1
0
mirror of https://github.com/flarum/core.git synced 2025-08-04 23:47:32 +02:00

feat: Code Splitting (#3860)

* feat: configure webpack to allow splitting chunks
* feat: `JsDirectoryCompiler` and expose js assets URL
* chore: support es2020 dynamic importing
* feat: control which URL to fetch chunks from
* feat: allow showing async modals & split 'LogInModal'
* feat: split `SignUpModal`
* feat: allow rendering async pages & split `UserSecurityPage`
* fix: module might not be listed in chunk
* feat: lazy load user pages
* feat: track the chunk containing each module
* chore: lightly warn
* chore: split `Composer`
* feat: add common frontend (for split common chunks)
* fix: jsDoc typing imports should be ignored
* feat: split `PostStream` `ForgotPasswordModal` and `EditUserModal`
* fix: multiple inline async imports not picked up
* chore: new `common` frontend assets only needs a jsdir compiler
* feat: add revision hash to chunk import URL
* fix: nothing to split for `admin` frontend yet
* chore: cleanup registry API
* chore: throw an error in debug mode if attempting to import a non-loaded module
* feat: defer `extend` & `override` until after module registration
* fix: plugin not picking up on all module sources
* fix: must override default chunk loader function from webpack plugin
* feat: split tags `TagDiscussionModal` and `TagSelectionModal`
* fix: wrong export name
* feat: import chunked modules from external packages
* feat: extensions compatibility
* feat: Router frontend extender async component
* chore: clean JS output path (removes stale chunks)
* fix: common chunks also need flushing
* chore: flush backend stale chunks
* Apply fixes from StyleCI
* feat: loading alert when async page component is loading
* chore: `yarn format`
* chore: typings
* chore: remove exception
* Apply fixes from StyleCI
* chore(infra): bundlewatch
* chore(infra): bundlewatch split chunks
* feat: split text editor
* chore: tag typings
* chore: bundlewatch
* fix: windows paths
* fix: wrong planned ext import format
This commit is contained in:
Sami Mazouz
2023-08-02 17:57:57 +01:00
committed by GitHub
parent 2ffbc44b4e
commit 229a7affa5
87 changed files with 1217 additions and 304 deletions

View File

@@ -54,7 +54,7 @@ declare type VnodeElementTag<Attrs = Record<string, unknown>, C extends Componen
* import app from 'flarum/common/app';
* ```
*/
declare const app: never;
declare const app: import('../common/Application').default;
declare const m: import('mithril').Static;
declare const dayjs: typeof import('dayjs');
@@ -98,8 +98,16 @@ interface FlarumObject {
* }
*/
extensions: Readonly<Record<string, ESModule>>;
reg: any;
/**
* Contains a registry of all exported modules,
* as well as chunks that can be imported and the modules
* each chunk contains.
*/
reg: import('../common/ExportRegistry').default;
/**
* For early operations, this object stores whether we are in debug mode or not.
*/
debug: boolean;
}
declare const flarum: FlarumObject;

View File

@@ -2,7 +2,6 @@ import Mithril from 'mithril';
import app from '../../admin/app';
import EditUserModal from '../../common/components/EditUserModal';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
@@ -432,7 +431,7 @@ export default class UserListPage extends AdminPage {
<Button
className="Button UserList-editModalBtn"
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
onclick={() => app.modal.show(EditUserModal, { user })}
onclick={() => app.modal.show(() => import('../../common/components/EditUserModal'), { user })}
>
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
</Button>

View File

@@ -16,7 +16,7 @@ export default class ExtensionPageResolver<
const extensionPage = app.extensionData.getPage<Attrs>(args.id);
if (extensionPage) {
return extensionPage;
return Promise.resolve(extensionPage);
}
return super.onmatch(args, requestedPath, route);

View File

@@ -60,6 +60,9 @@ export interface FlarumRequestOptions<ResponseType> extends Omit<Mithril.Request
modifyText?: (responseText: string) => string;
}
export type NewComponent<Comp> = new () => Comp;
export type AsyncNewComponent<Comp> = () => Promise<any & { default: NewComponent<Comp> }>;
/**
* A valid route definition.
*/
@@ -82,14 +85,14 @@ export type RouteItem<
/**
* The component to render when this route matches.
*/
component: new () => Comp;
component: NewComponent<Comp> | AsyncNewComponent<Comp>;
/**
* A custom resolver class.
*
* This should be the class itself, and **not** an instance of the
* class.
*/
resolverClass?: new (component: new () => Comp, routeName: string) => DefaultResolver<Attrs, Comp, RouteArgs>;
resolverClass?: new (component: NewComponent<Comp> | AsyncNewComponent<Comp>, routeName: string) => DefaultResolver<Attrs, Comp, RouteArgs>;
}
| {
/**
@@ -113,7 +116,7 @@ export interface RouteResolver<
*
* @see https://mithril.js.org/route.html#routeresolveronmatch
*/
onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): { new (): Comp };
onmatch(this: this, args: RouteArgs, requestedPath: string, route: string): Promise<{ new (): Comp }>;
/**
* A function which renders the provided component.
*

View File

@@ -7,6 +7,7 @@ export interface IExportRegistry {
/**
* Add an instance to the registry.
* Identified by a namespace (extension ID) and an ID (module path).
*/
add(namespace: string, id: string, object: any): void;
@@ -17,14 +18,79 @@ export interface IExportRegistry {
onLoad(namespace: string, id: string, handler: Function): void;
/**
* Retrieve an object of type `id` from the registry.
* Retrieve a module from the registry by namespace and ID.
*/
get(namespace: string, id: string): any;
}
export default class ExportRegistry implements IExportRegistry {
/**
* @internal
*/
export interface IChunkRegistry {
chunks: Map<string, Chunk>;
chunkModules: Map<string, Module>;
/**
* Check if a module has been loaded.
* Return the module if so, false otherwise.
*/
checkModule(namespace: string, id: string): any | false;
/**
* Register a module by the chunk ID it belongs to, the webpack module ID it belongs to,
* the namespace (extension ID), and its path.
*/
addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void;
/**
* Get a registered chunk. Each chunk has at least one module (the default one).
*/
getChunk(chunkId: number | string): Chunk | null;
/**
* The chunk loader which overrides the default Webpack chunk loader.
*/
loadChunk(original: Function, url: string, done: () => Promise<void>, key: number, chunkId: number | string): Promise<void>;
/**
* Responsible for loading external chunks.
* Called automatically when an extension/package tries to async import a chunked module.
*/
asyncModuleImport(path: string): Promise<any>;
}
type Chunk = {
/**
* The extension id of the chunk or 'core'.
*/
namespace: string;
/**
* The relative URL path to the chunk.
*/
urlPath: string;
/**
* An array of modules included in the chunk, by relative module path.
*/
modules?: string[];
};
type Module = {
/**
* The chunk ID the module belongs to.
*/
chunkId: string;
/**
* The module ID. Not unique, as most chunk modules are concatenated into one module.
*/
moduleId: string;
};
export default class ExportRegistry implements IExportRegistry, IChunkRegistry {
moduleExports = new Map<string, Map<string, any>>();
onLoads = new Map<string, Map<string, Function[]>>();
chunks = new Map<string, Chunk>();
chunkModules = new Map<string, Module>();
private _revisions: any = null;
add(namespace: string, id: string, object: any): void {
this.moduleExports.set(namespace, this.moduleExports.get(namespace) || new Map());
@@ -36,7 +102,7 @@ export default class ExportRegistry implements IExportRegistry {
?.forEach((handler) => handler(object));
}
onLoad(namespace: string, id: string, handler: Function): void {
onLoad(namespace: string, id: string, handler: (module: any) => void): void {
if (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) {
handler(this.moduleExports.get(namespace)?.get(id));
} else {
@@ -48,11 +114,126 @@ export default class ExportRegistry implements IExportRegistry {
get(namespace: string, id: string): any {
const module = this.moduleExports.get(namespace)?.get(id);
const error = `No module found for ${namespace}:${id}`;
if (!module) {
console.warn(`No module found for ${namespace}:${id}`);
// @ts-ignore
if (!module && flarum.debug) {
throw new Error(error);
} else if (!module) {
console.warn(error);
}
return module;
}
public checkModule(namespace: string, id: string): any | false {
const exists = (this.moduleExports.has(namespace) && this.moduleExports.get(namespace)?.has(id)) || false;
return exists ? this.get(namespace, id) : false;
}
addChunkModule(chunkId: number | string, moduleId: number | string, namespace: string, urlPath: string): void {
if (!this.chunks.has(chunkId.toString())) {
this.chunks.set(chunkId.toString(), {
namespace,
urlPath,
modules: [urlPath],
});
} else {
this.chunks.get(chunkId.toString())?.modules?.push(urlPath);
}
this.chunkModules.set(`${namespace}:${urlPath}`, {
chunkId: chunkId.toString(),
moduleId: moduleId.toString(),
});
}
getChunk(chunkId: number | string): Chunk | null {
const chunk = this.chunks.get(chunkId.toString()) ?? null;
if (!chunk) {
console.warn(`[Export Registry] No chunk by the ID ${chunkId} found.`);
return null;
}
return chunk;
}
async loadChunk(original: Function, url: string, done: (...args: any) => Promise<void>, key: number, chunkId: number | string): Promise<void> {
// @ts-ignore
app.alerts.showLoading();
return await original(
this.chunkUrl(chunkId) || url,
(...args: any) => {
// @ts-ignore
app.alerts.clearLoading();
return done(...args);
},
key,
chunkId
);
}
chunkUrl(chunkId: number | string): string | null {
const chunk = this.getChunk(chunkId.toString());
if (!chunk) return null;
this._revisions ??= JSON.parse(document.getElementById('flarum-rev-manifest')?.textContent ?? '{}');
// @ts-ignore cannot import the app object here, so we use the global one.
const path = `${app.forum.attribute<string>('jsChunksBaseUrl')}/${chunk.namespace}/${chunk.urlPath}.js`;
// The paths in the revision are stored as (relative path from the assets path) + the path.
// @ts-ignore
const assetsPath = app.forum.attribute<string>('assetsBaseUrl');
const key = path.replace(assetsPath, '').replace(/^\//, '');
const revision = this._revisions[key];
return revision ? `${path}?v=${revision}` : path;
}
async asyncModuleImport(path: string): Promise<any> {
const [namespace, id] = this.namespaceAndIdFromPath(path);
const module = this.chunkModules.get(`${namespace}:${id}`);
if (!module) {
throw new Error(`No chunk found for module ${namespace}:${id}`);
}
// @ts-ignore
const wr = __webpack_require__;
return await wr.e(module.chunkId).then(() => {
// Needed to make sure the module is loaded.
// Taken care of by webpack.
wr.bind(wr, module.moduleId)();
const moduleExport = this.get(namespace, id);
// For consistent access to async modules.
moduleExport.default = moduleExport.default || moduleExport;
return moduleExport;
});
}
namespaceAndIdFromPath(path: string): [string, string] {
// Either we get a path like `flarum/forum/components/LogInModal` or `ext:flarum/tags/forum/components/TagPage`.
const matches = /^(?:ext:([^\/]+)\/(?:flarum-(?:ext-)?)?([^\/]+)|(flarum))(?:\/(.+))?$/.exec(path);
const id = matches![4];
let namespace;
if (matches![1]) {
namespace = `${matches![1]}-${matches![2]}`;
} else {
namespace = 'core';
}
return [namespace, id];
}
}

View File

@@ -64,9 +64,7 @@ import './components/ModalManager';
import './components/Button';
import './components/Modal';
import './components/GroupBadge';
import './components/TextEditor';
import './components/TextEditorButton';
import './components/EditUserModal';
import './components/Tooltip';
import './helpers/fullTime';

View File

@@ -6,6 +6,7 @@ import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock';
import type ModalManagerState from '../states/ModalManagerState';
import type Mithril from 'mithril';
import LoadingIndicator from './LoadingIndicator';
interface IModalManagerAttrs {
state: ModalManagerState;
@@ -60,13 +61,15 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
);
})}
{this.attrs.state.backdropShown && (
{(this.attrs.state.backdropShown || this.attrs.state.loadingModal) && (
<div
className="Modal-backdrop backdrop"
ontransitionend={this.onBackdropTransitionEnd.bind(this)}
data-showing={!!this.attrs.state.modalList.length}
style={{ '--modal-count': this.attrs.state.modalList.length }}
/>
data-showing={!!this.attrs.state.modalList.length || this.attrs.state.loadingModal}
style={{ '--modal-count': this.attrs.state.modalList.length + Number(this.attrs.state.loadingModal) }}
>
{this.attrs.state.loadingModal && <LoadingIndicator />}
</div>
)}
</>
);

View File

@@ -7,6 +7,7 @@ import Button from './Button';
import BasicEditorDriver from '../utils/BasicEditorDriver';
import Tooltip from './Tooltip';
import LoadingIndicator from './LoadingIndicator';
/**
* The `TextEditor` component displays a textarea with controls, including a
@@ -36,17 +37,33 @@ export default class TextEditor extends Component {
* Whether the editor is disabled.
*/
this.disabled = !!this.attrs.disabled;
/**
* Whether the editor is loading.
*/
this.loading = true;
/**
* Async operations to complete before the editor is ready.
*/
this._loaders = [];
}
view() {
return (
<div className="TextEditor">
<div className="TextEditor-editorContainer"></div>
{this.loading ? (
<LoadingIndicator />
) : (
<>
<div className="TextEditor-editorContainer"></div>
<ul className="TextEditor-controls Composer-footer">
{listItems(this.controlItems().toArray())}
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
</ul>
<ul className="TextEditor-controls Composer-footer">
{listItems(this.controlItems().toArray())}
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
</ul>
</>
)}
</div>
);
}
@@ -54,6 +71,12 @@ export default class TextEditor extends Component {
oncreate(vnode) {
super.oncreate(vnode);
this._load().then(() => {
setTimeout(this.onbuild.bind(this), 50);
});
}
onbuild() {
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
}
@@ -68,6 +91,13 @@ export default class TextEditor extends Component {
}
}
_load() {
return Promise.all(this._loaders.map((loader) => loader())).then(() => {
this.loading = false;
m.redraw();
});
}
buildEditorParams() {
return {
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],

View File

@@ -24,10 +24,19 @@
* @param callback A callback which mutates the method's output
*/
export function extend<T extends Record<string, any>, K extends KeyOfType<T, Function>>(
object: T,
object: T | string,
methods: K | K[],
callback: (this: T, val: ReturnType<T[K]>, ...args: Parameters<T[K]>) => void
) {
// A lazy loaded module, only apply the function after the module is loaded.
if (typeof object === 'string') {
let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object);
return flarum.reg.onLoad(namespace, id, (module) => {
extend(module.prototype, methods, callback);
});
}
const allMethods = Array.isArray(methods) ? methods : [methods];
allMethods.forEach((method: K) => {
@@ -73,17 +82,26 @@ export function extend<T extends Record<string, any>, K extends KeyOfType<T, Fun
* @param newMethod The method to replace it with
*/
export function override<T extends Record<any, any>, K extends KeyOfType<T, Function>>(
object: T,
object: T | string,
methods: K | K[],
newMethod: (this: T, orig: T[K], ...args: Parameters<T[K]>) => void
) {
// A lazy loaded module, only apply the function after the module is loaded.
if (typeof object === 'string') {
let [namespace, id] = flarum.reg.namespaceAndIdFromPath(object);
return flarum.reg.onLoad(namespace, id, (module) => {
override(module.prototype, methods, newMethod);
});
}
const allMethods = Array.isArray(methods) ? methods : [methods];
allMethods.forEach((method) => {
const original: Function = object[method];
object[method] = function (this: T, ...args: Parameters<T[K]>) {
return newMethod.apply(this, [original.bind(this), ...args]);
return newMethod.apply(this, [original?.bind(this), ...args]);
} as T[K];
Object.assign(object[method], original);

View File

@@ -1,4 +1,4 @@
import Application, { FlarumGenericRoute } from '../Application';
import Application, { AsyncNewComponent, FlarumGenericRoute, NewComponent } from '../Application';
import IExtender, { IExtensionModule } from './IExtender';
type HelperRoute = (...args: any) => string;
@@ -14,7 +14,7 @@ export default class Routes implements IExtender {
* @param path The path of the route.
* @param component must extend `Page` component.
*/
add(name: string, path: `/${string}`, component: any): Routes {
add(name: string, path: `/${string}`, component: NewComponent<any> | AsyncNewComponent<any>): Routes {
this.routes[name] = { path, component };
return this;

View File

@@ -1,6 +1,7 @@
import type Mithril from 'mithril';
import type { RouteResolver } from '../Application';
import type { default as Component, ComponentAttrs } from '../Component';
import type { AsyncNewComponent, NewComponent, RouteResolver } from '../Application';
import type { ComponentAttrs } from '../Component';
import Component from '../Component';
/**
* Generates a route resolver for a given component.
@@ -15,10 +16,10 @@ export default class DefaultResolver<
RouteArgs extends Record<string, unknown> = {}
> implements RouteResolver<Attrs, Comp, RouteArgs>
{
component: new () => Comp;
component: NewComponent<Comp> | AsyncNewComponent<Comp>;
routeName: string;
constructor(component: new () => Comp, routeName: string) {
constructor(component: NewComponent<Comp> | AsyncNewComponent<Comp>, routeName: string) {
this.component = component;
this.routeName = routeName;
}
@@ -39,8 +40,12 @@ export default class DefaultResolver<
};
}
onmatch(args: RouteArgs, requestedPath: string, route: string): { new (): Comp } {
return this.component;
async onmatch(args: RouteArgs, requestedPath: string, route: string): Promise<NewComponent<Comp>> {
if (this.component.prototype instanceof Component) {
return this.component as NewComponent<Comp>;
}
return (await (this.component as AsyncNewComponent<Comp>)()).default;
}
render(vnode: Mithril.Vnode<Attrs, Comp>): Mithril.Children {

View File

@@ -1,5 +1,6 @@
import type Mithril from 'mithril';
import Alert, { AlertAttrs } from '../components/Alert';
import app from '../app';
/**
* Returned by `AlertManagerState.show`. Used to dismiss alerts.
@@ -17,6 +18,7 @@ export interface AlertState {
export default class AlertManagerState {
protected activeAlerts: AlertArray = {};
protected alertId: AlertIdentifier = 0;
protected loadingPool: number = 0;
getActiveAlerts() {
return this.activeAlerts;
@@ -71,4 +73,30 @@ export default class AlertManagerState {
this.activeAlerts = {};
m.redraw();
}
/**
* Shows a loading alert.
*/
showLoading(): AlertIdentifier | null {
this.loadingPool++;
if (this.loadingPool > 1) return null;
return this.show(
{
type: 'warning',
dismissible: false,
},
app.translator.trans('core.lib.loading_indicator.accessible_label')
);
}
/**
* Hides a loading alert.
*/
clearLoading(): void {
this.loadingPool--;
if (this.loadingPool === 0) this.clear();
}
}

View File

@@ -10,6 +10,12 @@ import Modal, { IDismissibleOptions } from '../components/Modal';
*/
type UnsafeModalClass = ComponentClass<any, Modal> & { get dismissibleOptions(): IDismissibleOptions; component: typeof Component.component };
/**
* Alternatively, `show` takes an async function that returns a modal class.
* This is useful for lazy-loading modals.
*/
type AsyncModalClass = () => Promise<any & { default: UnsafeModalClass }>;
type ModalItem = {
componentClass: UnsafeModalClass;
attrs?: Record<string, unknown>;
@@ -37,6 +43,11 @@ export default class ModalManagerState {
*/
backdropShown: boolean = false;
/**
* @internal
*/
loadingModal: boolean = false;
/**
* Used to force re-initialization of modals if a modal
* is replaced by another of the same type.
@@ -61,14 +72,24 @@ export default class ModalManagerState {
* @example <caption>Stacking modals</caption>
* app.modal.show(MyCoolStackedModal, { attr: 'value' }, true);
*/
show(componentClass: UnsafeModalClass, attrs: Record<string, unknown> = {}, stackModal: boolean = false): void {
if (!(componentClass.prototype instanceof Modal)) {
async show(componentClass: UnsafeModalClass | AsyncModalClass, attrs: Record<string, unknown> = {}, stackModal: boolean = false): Promise<void> {
if (!(componentClass.prototype instanceof Modal) && typeof componentClass !== 'function') {
// This is duplicated so that if the error is caught, an error message still shows up in the debug console.
const invalidModalWarning = 'The ModalManager can only show Modals.';
console.error(invalidModalWarning);
throw new Error(invalidModalWarning);
}
if (!(componentClass.prototype instanceof Modal)) {
this.loadingModal = true;
m.redraw.sync();
componentClass = componentClass as AsyncModalClass;
componentClass = (await componentClass()).default;
this.loadingModal = false;
}
this.backdropShown = true;
m.redraw.sync();
@@ -79,6 +100,8 @@ export default class ModalManagerState {
// skip this RAF call, the hook will attempt to add a focus trap as well as lock scroll
// onto the newly added modal before it's in the DOM, creating an extra scrollbar.
requestAnimationFrame(() => {
componentClass = componentClass as UnsafeModalClass;
// Set current modal
this.modal = { componentClass, attrs, key: this.key++ };

View File

@@ -3,10 +3,8 @@ import app from './app';
import History from './utils/History';
import Pane from './utils/Pane';
import DiscussionPage from './components/DiscussionPage';
import SignUpModal from './components/SignUpModal';
import HeaderPrimary from './components/HeaderPrimary';
import HeaderSecondary from './components/HeaderSecondary';
import Composer from './components/Composer';
import DiscussionRenamedNotification from './components/DiscussionRenamedNotification';
import CommentPost from './components/CommentPost';
import DiscussionRenamedPost from './components/DiscussionRenamedPost';
@@ -119,7 +117,6 @@ export default class ForumApplication extends Application {
m.mount(document.getElementById('header-navigation')!, Navigation);
m.mount(document.getElementById('header-primary')!, HeaderPrimary);
m.mount(document.getElementById('header-secondary')!, HeaderSecondary);
m.mount(document.getElementById('composer')!, { view: () => <Composer state={this.composer} /> });
alertEmailConfirmation(this);
@@ -164,7 +161,7 @@ export default class ForumApplication extends Application {
if (payload.loggedIn) {
window.location.reload();
} else {
this.modal.show(SignUpModal, payload);
this.modal.show(() => import('./components/SignUpModal'), payload);
}
}
}

View File

@@ -4,7 +4,6 @@ import classList from '../../common/utils/classList';
import PostUser from './PostUser';
import PostMeta from './PostMeta';
import PostEdited from './PostEdited';
import EditPostComposer from './EditPostComposer';
import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import Button from '../../common/components/Button';
@@ -88,6 +87,10 @@ export default class CommentPost extends Post {
}
isEditing() {
const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer');
if (!EditPostComposer) return false;
return app.composer.bodyMatches(EditPostComposer, { post: this.attrs.post });
}

View File

@@ -5,8 +5,6 @@ import Page, { IPageAttrs } from '../../common/components/Page';
import ItemList from '../../common/utils/ItemList';
import DiscussionHero from './DiscussionHero';
import DiscussionListPane from './DiscussionListPane';
import PostStream from './PostStream';
import PostStreamScrubber from './PostStreamScrubber';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import SplitDropdown from '../../common/components/SplitDropdown';
import listItems from '../../common/helpers/listItems';
@@ -26,6 +24,11 @@ export interface IDiscussionPageAttrs extends IPageAttrs {
* the discussion list pane, the hero, the posts, and the sidebar.
*/
export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = IDiscussionPageAttrs> extends Page<CustomAttrs> {
protected loading: boolean = true;
protected PostStream: any = null;
protected PostStreamScrubber: any = null;
/**
* The discussion that is being viewed.
*/
@@ -83,7 +86,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
return (
<div className="DiscussionPage">
<DiscussionListPane state={app.discussions} />
<div className="DiscussionPage-discussion">{this.discussion ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
<div className="DiscussionPage-discussion">{!this.loading ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
</div>
);
}
@@ -140,7 +143,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
items.add(
'poststream',
<div className="DiscussionPage-stream">
<PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
<this.PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
</div>,
10
);
@@ -152,18 +155,23 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
* Load the discussion from the API or use the preloaded one.
*/
load(): void {
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
if (preloadedDiscussion) {
// We must wrap this in a setTimeout because if we are mounting this
// component for the first time on page load, then any calls to m.redraw
// will be ineffective and thus any configs (scroll code) will be run
// before stuff is drawn to the page.
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
} else {
const params = this.requestParams();
Promise.all([import('./PostStream'), import('./PostStreamScrubber')]).then(([PostStreamImport, PostStreamScrubberImport]) => {
this.PostStream = PostStreamImport.default;
this.PostStreamScrubber = PostStreamScrubberImport.default;
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
}
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
if (preloadedDiscussion) {
// We must wrap this in a setTimeout because if we are mounting this
// component for the first time on page load, then any calls to m.redraw
// will be ineffective and thus any configs (scroll code) will be run
// before stuff is drawn to the page.
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
} else {
const params = this.requestParams();
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
}
});
m.redraw();
}
@@ -183,6 +191,8 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
* Initialize the component to display the given discussion.
*/
show(discussion: ApiResponseSingle<Discussion>): void {
this.loading = false;
app.history.push('discussion', discussion.title());
app.setTitle(discussion.title());
app.setTitleCount(0);
@@ -249,7 +259,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
);
}
items.add('scrubber', <PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
items.add('scrubber', <this.PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
return items;
}

View File

@@ -1,8 +1,6 @@
import app from '../../forum/app';
import Component from '../../common/Component';
import Button from '../../common/components/Button';
import LogInModal from './LogInModal';
import SignUpModal from './SignUpModal';
import SessionDropdown from './SessionDropdown';
import SelectDropdown from '../../common/components/SelectDropdown';
import NotificationsDropdown from './NotificationsDropdown';
@@ -71,7 +69,7 @@ export default class HeaderSecondary extends Component {
if (app.forum.attribute('allowSignUp')) {
items.add(
'signUp',
<Button className="Button Button--link" onclick={() => app.modal.show(SignUpModal)}>
<Button className="Button Button--link" onclick={() => app.modal.show(() => import('./SignUpModal'))}>
{app.translator.trans('core.forum.header.sign_up_link')}
</Button>,
10
@@ -80,7 +78,7 @@ export default class HeaderSecondary extends Component {
items.add(
'logIn',
<Button className="Button Button--link" onclick={() => app.modal.show(LogInModal)}>
<Button className="Button Button--link" onclick={() => app.modal.show(() => import('./LogInModal'))}>
{app.translator.trans('core.forum.header.log_in_link')}
</Button>,
0

View File

@@ -4,8 +4,6 @@ import ItemList from '../../common/utils/ItemList';
import listItems from '../../common/helpers/listItems';
import DiscussionList from './DiscussionList';
import WelcomeHero from './WelcomeHero';
import DiscussionComposer from './DiscussionComposer';
import LogInModal from './LogInModal';
import DiscussionPage from './DiscussionPage';
import Dropdown from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
@@ -284,12 +282,11 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
newDiscussionAction(): Promise<unknown> {
return new Promise((resolve, reject) => {
if (app.session.user) {
app.composer.load(DiscussionComposer, { user: app.session.user });
app.composer.show();
app.composer.load(() => import('./DiscussionComposer'), { user: app.session.user }).then(() => app.composer.show());
return resolve(app.composer);
} else {
app.modal.show(LogInModal);
app.modal.show(() => import('./LogInModal'));
return reject();
}

View File

@@ -1,7 +1,5 @@
import app from '../../forum/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
import ForgotPasswordModal from './ForgotPasswordModal';
import SignUpModal from './SignUpModal';
import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText';
@@ -141,7 +139,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
const email = this.identification();
const attrs = email.includes('@') ? { email } : undefined;
app.modal.show(ForgotPasswordModal, attrs);
app.modal.show(() => import('./ForgotPasswordModal'), attrs);
}
/**
@@ -155,7 +153,7 @@ export default class LogInModal<CustomAttrs extends ILoginModalAttrs = ILoginMod
[identification.includes('@') ? 'email' : 'username']: identification,
};
app.modal.show(SignUpModal, attrs);
app.modal.show(() => import('./SignUpModal'), attrs);
}
onready() {

View File

@@ -1,7 +1,7 @@
import app from '../../forum/app';
import Component from '../../common/Component';
import ScrollListener from '../../common/utils/ScrollListener';
import PostLoading from './LoadingPost';
import LoadingPost from './LoadingPost';
import ReplyPlaceholder from './ReplyPlaceholder';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
@@ -75,7 +75,7 @@ export default class PostStream extends Component {
} else {
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
content = <PostLoading />;
content = <LoadingPost />;
}
return (

View File

@@ -1,6 +1,5 @@
import app from '../../forum/app';
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
import LogInModal from './LogInModal';
import Button from '../../common/components/Button';
import LogInButtons from './LogInButtons';
import extractText from '../../common/utils/extractText';
@@ -151,7 +150,7 @@ export default class SignUpModal<CustomAttrs extends ISignupModalAttrs = ISignup
identification: this.email() || this.username(),
};
app.modal.show(LogInModal, attrs);
app.modal.show(() => import('./LogInModal'), attrs);
}
onready() {

View File

@@ -8,7 +8,6 @@ import AccessTokensList from './AccessTokensList';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Button from '../../common/components/Button';
import NewAccessTokenModal from './NewAccessTokenModal';
import { camelCaseToSnakeCase } from '../../common/utils/string';
import type AccessToken from '../../common/models/AccessToken';
import type Mithril from 'mithril';
import Tooltip from '../../common/components/Tooltip';

View File

@@ -14,61 +14,40 @@ import './states/GlobalSearchState';
import './states/NotificationListState';
import './states/PostStreamState';
import './states/SearchState';
import './states/UserSecurityPageState';
import './components/AffixedSidebar';
import './components/DiscussionPage';
import './components/DiscussionListPane';
import './components/LogInModal';
import './components/ComposerBody';
import './components/ForgotPasswordModal';
import './components/Notification';
import './components/LogInButton';
import './components/DiscussionsUserPage';
import './components/Composer';
import './components/SessionDropdown';
import './components/HeaderPrimary';
import './components/PostEdited';
import './components/PostStream';
import './components/ChangePasswordModal';
import './components/IndexPage';
import './components/DiscussionRenamedNotification';
import './components/DiscussionsSearchSource';
import './components/HeaderSecondary';
import './components/ComposerButton';
import './components/DiscussionList';
import './components/ReplyPlaceholder';
import './components/AvatarEditor';
import './components/Post';
import './components/SettingsPage';
import './components/TerminalPost';
import './components/ChangeEmailModal';
import './components/NotificationsDropdown';
import './components/UserPage';
import './components/PostUser';
import './components/UserCard';
import './components/UsersSearchSource';
import './components/UserSecurityPage';
import './components/NotificationGrid';
import './components/PostPreview';
import './components/EventPost';
import './components/DiscussionHero';
import './components/PostMeta';
import './components/DiscussionRenamedPost';
import './components/DiscussionComposer';
import './components/LogInButtons';
import './components/NotificationList';
import './components/WelcomeHero';
import './components/SignUpModal';
import './components/CommentPost';
import './components/ComposerPostPreview';
import './components/ReplyComposer';
import './components/NotificationsPage';
import './components/PostStreamScrubber';
import './components/EditPostComposer';
import './components/RenameDiscussionModal';
import './components/Search';
import './components/DiscussionListItem';
import './components/LoadingPost';
import './components/PostsUserPage';
import './resolvers/DiscussionPageResolver';
import './routes';

View File

@@ -2,14 +2,10 @@ import ForumApplication from './ForumApplication';
import IndexPage from './components/IndexPage';
import DiscussionPage from './components/DiscussionPage';
import PostsUserPage from './components/PostsUserPage';
import DiscussionsUserPage from './components/DiscussionsUserPage';
import SettingsPage from './components/SettingsPage';
import NotificationsPage from './components/NotificationsPage';
import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
import Discussion from '../common/models/Discussion';
import type Post from '../common/models/Post';
import type User from '../common/models/User';
import UserSecurityPage from './components/UserSecurityPage';
/**
* Helper functions to generate URLs to form pages.
@@ -32,11 +28,11 @@ export default function (app: ForumApplication) {
user: { path: '/u/:username', component: PostsUserPage },
'user.posts': { path: '/u/:username', component: PostsUserPage },
'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage },
'user.discussions': { path: '/u/:username/discussions', component: () => import('./components/DiscussionsUserPage') },
settings: { path: '/settings', component: SettingsPage },
'user.security': { path: '/u/:username/security', component: UserSecurityPage },
notifications: { path: '/notifications', component: NotificationsPage },
settings: { path: '/settings', component: () => import('./components/SettingsPage') },
'user.security': { path: '/u/:username/security', component: () => import('./components/UserSecurityPage') },
notifications: { path: '/notifications', component: () => import('./components/NotificationsPage') },
};
}

View File

@@ -1,7 +1,7 @@
import app from '../../forum/app';
import subclassOf from '../../common/utils/subclassOf';
import Stream from '../../common/utils/Stream';
import ReplyComposer from '../components/ReplyComposer';
import Component from '../../common/Component';
class ComposerState {
constructor() {
@@ -34,15 +34,27 @@ class ComposerState {
*/
this.editor = null;
/**
* If the composer was loaded and mounted.
*
* @type {boolean}
*/
this.mounted = false;
this.clear();
}
/**
* Load a content component into the composer.
*
* @param {typeof import('../components/ComposerBody').default} componentClass
* @param {() => Promise<any & { default: typeof import('../components/ComposerBody') }> | typeof import('../components/ComposerBody').default} componentClass
* @param {object} attrs
*/
load(componentClass, attrs) {
async load(componentClass, attrs) {
if (!(componentClass.prototype instanceof Component)) {
componentClass = (await componentClass()).default;
}
const body = { componentClass, attrs };
if (this.preventExit()) return;
@@ -81,7 +93,13 @@ class ComposerState {
/**
* Show the composer.
*/
show() {
async show() {
if (!this.mounted) {
const Composer = (await import('../components/Composer')).default;
m.mount(document.getElementById('composer'), { view: () => <Composer state={this} /> });
this.mounted = true;
}
if (this.position === ComposerState.Position.NORMAL || this.position === ComposerState.Position.FULLSCREEN) return;
this.position = ComposerState.Position.NORMAL;
@@ -185,6 +203,10 @@ class ComposerState {
* @return {boolean}
*/
composingReplyTo(discussion) {
const ReplyComposer = flarum.reg.checkModule('core', 'forum/components/ReplyComposer');
if (!ReplyComposer) return false;
return this.isVisible() && this.bodyMatches(ReplyComposer, { discussion });
}

View File

@@ -1,7 +1,5 @@
import app from '../../forum/app';
import DiscussionPage from '../components/DiscussionPage';
import ReplyComposer from '../components/ReplyComposer';
import LogInModal from '../components/LogInModal';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import RenameDiscussionModal from '../components/RenameDiscussionModal';
@@ -168,12 +166,15 @@ const DiscussionControls = {
if (app.session.user) {
if (this.canReply()) {
if (!app.composer.composingReplyTo(this) || forceRefresh) {
app.composer.load(ReplyComposer, {
user: app.session.user,
discussion: this,
});
app.composer
.load(() => import('../components/ReplyComposer'), {
user: app.session.user,
discussion: this,
})
.then(() => app.composer.show());
} else {
app.composer.show();
}
app.composer.show();
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
app.current.get('stream').goToNumber('reply');
@@ -185,7 +186,7 @@ const DiscussionControls = {
}
}
app.modal.show(LogInModal);
app.modal.show(() => import('../components/LogInModal'));
return reject();
});

View File

@@ -1,5 +1,4 @@
import app from '../../forum/app';
import EditPostComposer from '../components/EditPostComposer';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import ItemList from '../../common/utils/ItemList';
@@ -121,8 +120,7 @@ const PostControls = {
*/
editAction() {
return new Promise((resolve) => {
app.composer.load(EditPostComposer, { post: this });
app.composer.show();
app.composer.load(() => import('../components/EditPostComposer'), { post: this }).then(() => app.composer.show());
return resolve();
});

View File

@@ -1,7 +1,6 @@
import app from '../../forum/app';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import EditUserModal from '../../common/components/EditUserModal';
import UserPage from '../components/UserPage';
import ItemList from '../../common/utils/ItemList';
@@ -143,7 +142,7 @@ const UserControls = {
* @param {import('../../common/models/User').default} user
*/
editAction(user) {
app.modal.show(EditUserModal, { user });
app.modal.show(() => import('../../common/components/EditUserModal'), { user });
},
};

View File

@@ -3,6 +3,7 @@
border-radius: var(--border-radius);
line-height: 1.5;
--loading-indicator-color: var(--alert-color);
background: var(--alert-bg);
&,

View File

@@ -22,7 +22,7 @@
--size: 24px;
--thickness: 2px;
color: var(--muted-color);
color: var(--loading-indicator-color);
// Center vertically and horizontally
// Allows people to set `height` and it'll stay centered within the new height

View File

@@ -20,16 +20,19 @@
}
.Modal-backdrop {
--loading-indicator-color: var(--body-bg);
background: var(--overlay-bg);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
opacity: 0;
transition: opacity 0.2s ease-out;
z-index: ~"calc(var(--zindex-modal) + var(--modal-count) - 2)";
display: flex;
align-items: center;
justify-content: center;
&[data-showing] {
opacity: 1;
}

View File

@@ -81,6 +81,8 @@
--tooltip-bg: @tooltip-bg;
--tooltip-color: @tooltip-color;
--loading-indicator-color: var(--muted-color);
--online-user-circle-color: @online-user-circle-color;
--discussion-title-color: mix(@heading-color, @body-bg, 55%);

View File

@@ -90,6 +90,7 @@ class ForumSerializer extends AbstractSerializer
'canCreateAccessToken' => $this->actor->can('createAccessToken'),
'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'),
'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'),
'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'),
];
if ($this->actor->can('administrate')) {

View File

@@ -36,6 +36,7 @@ class Frontend implements ExtenderInterface
private array $content = [];
private array $preloadArrs = [];
private ?string $titleDriver = null;
private array $jsDirectory = [];
/**
* @param string $frontend: The name of the frontend.
@@ -71,6 +72,20 @@ class Frontend implements ExtenderInterface
return $this;
}
/**
* Add a directory of JavaScript files to include in the JS assets public directory.
* Primarily used to copy JS chunks.
*
* @param string $path The path to the specific frontend chunks directory.
* @return $this
*/
public function jsDirectory(string $path): self
{
$this->jsDirectory[] = $path;
return $this;
}
/**
* Add a route to the frontend.
*
@@ -183,7 +198,7 @@ class Frontend implements ExtenderInterface
private function registerAssets(Container $container, string $moduleName): void
{
if (empty($this->css) && empty($this->js)) {
if (empty($this->css) && empty($this->js) && empty($this->jsDirectory)) {
return;
}
@@ -209,6 +224,14 @@ class Frontend implements ExtenderInterface
}
});
}
if (! empty($this->jsDirectory)) {
$assets->jsDirectory(function (SourceCollector $sources) use ($moduleName) {
foreach ($this->jsDirectory as $path) {
$sources->addDirectory($path, $moduleName);
}
});
}
});
if (! $container->bound($abstract)) {

View File

@@ -17,6 +17,7 @@ use Illuminate\Contracts\Container\Container;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Intervention\Image\ImageManager;
use League\Flysystem\Visibility;
use RuntimeException;
class FilesystemServiceProvider extends AbstractServiceProvider
@@ -33,8 +34,9 @@ class FilesystemServiceProvider extends AbstractServiceProvider
return [
'flarum-assets' => function (Paths $paths, UrlGenerator $url) {
return [
'root' => "$paths->public/assets",
'url' => $url->to('forum')->path('assets')
'root' => "$paths->public/assets",
'url' => $url->to('forum')->path('assets'),
'visibility' => Visibility::PUBLIC
];
},
'flarum-avatars' => function (Paths $paths, UrlGenerator $url) {

View File

@@ -110,6 +110,10 @@ class ForumServiceProvider extends AbstractServiceProvider
});
});
$assets->jsDirectory(function (SourceCollector $sources) {
$sources->addDirectory(__DIR__.'/../../js/dist/forum', 'core');
});
$assets->css(function (SourceCollector $sources) use ($container) {
$sources->addFile(__DIR__.'/../../less/forum.less');
$sources->addString(function () use ($container) {

View File

@@ -11,6 +11,7 @@ namespace Flarum\Frontend;
use Flarum\Frontend\Compiler\CompilerInterface;
use Flarum\Frontend\Compiler\JsCompiler;
use Flarum\Frontend\Compiler\JsDirectoryCompiler;
use Flarum\Frontend\Compiler\LessCompiler;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Illuminate\Contracts\Filesystem\Cloud;
@@ -29,7 +30,8 @@ class Assets
'js' => [],
'css' => [],
'localeJs' => [],
'localeCss' => []
'localeCss' => [],
'jsDirectory' => [],
];
protected array $lessImportOverrides = [];
@@ -72,6 +74,13 @@ class Assets
return $this;
}
public function jsDirectory(callable $callback): static
{
$this->addSources('jsDirectory', $callback);
return $this;
}
private function addSources(string $type, callable $callback): void
{
$this->sources[$type][] = $callback;
@@ -122,6 +131,15 @@ class Assets
return $compiler;
}
public function makeJsDirectory(): JsDirectoryCompiler
{
$compiler = $this->makeJsDirectoryCompiler('js'.DIRECTORY_SEPARATOR.'{ext}'.DIRECTORY_SEPARATOR.$this->name);
$this->populate($compiler, 'jsDirectory');
return $compiler;
}
protected function makeJsCompiler(string $filename): JsCompiler
{
return resolve(JsCompiler::class, [
@@ -158,6 +176,14 @@ class Assets
return $compiler;
}
protected function makeJsDirectoryCompiler(string $string): JsDirectoryCompiler
{
return resolve(JsDirectoryCompiler::class, [
'assetsDir' => $this->assetsDir,
'destinationPath' => $string
]);
}
public function getName(): string
{
return $this->name;

View File

@@ -11,7 +11,7 @@ namespace Flarum\Frontend\Compiler;
interface CompilerInterface
{
public function getFilename(): string;
public function getFilename(): ?string;
public function setFilename(string $filename): void;

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Frontend\Compiler\Concerns;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Flarum\Frontend\Compiler\Source\SourceInterface;
trait HasSources
{
/**
* @var callable[]
*/
protected $sourcesCallbacks = [];
public function addSources(callable $callback): void
{
$this->sourcesCallbacks[] = $callback;
}
/**
* @return SourceInterface[]
*/
protected function getSources(): array
{
$sources = new SourceCollector($this->allowedSourceTypes());
foreach ($this->sourcesCallbacks as $callback) {
$callback($sources);
}
return $sources->getSources();
}
abstract protected function allowedSourceTypes(): array;
}

View File

@@ -48,4 +48,13 @@ class FileVersioner implements VersionerInterface
return null;
}
public function allRevisions(): array
{
if ($contents = $this->filesystem->get(static::REV_MANIFEST)) {
return json_decode($contents, true);
}
return [];
}
}

View File

@@ -0,0 +1,137 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Frontend\Compiler;
use Flarum\Frontend\Compiler\Concerns\HasSources;
use Flarum\Frontend\Compiler\Source\DirectorySource;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Illuminate\Contracts\Filesystem\Cloud;
use Illuminate\Filesystem\FilesystemAdapter;
/**
* Used to copy JS files from a package directory to the assets' directory.
* Without concatenating them. Primarily used for lazy loading JS modules.
*
* @method DirectorySource[] getSources()
*/
class JsDirectoryCompiler implements CompilerInterface
{
use HasSources;
protected VersionerInterface $versioner;
public function __construct(
protected Cloud $assetsDir,
protected string $destinationPath
) {
$this->versioner = new FileVersioner($assetsDir);
}
public function getFilename(): ?string
{
return null;
}
public function setFilename(string $filename): void
{
//
}
public function commit(bool $force = false): void
{
foreach ($this->getSources() as $source) {
$this->compileSource($source, $force);
}
}
public function getUrl(): ?string
{
return null;
}
public function flush(): void
{
foreach ($this->getSources() as $source) {
$this->flushSource($source);
}
// Delete the remaining empty directory.
$this->assetsDir->deleteDirectory($this->destinationPath);
}
protected function allowedSourceTypes(): array
{
return [DirectorySource::class];
}
protected function compileSource(DirectorySource $source, bool $force = false): void
{
$this->eachFile($source, fn (JsCompiler $compiler) => $compiler->commit($force));
}
protected function flushSource(DirectorySource $source): void
{
$this->eachFile($source, fn (JsCompiler $compiler) => $compiler->flush());
$destinationDir = $this->destinationFor($source);
// Destination can still contain stale chunks.
$this->assetsDir->deleteDirectory($destinationDir);
// Delete stale revisions.
$remainingRevisions = $this->versioner->allRevisions();
foreach ($remainingRevisions as $filename => $revision) {
if (str_starts_with($filename, $destinationDir)) {
$this->versioner->putRevision($filename, null);
}
}
}
protected function eachFile(DirectorySource $source, callable $callback): void
{
$filesystem = $source->getFilesystem();
foreach ($filesystem->allFiles() as $relativeFilePath) {
// Skip non-JS files.
if ($filesystem->mimeType($relativeFilePath) !== 'application/javascript') {
continue;
}
$jsCompiler = $this->compilerFor($source, $filesystem, $relativeFilePath);
$callback($jsCompiler);
}
}
protected function compilerFor(DirectorySource $source, FilesystemAdapter $filesystem, string $relativeFilePath): JsCompiler
{
// Filesystem's root is the actual directory we want to copy.
// The destination path is relative to the assets' filesystem.
$jsCompiler = resolve(JsCompiler::class, [
'assetsDir' => $this->assetsDir,
// We put each file in `js/extensionId/frontend` (path provided) `/relativeFilePath` (such as `components/LogInModal.js`).
'filename' => $this->destinationFor($source, $relativeFilePath),
]);
$jsCompiler->addSources(
fn (SourceCollector $sources) => $sources->addFile($filesystem->path($relativeFilePath), $source->getExtensionId())
);
return $jsCompiler;
}
protected function destinationFor(DirectorySource $source, ?string $relativeFilePath = null): string
{
$extensionId = $source->getExtensionId() ?? 'core';
return str_replace('{ext}', $extensionId, $this->destinationPath).DIRECTORY_SEPARATOR.$relativeFilePath;
}
}

View File

@@ -9,8 +9,10 @@
namespace Flarum\Frontend\Compiler;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Flarum\Frontend\Compiler\Concerns\HasSources;
use Flarum\Frontend\Compiler\Source\FileSource;
use Flarum\Frontend\Compiler\Source\SourceInterface;
use Flarum\Frontend\Compiler\Source\StringSource;
use Illuminate\Contracts\Filesystem\Cloud;
/**
@@ -18,38 +20,17 @@ use Illuminate\Contracts\Filesystem\Cloud;
*/
class RevisionCompiler implements CompilerInterface
{
use HasSources;
const EMPTY_REVISION = 'empty';
/**
* @var Cloud
*/
protected $assetsDir;
protected VersionerInterface $versioner;
/**
* @var VersionerInterface
*/
protected $versioner;
/**
* @var string
*/
protected $filename;
/**
* @var callable[]
*/
protected $sourcesCallbacks = [];
/**
* @param Cloud $assetsDir
* @param string $filename
* @param VersionerInterface|null $versioner @deprecated nullable will be removed at v2.0
*/
public function __construct(Cloud $assetsDir, string $filename, VersionerInterface $versioner = null)
{
$this->assetsDir = $assetsDir;
$this->filename = $filename;
$this->versioner = $versioner ?: new FileVersioner($assetsDir);
public function __construct(
protected Cloud $assetsDir,
protected string $filename,
) {
$this->versioner = new FileVersioner($assetsDir);
}
public function getFilename(): string
@@ -84,25 +65,6 @@ class RevisionCompiler implements CompilerInterface
}
}
public function addSources(callable $callback): void
{
$this->sourcesCallbacks[] = $callback;
}
/**
* @return SourceInterface[]
*/
protected function getSources(): array
{
$sources = new SourceCollector;
foreach ($this->sourcesCallbacks as $callback) {
$callback($sources);
}
return $sources->getSources();
}
public function getUrl(): ?string
{
$revision = $this->versioner->getRevision($this->filename);
@@ -197,4 +159,9 @@ class RevisionCompiler implements CompilerInterface
$this->assetsDir->delete($file);
}
}
protected function allowedSourceTypes(): array
{
return [FileSource::class, StringSource::class];
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Frontend\Compiler\Source;
use Illuminate\Filesystem\FilesystemAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
class DirectorySource implements SourceInterface
{
protected FilesystemAdapter $filesystem;
public function __construct(
protected string $path,
protected ?string $extensionId = null
) {
$this->filesystem = new FilesystemAdapter(
new Filesystem($adapter = new LocalFilesystemAdapter($path)),
$adapter,
['root' => $path]
);
}
public function getContent(): string
{
return '';
}
public function getCacheDifferentiator(): array
{
return [$this->path, filemtime($this->path)];
}
public function getFilesystem(): FilesystemAdapter
{
return $this->filesystem;
}
public function getExtensionId(): ?string
{
return $this->extensionId;
}
}

View File

@@ -16,6 +16,11 @@ use Closure;
*/
class SourceCollector
{
public function __construct(
protected array $allowedSourceTypes = []
) {
}
/**
* @var SourceInterface[]
*/
@@ -23,14 +28,27 @@ class SourceCollector
public function addFile(string $file, string $extensionId = null): static
{
$this->sources[] = new FileSource($file, $extensionId);
$this->sources[] = $this->validateSourceType(
new FileSource($file, $extensionId)
);
return $this;
}
public function addString(Closure $callback): static
{
$this->sources[] = new StringSource($callback);
$this->sources[] = $this->validateSourceType(
new StringSource($callback)
);
return $this;
}
public function addDirectory(string $directory, string $extensionId = null): static
{
$this->sources[] = $this->validateSourceType(
new DirectorySource($directory, $extensionId)
);
return $this;
}
@@ -42,4 +60,28 @@ class SourceCollector
{
return $this->sources;
}
protected function validateSourceType(SourceInterface $source): SourceInterface
{
// allowedSourceTypes is an array of class names (or interface names)
// so we need to check if the $source is an instance of one of those classes/interfaces (could be a parent class as well)
$isInstanceOfOneOfTheAllowedSourceTypes = false;
foreach ($this->allowedSourceTypes as $allowedSourceType) {
if ($source instanceof $allowedSourceType) {
$isInstanceOfOneOfTheAllowedSourceTypes = true;
break;
}
}
if (! empty($this->allowedSourceTypes) && ! $isInstanceOfOneOfTheAllowedSourceTypes) {
throw new \InvalidArgumentException(sprintf(
'Source type %s is not allowed for this collector. Allowed types are: %s',
get_class($source),
implode(', ', $this->allowedSourceTypes)
));
}
return $source;
}
}

View File

@@ -14,4 +14,6 @@ interface VersionerInterface
public function putRevision(string $file, ?string $revision): void;
public function getRevision(string $file): ?string;
public function allRevisions(): array;
}

View File

@@ -20,6 +20,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
class Assets
{
protected FrontendAssets $assets;
protected FrontendAssets $commonAssets;
public function __construct(
protected Container $container,
@@ -35,6 +36,7 @@ class Assets
public function forFrontend(string $name): self
{
$this->assets = $this->container->make('flarum.assets.'.$name);
$this->commonAssets = $this->container->make('flarum.assets.common');
return $this;
}
@@ -59,10 +61,16 @@ class Assets
*/
protected function assembleCompilers(?string $locale): array
{
return [
'js' => [$this->assets->makeJs(), $this->assets->makeLocaleJs($locale)],
$frontendCompilers = [
'js' => [$this->assets->makeJs(), $this->assets->makeLocaleJs($locale), $this->assets->makeJsDirectory()],
'css' => [$this->assets->makeCss(), $this->assets->makeLocaleCss($locale)]
];
$commonCompilers = [
'js' => [$this->commonAssets->makeJsDirectory()],
];
return array_merge_recursive($commonCompilers, $frontendCompilers);
}
/**

View File

@@ -9,7 +9,11 @@
namespace Flarum\Frontend;
use Flarum\Foundation\Config;
use Flarum\Frontend\Compiler\FileVersioner;
use Flarum\Frontend\Compiler\VersionerInterface;
use Flarum\Frontend\Driver\TitleDriverInterface;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
@@ -131,12 +135,22 @@ class Document implements Renderable
*/
public array $preloads = [];
/**
* We need the versioner to get the revisions of split chunks.
*/
protected VersionerInterface $versioner;
public function __construct(
protected Factory $view,
protected array $forumApiDocument,
protected Request $request,
protected TitleDriverInterface $titleDriver
protected TitleDriverInterface $titleDriver,
protected Config $config,
FilesystemFactory $filesystem
) {
$this->versioner = new FileVersioner(
$filesystem->disk('flarum-assets')
);
}
public function render(): string
@@ -157,6 +171,8 @@ class Document implements Renderable
'js' => $this->makeJs(),
'head' => $this->makeHead(),
'foot' => $this->makeFoot(),
'revisions' => $this->versioner->allRevisions(),
'debug' => $this->config->inDebugMode(),
]);
}

View File

@@ -10,8 +10,7 @@
namespace Flarum\Frontend;
use Flarum\Api\Client;
use Flarum\Frontend\Driver\TitleDriverInterface;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\Container\Container;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -23,9 +22,8 @@ class Frontend
protected array $content = [];
public function __construct(
protected Factory $view,
protected Client $api,
protected TitleDriverInterface $titleDriver
protected Container $container
) {
}
@@ -36,9 +34,9 @@ class Frontend
public function document(Request $request): Document
{
$forumDocument = $this->getForumDocument($request);
$forumApiDocument = $this->getForumDocument($request);
$document = new Document($this->view, $forumDocument, $request, $this->titleDriver);
$document = $this->container->makeWith(Document::class, compact('forumApiDocument', 'request'));
$this->populate($document, $request);

View File

@@ -9,15 +9,20 @@
namespace Flarum\Frontend;
use Flarum\Extension\Event\Disabled;
use Flarum\Extension\Event\Enabled;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Event\ClearingCache;
use Flarum\Foundation\Paths;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Flarum\Frontend\Driver\BasicTitleDriver;
use Flarum\Frontend\Driver\TitleDriverInterface;
use Flarum\Http\SlugManager;
use Flarum\Http\UrlGenerator;
use Flarum\Locale\LocaleManager;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\View\Factory as ViewFactory;
class FrontendServiceProvider extends AbstractServiceProvider
@@ -163,9 +168,20 @@ class FrontendServiceProvider extends AbstractServiceProvider
return [];
}
);
$this->container->bind('flarum.assets.common', function (Container $container) {
/** @var \Flarum\Frontend\Assets $assets */
$assets = $container->make('flarum.assets.factory')('common');
$assets->jsDirectory(function (SourceCollector $sources) {
$sources->addDirectory(__DIR__.'/../../js/dist/common', 'core');
});
return $assets;
});
}
public function boot(Container $container, ViewFactory $views): void
public function boot(Container $container, Dispatcher $events, ViewFactory $views): void
{
$this->loadViewsFrom(__DIR__.'/../../views', 'flarum');
@@ -174,6 +190,17 @@ class FrontendServiceProvider extends AbstractServiceProvider
'url' => $container->make(UrlGenerator::class),
'slugManager' => $container->make(SlugManager::class)
]);
$events->listen(
[Enabled::class, Disabled::class, ClearingCache::class],
function () use ($container) {
$recompile = new RecompileFrontendAssets(
$container->make('flarum.assets.common'),
$container->make(LocaleManager::class)
);
$recompile->flush();
}
);
}
public function addBaseCss(SourceCollector $sources): void

View File

@@ -53,5 +53,7 @@ class RecompileFrontendAssets
foreach ($this->locales->getLocales() as $locale => $name) {
$this->assets->makeLocaleJs($locale)->flush();
}
$this->assets->makeJsDirectory()->flush();
}
}

View File

@@ -16,11 +16,13 @@
<script>
document.getElementById('flarum-loading').style.display = 'block';
var flarum = {extensions: {}};
var flarum = {extensions: {}, debug: @js($debug)};
</script>
{!! $js !!}
<script id="flarum-rev-manifest" type="application/json">@json($revisions)</script>
<script id="flarum-json-payload" type="application/json">@json($payload)</script>
<script>