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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 1217 additions and 304 deletions

View File

@ -4,9 +4,17 @@
"path": "./framework/core/js/dist/*.js",
"maxSize": "150KB"
},
{
"path": "./framework/core/js/dist/*/**/*.js",
"maxSize": "30KB"
},
{
"path": "./extensions/*/js/dist/*.js",
"maxSize": "30KB"
},
{
"path": "./extensions/*/js/dist/*/**/*.js",
"maxSize": "30KB"
}
],
"defaultCompression": "gzip",

View File

@ -4,8 +4,6 @@ import { override, extend } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import Stream from 'flarum/common/utils/Stream';
import ForumApplication from 'flarum/forum/ForumApplication';
import Composer from 'flarum/forum/components/Composer';
import PostStream from 'flarum/forum/components/PostStream';
import ModalManager from 'flarum/common/components/ModalManager';
import PostMeta from 'flarum/forum/components/PostMeta';
@ -13,7 +11,7 @@ import DiscussionPage from 'flarum/forum/components/DiscussionPage';
extend(ForumApplication.prototype, 'mount', function () {
if (m.route.param('hideFirstPost')) {
extend(PostStream.prototype, 'view', (vdom) => {
extend('flarum/forum/components/PostStream', 'view', (vdom) => {
if (vdom.children[0].attrs['data-number'] === 1) {
vdom.children.splice(0, 1);
}
@ -42,7 +40,7 @@ const reposition = function () {
};
extend(ModalManager.prototype, 'show', reposition);
extend(Composer.prototype, 'show', reposition);
extend('flarum/forum/components/Composer', 'show', reposition);
window.iFrameResizer = {
readyCallback: function () {
@ -50,7 +48,7 @@ window.iFrameResizer = {
},
};
extend(PostStream.prototype, 'goToNumber', function (promise, number) {
extend('flarum/forum/components/PostStream', 'goToNumber', function (promise, number) {
if (number === 'reply' && 'parentIFrame' in window && app.composer.isFullScreen()) {
const itemTop = this.$('.PostStream-item:last').offset().top;
window.parentIFrame.scrollToOffset(0, itemTop);

View File

@ -13,7 +13,8 @@ use s9e\TextFormatter\Configurator;
return [
(new Extend\Frontend('forum'))
->js(__DIR__.'/js/dist/forum.js')
->css(__DIR__.'/less/forum.less'),
->css(__DIR__.'/less/forum.less')
->jsDirectory(__DIR__.'/js/dist/forum'),
(new Extend\Formatter)
->configure(function (Configurator $config) {

View File

@ -1,7 +1,4 @@
import emojiMap from 'simple-emoji-map';
import { extend } from 'flarum/common/extend';
import TextEditor from 'flarum/common/components/TextEditor';
import TextEditorButton from 'flarum/common/components/TextEditorButton';
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
@ -10,11 +7,15 @@ import getEmojiIconCode from './helpers/getEmojiIconCode';
import cdn from './cdn';
export default function addComposerAutocomplete() {
const emojiKeys = Object.keys(emojiMap);
const $container = $('<div class="ComposerBody-emojiDropdownContainer"></div>');
const dropdown = new AutocompleteDropdown();
let emojiMap = null;
extend(TextEditor.prototype, 'oncreate', function () {
extend('flarum/common/components/TextEditor', 'oninit', function () {
this._loaders.push(async () => await import('./emojiMap').then((m) => (emojiMap = m.default)));
});
extend('flarum/common/components/TextEditor', 'onbuild', function () {
const $editor = this.$('.TextEditor-editor').wrap('<div class="ComposerBody-emojiWrapper"></div>');
this.navigator = new KeyboardNavigatable();
@ -29,7 +30,9 @@ export default function addComposerAutocomplete() {
$editor.after($container);
});
extend(TextEditor.prototype, 'buildEditorParams', function (params) {
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
const emojiKeys = Object.keys(emojiMap);
let relEmojiStart;
let absEmojiStart;
let typed;
@ -166,7 +169,7 @@ export default function addComposerAutocomplete() {
});
});
extend(TextEditor.prototype, 'toolbarItems', function (items) {
extend('flarum/common/components/TextEditor', 'toolbarItems', function (items) {
items.add(
'emoji',
<TextEditorButton onclick={() => this.attrs.composer.editor.insertAtCursor(' :')} icon="far fa-smile">

View File

@ -0,0 +1,3 @@
import emojiMap from 'simple-emoji-map';
export default emojiMap;

View File

@ -1,6 +1,5 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import NotificationGrid from 'flarum/forum/components/NotificationGrid';
import addLikeAction from './addLikeAction';
import addLikesList from './addLikesList';
@ -16,7 +15,7 @@ app.initializers.add('flarum-likes', () => {
addLikesList();
addLikesTabToUserProfile();
extend(NotificationGrid.prototype, 'notificationTypes', function (items) {
extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) {
items.add('postLiked', {
name: 'postLiked',
icon: 'far fa-thumbs-up',

View File

@ -1,6 +1,5 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import NotificationGrid from 'flarum/forum/components/NotificationGrid';
import DiscussionLockedNotification from './components/DiscussionLockedNotification';
import addLockBadge from './addLockBadge';
@ -14,7 +13,7 @@ app.initializers.add('flarum-lock', () => {
addLockBadge();
addLockControl();
extend(NotificationGrid.prototype, 'notificationTypes', function (items) {
extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) {
items.add('discussionLocked', {
name: 'discussionLocked',
icon: 'fas fa-lock',

View File

@ -9,7 +9,6 @@
import app from 'flarum/common/app';
import { extend, override } from 'flarum/common/extend';
import TextEditor from 'flarum/common/components/TextEditor';
import BasicEditorDriver from 'flarum/common/utils/BasicEditorDriver';
import styleSelectedText from 'flarum/common/utils/styleSelectedText';
@ -89,13 +88,9 @@ export function initialize(app) {
items.add('italic', makeShortcut('italic', 'i', this));
});
if (TextEditor.prototype.markdownToolbarItems) {
override(TextEditor.prototype, 'markdownToolbarItems', markdownToolbarItems);
} else {
TextEditor.prototype.markdownToolbarItems = markdownToolbarItems;
}
override('flarum/common/components/TextEditor', 'markdownToolbarItems', markdownToolbarItems);
extend(TextEditor.prototype, 'toolbarItems', function (items) {
extend('flarum/common/components/TextEditor', 'toolbarItems', function (items) {
items.add(
'markdown',
<MarkdownToolbar for={this.textareaId} setShortcutHandler={(handler) => (shortcutHandler = handler)}>

View File

@ -0,0 +1,15 @@
{
// Use Flarum's tsconfig as a starting point
"extends": "flarum-tsconfig",
// This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder
// and also tells your Typescript server to read core's global typings for
// access to `dayjs` and `$` in the global namespace.
"include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*"],
"compilerOptions": {
// This will output typings to `dist-typings`
"declarationDir": "./dist-typings",
"paths": {
"flarum/*": ["../../../framework/core/js/dist-typings/*"]
}
}
}

View File

@ -1,6 +1,5 @@
import app from 'flarum/forum/app';
import { extend } from 'flarum/common/extend';
import TextEditor from 'flarum/common/components/TextEditor';
import TextEditorButton from 'flarum/common/components/TextEditorButton';
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
@ -11,7 +10,7 @@ export default function addComposerAutocomplete() {
const $container = $('<div class="ComposerBody-mentionsDropdownContainer"></div>');
const dropdown = new AutocompleteDropdown();
extend(TextEditor.prototype, 'oncreate', function () {
extend('flarum/common/components/TextEditor', 'onbuild', function () {
const $editor = this.$('.TextEditor-editor').wrap('<div class="ComposerBody-mentionsWrapper"></div>');
this.navigator = new KeyboardNavigatable();
@ -26,7 +25,7 @@ export default function addComposerAutocomplete() {
$editor.after($container);
});
extend(TextEditor.prototype, 'buildEditorParams', function (params) {
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
let relMentionStart;
let absMentionStart;
let matchTyped;
@ -128,7 +127,7 @@ export default function addComposerAutocomplete() {
params.inputListeners.push(suggestionsInputListener);
});
extend(TextEditor.prototype, 'toolbarItems', function (items) {
extend('flarum/common/components/TextEditor', 'toolbarItems', function (items) {
items.add(
'mention',
<TextEditorButton onclick={() => this.attrs.composer.editor.insertAtCursor(' @')} icon="fas fa-at">

View File

@ -1,6 +1,5 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import NotificationGrid from 'flarum/forum/components/NotificationGrid';
import { getPlainContent } from 'flarum/common/utils/string';
import textContrastClass from 'flarum/common/helpers/textContrastClass';
import Post from 'flarum/forum/components/Post';
@ -46,7 +45,7 @@ app.initializers.add('flarum-mentions', function () {
app.notificationComponents.groupMentioned = GroupMentionedNotification;
// Add notification preferences.
extend(NotificationGrid.prototype, 'notificationTypes', function (items) {
extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) {
items.add('postMentioned', {
name: 'postMentioned',
icon: 'fas fa-reply',

View File

@ -6,8 +6,6 @@ import usernameHelper from 'flarum/common/helpers/username';
import avatar from 'flarum/common/helpers/avatar';
import highlight from 'flarum/common/helpers/highlight';
import { truncate } from 'flarum/common/utils/string';
import ReplyComposer from 'flarum/forum/components/ReplyComposer';
import EditPostComposer from 'flarum/forum/components/EditPostComposer';
import getCleanDisplayName from '../utils/getCleanDisplayName';
import type AtMentionFormat from './formats/AtMentionFormat';
@ -23,7 +21,10 @@ export default class PostMention extends MentionableModel<Post, AtMentionFormat>
* match any username characters that have been typed.
*/
initialResults(): Post[] {
if (!app.composer.bodyMatches(ReplyComposer) && !app.composer.bodyMatches(EditPostComposer)) {
const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer');
const ReplyComposer = flarum.reg.checkModule('core', 'forum/components/ReplyComposer');
if ((!ReplyComposer || !app.composer.bodyMatches(ReplyComposer)) && (!EditPostComposer || !app.composer.bodyMatches(EditPostComposer))) {
return [];
}

View File

@ -1,6 +1,5 @@
import app from 'flarum/forum/app';
import DiscussionControls from 'flarum/forum/utils/DiscussionControls';
import EditPostComposer from 'flarum/forum/components/EditPostComposer';
export function insertMention(post, composer, quote) {
return new Promise((resolve) => {
@ -27,7 +26,9 @@ export function insertMention(post, composer, quote) {
}
export default function reply(post, quote) {
if (app.composer.bodyMatches(EditPostComposer) && app.composer.body.attrs.post.discussion() === post.discussion()) {
const EditPostComposer = flarum.reg.checkModule('core', 'forum/components/EditPostComposer');
if (EditPostComposer && app.composer.bodyMatches(EditPostComposer) && app.composer.body.attrs.post.discussion() === post.discussion()) {
// If we're already editing a post in the discussion of post we're quoting,
// insert the mention directly.
return insertMention(post, app.composer, quote);

View File

@ -1,9 +1,6 @@
import app from 'flarum/forum/app';
import { extend } from 'flarum/common/extend';
import Button from 'flarum/common/components/Button';
import EditUserModal from 'flarum/common/components/EditUserModal';
import SignUpModal from 'flarum/forum/components/SignUpModal';
import SettingsPage from 'flarum/forum/components/SettingsPage';
import extractText from 'flarum/common/utils/extractText';
import Stream from 'flarum/common/utils/Stream';
import NickNameModal from './components/NicknameModal';
@ -11,7 +8,7 @@ import NickNameModal from './components/NicknameModal';
export { default as extend } from './extend';
app.initializers.add('flarum/nicknames', () => {
extend(SettingsPage.prototype, 'accountItems', function (items) {
extend('flarum/forum/components/SettingsPage', 'accountItems', function (items) {
if (app.forum.attribute('displayNameDriver') !== 'nickname') return;
if (this.user.canEditNickname()) {
@ -24,11 +21,11 @@ app.initializers.add('flarum/nicknames', () => {
}
});
extend(EditUserModal.prototype, 'oninit', function () {
extend('flarum/common/components/EditUserModal', 'oninit', function () {
this.nickname = Stream(this.attrs.user.displayName());
});
extend(EditUserModal.prototype, 'fields', function (items) {
extend('flarum/common/components/EditUserModal', 'fields', function (items) {
if (app.forum.attribute('displayNameDriver') !== 'nickname') return;
if (!this.attrs.user.canEditNickname()) return;
@ -47,7 +44,7 @@ app.initializers.add('flarum/nicknames', () => {
);
});
extend(EditUserModal.prototype, 'data', function (data) {
extend('flarum/common/components/EditUserModal', 'data', function (data) {
if (app.forum.attribute('displayNameDriver') !== 'nickname') return;
if (!this.attrs.user.canEditNickname()) return;
@ -57,13 +54,13 @@ app.initializers.add('flarum/nicknames', () => {
}
});
extend(SignUpModal.prototype, 'oninit', function () {
extend('flarum/forum/components/SignUpModal', 'oninit', function () {
if (app.forum.attribute('displayNameDriver') !== 'nickname') return;
this.nickname = Stream(this.attrs.username || '');
});
extend(SignUpModal.prototype, 'onready', function () {
extend('flarum/forum/components/SignUpModal', 'onready', function () {
if (app.forum.attribute('displayNameDriver') !== 'nickname') return;
if (app.forum.attribute('setNicknameOnRegistration') && app.forum.attribute('randomizeUsernameOnRegistration')) {
@ -71,7 +68,7 @@ app.initializers.add('flarum/nicknames', () => {
}
});
extend(SignUpModal.prototype, 'fields', function (items) {
extend('flarum/forum/components/SignUpModal', 'fields', function (items) {
if (app.forum.attribute('displayNameDriver') !== 'nickname') return;
if (app.forum.attribute('setNicknameOnRegistration')) {
@ -97,7 +94,7 @@ app.initializers.add('flarum/nicknames', () => {
}
});
extend(SignUpModal.prototype, 'submitData', function (data) {
extend('flarum/forum/components/SignUpModal', 'submitData', function (data) {
if (app.forum.attribute('displayNameDriver') !== 'nickname') return;
if (app.forum.attribute('setNicknameOnRegistration')) {

View File

@ -1,10 +1,10 @@
import app from 'flarum/forum/app';
import { extend } from 'flarum/common/extend';
import SettingsPage from 'flarum/forum/components/SettingsPage';
import type SettingsPage from 'flarum/forum/components/SettingsPage';
import Switch from 'flarum/common/components/Switch';
export default function () {
extend(SettingsPage.prototype, 'notificationsItems', function (this: SettingsPage, items) {
extend('flarum/forum/components/SettingsPage', 'notificationsItems', function (this: SettingsPage, items) {
items.add(
'followAfterReply',
<Switch

View File

@ -1,8 +1,5 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import Model from 'flarum/common/Model';
import Discussion from 'flarum/common/models/Discussion';
import NotificationGrid from 'flarum/forum/components/NotificationGrid';
import addSubscriptionBadge from './addSubscriptionBadge';
import addSubscriptionControls from './addSubscriptionControls';
@ -21,7 +18,7 @@ app.initializers.add('subscriptions', function () {
addSubscriptionFilter();
addSubscriptionSettings();
extend(NotificationGrid.prototype, 'notificationTypes', function (items) {
extend('flarum/forum/components/NotificationGrid', 'notificationTypes', function (items) {
items.add('newPost', {
name: 'newPost',
icon: 'fas fa-star',

View File

@ -10,7 +10,7 @@
"declarationDir": "./dist-typings",
"baseUrl": ".",
"paths": {
"flarum/*": ["../vendor/flarum/core/js/dist-typings/*"]
"flarum/*": ["../../../framework/core/js/dist-typings/*"]
}
}
}

View File

@ -48,10 +48,14 @@ $eagerLoadTagState = function ($query, ?ServerRequestInterface $request, array $
return [
(new Extend\Frontend('forum'))
->js(__DIR__.'/js/dist/forum.js')
->jsDirectory(__DIR__.'/js/dist/forum')
->css(__DIR__.'/less/forum.less')
->route('/t/{slug}', 'tag', Content\Tag::class)
->route('/tags', 'tags', Content\Tags::class),
(new Extend\Frontend('common'))
->jsDirectory(__DIR__.'/js/dist/common'),
(new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js')
->css(__DIR__.'/less/admin.less'),

View File

@ -2,7 +2,6 @@ import app from 'flarum/admin/app';
import Component from 'flarum/common/Component';
import Button from 'flarum/common/components/Button';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import TagSelectionModal from '../../common/components/TagSelectionModal';
import tagsLabel from '../../common/helpers/tagsLabel';
import type { CommonSettingsItemOptions } from 'flarum/admin/components/AdminPage';
@ -46,7 +45,7 @@ export default class SelectTagsSettingComponent<
<Button
className="Button Button--text"
onclick={() =>
app.modal.show(TagSelectionModal, {
app.modal.show(() => import('../../common/components/TagSelectionModal'), {
selectedTags: this.tags,
onsubmit: (tags: Tag[]) => {
this.tags = tags;

View File

@ -6,6 +6,4 @@ import './helpers/tagsLabel';
import './helpers/tagIcon';
import './helpers/tagLabel';
import './components/TagSelectionModal';
import './states/TagListState';

View File

@ -1,13 +1,11 @@
import { extend, override } from 'flarum/common/extend';
import IndexPage from 'flarum/forum/components/IndexPage';
import DiscussionComposer from 'flarum/forum/components/DiscussionComposer';
import classList from 'flarum/common/utils/classList';
import TagDiscussionModal from './components/TagDiscussionModal';
import tagsLabel from '../common/helpers/tagsLabel';
import getSelectableTags from './utils/getSelectableTags';
export default function () {
export default function addTagComposer() {
extend(IndexPage.prototype, 'newDiscussionAction', function (promise) {
// From `addTagFilter
const tag = this.currentTag();
@ -21,17 +19,16 @@ export default function () {
}
});
extend(DiscussionComposer.prototype, 'oninit', function () {
extend('flarum/forum/components/DiscussionComposer', 'oninit', function () {
app.tagList.load(['parent']).then(() => m.redraw());
});
// Add tag-selection abilities to the discussion composer.
DiscussionComposer.prototype.chooseTags = function () {
this.constructor.prototype.chooseTags = function () {
const selectableTags = getSelectableTags();
if (!selectableTags.length) return;
app.modal.show(TagDiscussionModal, {
app.modal.show(() => import('./components/TagDiscussionModal'), {
selectedTags: (this.composer.fields.tags || []).slice(0),
onsubmit: (tags) => {
this.composer.fields.tags = tags;
@ -39,10 +36,11 @@ export default function () {
},
});
};
});
// Add a tag-selection menu to the discussion composer's header, after the
// title.
extend(DiscussionComposer.prototype, 'headerItems', function (items) {
extend('flarum/forum/components/DiscussionComposer', 'headerItems', function (items) {
const tags = this.composer.fields.tags || [];
const selectableTags = getSelectableTags();
@ -59,7 +57,7 @@ export default function () {
);
});
override(DiscussionComposer.prototype, 'onsubmit', function (original) {
override('flarum/forum/components/DiscussionComposer', 'onsubmit', function (original) {
const chosenTags = this.composer.fields.tags || [];
const chosenPrimaryTags = chosenTags.filter((tag) => tag.position() !== null && !tag.isChild());
const chosenSecondaryTags = chosenTags.filter((tag) => tag.position() === null);
@ -76,7 +74,7 @@ export default function () {
chosenSecondaryTags.length < minSecondaryTags) &&
selectableTags.length
) {
app.modal.show(TagDiscussionModal, {
app.modal.show(() => import('./components/TagDiscussionModal'), {
selectedTags: chosenTags,
onsubmit: (tags) => {
this.composer.fields.tags = tags;
@ -89,7 +87,7 @@ export default function () {
});
// Add the selected tags as data to submit to the server.
extend(DiscussionComposer.prototype, 'data', function (data) {
extend('flarum/forum/components/DiscussionComposer', 'data', function (data) {
data.relationships = data.relationships || {};
data.relationships.tags = this.composer.fields.tags;
});

View File

@ -2,15 +2,13 @@ import { extend } from 'flarum/common/extend';
import DiscussionControls from 'flarum/forum/utils/DiscussionControls';
import Button from 'flarum/common/components/Button';
import TagDiscussionModal from './components/TagDiscussionModal';
export default function () {
export default function addTagControl() {
// Add a control allowing the discussion to be moved to another category.
extend(DiscussionControls, 'moderationControls', function (items, discussion) {
if (discussion.canTag()) {
items.add(
'tags',
<Button icon="fas fa-tag" onclick={() => app.modal.show(TagDiscussionModal, { discussion })}>
<Button icon="fas fa-tag" onclick={() => app.modal.show(() => import('./components/TagDiscussionModal'), { discussion })}>
{app.translator.trans('flarum-tags.forum.discussion_controls.edit_tags_button')}
</Button>
);

View File

@ -13,7 +13,7 @@ import { ComponentAttrs } from 'flarum/common/Component';
const findTag = (slug: string) => app.store.all<Tag>('tags').find((tag) => tag.slug().localeCompare(slug, undefined, { sensitivity: 'base' }) === 0);
export default function () {
export default function addTagFilter() {
IndexPage.prototype.currentTag = function () {
if (this.currentActiveTag) {
return this.currentActiveTag;

View File

@ -7,7 +7,7 @@ import classList from 'flarum/common/utils/classList';
import tagsLabel from '../common/helpers/tagsLabel';
import sortTags from '../common/utils/sortTags';
export default function () {
export default function addTagLabels() {
// Add tag labels to each discussion in the discussion list.
extend(DiscussionListItem.prototype, 'infoItems', function (items) {
const tags = this.attrs.discussion.tags();

View File

@ -8,7 +8,7 @@ import TagsPage from './components/TagsPage';
import app from 'flarum/forum/app';
import sortTags from '../common/utils/sortTags';
export default function () {
export default function addTagList() {
// Add a link to the tags page, as well as a list of all the tags,
// to the index page's sidebar.
extend(IndexPage.prototype, 'navItems', function (items) {

View File

@ -36,7 +36,7 @@ export default class TagDiscussionModal extends TagSelectionModal<TagDiscussionM
},
};
attrs.requireParentTag = true;
attrs.selectableTags = () => getSelectableTags(attrs.discussion);
attrs.selectableTags = () => getSelectableTags(attrs.discussion!);
attrs.selectedTags ??= (attrs.discussion?.tags() as Tag[]) || [];
attrs.canSelect = (tag) => tag.canStartDiscussion();

View File

@ -3,7 +3,6 @@ import '../common/common';
import './utils/getSelectableTags';
import './components/TagHero';
import './components/TagDiscussionModal';
import './components/TagsPage';
import './components/DiscussionTaggedPost';
import './components/TagLinkButton';

View File

@ -1,11 +0,0 @@
export default function getSelectableTags(discussion) {
let tags = app.store.all('tags');
if (discussion) {
tags = tags.filter((tag) => tag.canAddToDiscussion() || discussion.tags().indexOf(tag) !== -1);
} else {
tags = tags.filter((tag) => tag.canStartDiscussion());
}
return tags;
}

View File

@ -0,0 +1,15 @@
import type Tag from '../../common/models/Tag';
import type Discussion from 'flarum/common/models/Discussion';
export default function getSelectableTags(discussion: Discussion) {
let tags = app.store.all<Tag>('tags');
if (discussion) {
const discussionTags = discussion.tags() || [];
tags = tags.filter((tag) => tag.canAddToDiscussion() || discussionTags.includes(tag));
} else {
tags = tags.filter((tag) => tag.canStartDiscussion());
}
return tags;
}

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">
{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>
</>
)}
</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,6 +155,10 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
* Load the discussion from the API or use the preloaded one.
*/
load(): void {
Promise.all([import('./PostStream'), import('./PostStreamScrubber')]).then(([PostStreamImport, PostStreamScrubberImport]) => {
this.PostStream = PostStreamImport.default;
this.PostStreamScrubber = PostStreamScrubberImport.default;
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
if (preloadedDiscussion) {
// We must wrap this in a setTimeout because if we are mounting this
@ -164,6 +171,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
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, {
app.composer
.load(() => import('../components/ReplyComposer'), {
user: app.session.user,
discussion: this,
});
}
})
.then(() => app.composer.show());
} else {
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
@ -34,7 +35,8 @@ class FilesystemServiceProvider extends AbstractServiceProvider
'flarum-assets' => function (Paths $paths, UrlGenerator $url) {
return [
'root' => "$paths->public/assets",
'url' => $url->to('forum')->path('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>

View File

@ -8,7 +8,7 @@
"noImplicitThis": true,
"declaration": true,
"emitDeclarationOnly": true,
"module": "es2015",
"module": "es2020",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"esModuleInterop": true,
@ -16,7 +16,7 @@
"target": "es6",
"jsx": "preserve",
"allowJs": true,
"lib": ["dom", "es5", "es2015", "es2016", "es2017", "es2018", "es2019.array"],
"lib": ["dom", "es5", "es2015", "es2016", "es2017", "es2018", "es2019.array", "es2020"],
"allowSyntheticDefaultImports": true
}
}

View File

@ -0,0 +1,21 @@
/**
* This plugin overrides the webpack chunk loader function `__webpack_require__.l` which is a webpack constant
* with `flarum.reg.loadChunk`, which resides in the flarum app.
*/
class OverrideChunkLoaderFunction {
apply(compiler) {
// We don't want to literally override its source.
// We want to override the function that is called by webpack.
// By adding a new line to reassing the function to our own function.
// The function is called by webpack so we can't just override it.
compiler.hooks.compilation.tap('OverrideChunkLoaderFunction', (compilation) => {
compilation.mainTemplate.hooks.requireEnsure.tap('OverrideChunkLoaderFunction', (source) => {
return source + '\nconst originalLoadChunk = __webpack_require__.l;\n__webpack_require__.l = flarum.reg.loadChunk.bind(flarum.reg, originalLoadChunk);';
});
});
}
}
module.exports = OverrideChunkLoaderFunction;

View File

@ -0,0 +1,110 @@
const path = require("path");
const extensionId = require("./extensionId.cjs");
const {Compilation} = require("webpack");
class RegisterAsyncChunksPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("RegisterAsyncChunksPlugin", (compilation) => {
let alreadyOptimized = false;
compilation.hooks.unseal.tap("RegisterAsyncChunksPlugin", () => {
alreadyOptimized = false;
});
compilation.hooks.processAssets.tap(
{
name: "RegisterAsyncChunksPlugin",
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
if (alreadyOptimized) return;
alreadyOptimized = true;
const chunks = Array.from(compilation.chunks);
const chunkModuleMemory = [];
const modulesToCheck = [];
for (const chunk of chunks) {
for (const module of compilation.chunkGraph.getChunkModulesIterable(chunk)) {
// A normal module.
if (module?.resource && module.resource.split(path.sep).includes('src') && module._source?._value.includes("webpackChunkName: ")) {
modulesToCheck.push(module);
}
// A ConcatenatedModule.
if (module?.modules) {
module.modules.forEach((module) => {
if (module.resource && module.resource.split(path.sep).includes('src') && module._source?._value.includes("webpackChunkName: ")) {
modulesToCheck.push(module);
}
});
}
}
}
for (const module of modulesToCheck) {
// If the module source has an async webpack chunk, add the chunk id to flarum.reg
// at the end of the module source.
const reg = [];
// Each line that has a webpackChunkName comment.
[...module._source._value.matchAll(/.*\/\* webpackChunkName: .* \*\/.*/gm)].forEach(([match]) => {
[...match.matchAll(/(.*?) webpackChunkName: '([^']*)'.*? \*\/ '([^']+)'.*?/gm)]
.forEach(([match, _, urlPath, importPath]) => {
// Import path is relative to module.resource, so we need to resolve it
const importPathResolved = path.resolve(path.dirname(module.resource), importPath);
const thisComposerJson = require(path.resolve(process.cwd(), '../composer.json'));
const namespace = extensionId(thisComposerJson.name);
const chunkModules = (c) => Array.from(compilation.chunkGraph.getChunkModulesIterable(c));
const relevantChunk = chunks.find(
(chunk) => chunkModules(chunk)?.find(
(module) => module.resource?.split('.')[0] === importPathResolved || module.rootModule?.resource?.split('.')[0] === importPathResolved
)
);
if (! relevantChunk) {
console.error(`Could not find chunk for ${importPathResolved}`);
return match;
}
let concatenatedModule = chunkModules(relevantChunk)[0];
const moduleId = compilation.chunkGraph.getModuleId(concatenatedModule);
const registrableModulesUrlPaths = new Map();
registrableModulesUrlPaths.set(urlPath, [relevantChunk.id, moduleId, namespace, urlPath]);
if (concatenatedModule?.rootModule) {
// This is a chunk with many modules, we need to register all of them.
concatenatedModule.modules?.forEach((module) => {
// The path right after the src/ directory, without the extension.
const regPathSep = `\\${path.sep}`;
const urlPath = module.resource.replace(`/.*${regPathSep}src(.*)${regPathSep}\..*/`, '$1');
if (! registrableModulesUrlPaths.has(urlPath)) {
registrableModulesUrlPaths.set(urlPath, [relevantChunk.id, moduleId, namespace, urlPath]);
}
});
}
registrableModulesUrlPaths.forEach(([chunkId, moduleId, namespace, urlPath]) => {
if (! chunkModuleMemory.includes(urlPath)) {
reg.push(`flarum.reg.addChunkModule('${chunkId}', '${moduleId}', '${namespace}', '${urlPath}');`);
chunkModuleMemory.push(urlPath);
}
});
return match;
});
});
module._source._value += reg.join('\n');
}
}
);
});
}
}
module.exports = RegisterAsyncChunksPlugin;

View File

@ -0,0 +1,83 @@
const path = require("path");
const {getOptions} = require("loader-utils");
const {validate} = require("schema-utils");
const fs = require("fs");
const optionsSchema = {
type: 'object',
properties: {
extension: {
type: 'string',
},
},
};
let namespace;
module.exports = function autoChunkNameLoader(source) {
const options = getOptions(this) || {};
validate(optionsSchema, options, {
name: 'Flarum Webpack Loader',
composerPath: 'Path to the extension composer.json file',
});
// Ensure that composer.json is watched for changes
// so that the loader is run again when composer.json
// is updated.
const composerJsonPath = path.resolve(options.composerPath || '../composer.json');
this.addDependency(composerJsonPath);
// Get the namespace of the module to be exported
// the namespace is essentially just the usual extension ID.
if (!namespace) {
const composerJson = JSON.parse(fs.readFileSync(composerJsonPath, 'utf8'));
// Get the value of the 'name' property
namespace =
composerJson.name === 'flarum/core' ? 'core' : composerJson.name.replace('/flarum-ext-', '-').replace('/flarum-', '').replace('/', '-');
}
// Get the absolute path to this module
const pathToThisModule = this.resourcePath;
// Find all lines that have an async import.
source = source.replaceAll(/^.*import\(['"].*['"]\).*$/gm, (match) => {
// Skip if this is inside a jsDoc comment.
if (/^\s*\*\s*@.*/.test(match)) {
return match;
}
// In this line.
// Replace all `import('path/to/module')` with `import(/* webpackChunkName: "relative/path/to/module/from/src" */ 'relative/path/to/module')`.
// Or, if attempting to import an external (from core or an extension) replace with a call to the right method that will compute the URL.
return match.replaceAll(/(.*?)import\(['"]([^'"]*)['"]\)/gm, (match, pre, relativePathToImport) => {
const externalImport = relativePathToImport.match(/^(flarum\/|ext:)/);
if (externalImport) {
return `${pre}flarum.reg.asyncModuleImport('${relativePathToImport}')`;
} else {
// Compute the absolute path from src to the module being imported
// based on the path of the file being imported from.
const absolutePathToImport = path.resolve(path.dirname(pathToThisModule), relativePathToImport);
let chunkPath = relativePathToImport;
if (absolutePathToImport.includes('src')) {
chunkPath = absolutePathToImport.split('src/')[1];
}
const webpackCommentOptions = {
webpackChunkName: chunkPath,
webpackMode: 'lazy-once',
};
const comment = Object.entries(webpackCommentOptions).map(([key, value]) => `${key}: '${value}'`).join(', ');
// Return the new import statement
return `${pre}import(/* ${comment} */ '${relativePathToImport}')`;
}
});
});
return source;
};

View File

@ -8,6 +8,7 @@ const path = require('path');
const fs = require('fs');
const { validate } = require('schema-utils');
const { getOptions, interpolateName } = require('loader-utils');
const extensionId = require('./extensionId.cjs');
const optionsSchema = {
type: 'object',
@ -33,7 +34,7 @@ function addAutoExports(source, pathToModule, moduleName) {
const id = pathToModule.substring(0, pathToModule.length - 1);
// Add code at the end of the file to add the file to registry
addition += `\nflarum.reg.add('${namespace}', '${id}', ${defaultExport})`;
addition += `\nflarum.reg.add('${namespace}', '${id}', ${defaultExport});`;
}
// In a normal case, we do one of two things:
@ -43,7 +44,7 @@ function addAutoExports(source, pathToModule, moduleName) {
// and can be imported using `import Foo from 'flarum/../Foo'`.
if (defaultExport) {
// Add code at the end of the file to add the file to registry
addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', ${defaultExport})`;
addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', ${defaultExport});`;
}
// 2. If there is no default export, then there are named exports,
@ -67,7 +68,7 @@ function addAutoExports(source, pathToModule, moduleName) {
return `import { ${defaultExport} } from '${path}';\nexport default ${defaultExport}`;
});
addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', ${objectDefaultExport})`;
addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', ${objectDefaultExport});`;
}
// 2.2. If there is no default export, check for direct exports from other modules.
// We add the module to the registry with the map of named exports.
@ -117,7 +118,7 @@ function addAutoExports(source, pathToModule, moduleName) {
}, '');
// Add code at the end of the file to add the file to registry
if (map) addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', { ${map} })`;
if (map) addition += `\nflarum.reg.add('${namespace}', '${pathToModule}${moduleName}', { ${map} });`;
}
}
}
@ -147,8 +148,7 @@ module.exports = function autoExportLoader(source) {
const composerJson = JSON.parse(fs.readFileSync(composerJsonPath, 'utf8'));
// Get the value of the 'name' property
namespace =
composerJson.name === 'flarum/core' ? 'core' : composerJson.name.replace('/flarum-ext-', '-').replace('/flarum-', '').replace('/', '-');
namespace = extensionId(composerJson.name);
}
// Get the type of the module to be exported

View File

@ -0,0 +1,3 @@
module.exports = (name) => name === 'flarum/core'
? 'core'
: name.replace('/flarum-ext-', '-').replace('/flarum-', '').replace('/', '-')

View File

@ -1,6 +1,8 @@
const fs = require('fs');
const path = require('path');
const { NormalModuleReplacementPlugin } = require('webpack');
const RegisterAsyncChunksPlugin = require("./RegisterAsyncChunksPlugin.cjs");
const OverrideChunkLoaderFunction = require("./OverrideChunkLoaderFunction.cjs");
const entryPointNames = ['forum', 'admin'];
const entryPointExts = ['js', 'ts'];
@ -55,6 +57,14 @@ if (useBundleAnalyzer) {
plugins.push(new (require('webpack-bundle-analyzer').BundleAnalyzerPlugin)());
}
/**
* This plugin allows us to register each async chunk with flarum.reg.addChunk.
* This works hand-in-hand with the autoChunkNameLoader, which adds a comment
* inside each async import with the chunk name and other webpack config.
*/
plugins.push(new RegisterAsyncChunksPlugin());
plugins.push(new OverrideChunkLoaderFunction());
module.exports = function () {
return {
// Set up entry points for each of the forum + admin apps, but only
@ -70,9 +80,13 @@ module.exports = function () {
module: {
rules: [
{
include: /src/, // Only apply this loader to files in the src directory
include: /src/,
loader: path.resolve(__dirname, './autoExportLoader.cjs'),
},
{
include: /src/,
loader: path.resolve(__dirname, './autoChunkNameLoader.cjs'),
},
{
// Matches .js, .jsx, .ts, .tsx
test: /\.[jt]sx?$/,
@ -85,11 +99,22 @@ module.exports = function () {
],
},
optimization: {
splitChunks: {
chunks: 'async',
cacheGroups: {
// Avoid node_modules being split into separate chunks
defaultVendors: false,
}
}
},
output: {
path: path.resolve(process.cwd(), 'dist'),
library: 'module.exports',
libraryTarget: 'assign',
devtoolNamespace: require(path.resolve(process.cwd(), 'package.json')).name,
clean: true,
},
externals: [