mirror of
https://github.com/flarum/framework.git
synced 2024-11-21 20:54:31 +08:00
feat: Code Splitting (#3860)
* feat: configure webpack to allow splitting chunks * feat: `JsDirectoryCompiler` and expose js assets URL * chore: support es2020 dynamic importing * feat: control which URL to fetch chunks from * feat: allow showing async modals & split 'LogInModal' * feat: split `SignUpModal` * feat: allow rendering async pages & split `UserSecurityPage` * fix: module might not be listed in chunk * feat: lazy load user pages * feat: track the chunk containing each module * chore: lightly warn * chore: split `Composer` * feat: add common frontend (for split common chunks) * fix: jsDoc typing imports should be ignored * feat: split `PostStream` `ForgotPasswordModal` and `EditUserModal` * fix: multiple inline async imports not picked up * chore: new `common` frontend assets only needs a jsdir compiler * feat: add revision hash to chunk import URL * fix: nothing to split for `admin` frontend yet * chore: cleanup registry API * chore: throw an error in debug mode if attempting to import a non-loaded module * feat: defer `extend` & `override` until after module registration * fix: plugin not picking up on all module sources * fix: must override default chunk loader function from webpack plugin * feat: split tags `TagDiscussionModal` and `TagSelectionModal` * fix: wrong export name * feat: import chunked modules from external packages * feat: extensions compatibility * feat: Router frontend extender async component * chore: clean JS output path (removes stale chunks) * fix: common chunks also need flushing * chore: flush backend stale chunks * Apply fixes from StyleCI * feat: loading alert when async page component is loading * chore: `yarn format` * chore: typings * chore: remove exception * Apply fixes from StyleCI * chore(infra): bundlewatch * chore(infra): bundlewatch split chunks * feat: split text editor * chore: tag typings * chore: bundlewatch * fix: windows paths * fix: wrong planned ext import format
This commit is contained in:
parent
2ffbc44b4e
commit
229a7affa5
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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">
|
||||
|
|
3
extensions/emoji/js/src/forum/emojiMap.ts
Normal file
3
extensions/emoji/js/src/forum/emojiMap.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import emojiMap from 'simple-emoji-map';
|
||||
|
||||
export default emojiMap;
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)}>
|
||||
|
|
15
extensions/markdown/js/tsconfig.json
Normal file
15
extensions/markdown/js/tsconfig.json
Normal 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/*"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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')) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
"declarationDir": "./dist-typings",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"flarum/*": ["../vendor/flarum/core/js/dist-typings/*"]
|
||||
"flarum/*": ["../../../framework/core/js/dist-typings/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -6,6 +6,4 @@ import './helpers/tagsLabel';
|
|||
import './helpers/tagIcon';
|
||||
import './helpers/tagLabel';
|
||||
|
||||
import './components/TagSelectionModal';
|
||||
|
||||
import './states/TagListState';
|
||||
|
|
|
@ -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,28 +19,28 @@ 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.
|
||||
this.constructor.prototype.chooseTags = function () {
|
||||
const selectableTags = getSelectableTags();
|
||||
|
||||
if (!selectableTags.length) return;
|
||||
|
||||
app.modal.show(() => import('./components/TagDiscussionModal'), {
|
||||
selectedTags: (this.composer.fields.tags || []).slice(0),
|
||||
onsubmit: (tags) => {
|
||||
this.composer.fields.tags = tags;
|
||||
this.$('textarea').focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// Add tag-selection abilities to the discussion composer.
|
||||
DiscussionComposer.prototype.chooseTags = function () {
|
||||
const selectableTags = getSelectableTags();
|
||||
|
||||
if (!selectableTags.length) return;
|
||||
|
||||
app.modal.show(TagDiscussionModal, {
|
||||
selectedTags: (this.composer.fields.tags || []).slice(0),
|
||||
onsubmit: (tags) => {
|
||||
this.composer.fields.tags = tags;
|
||||
this.$('textarea').focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
15
extensions/tags/js/src/forum/utils/getSelectableTags.ts
Normal file
15
extensions/tags/js/src/forum/utils/getSelectableTags.ts
Normal 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;
|
||||
}
|
14
framework/core/js/src/@types/global.d.ts
vendored
14
framework/core/js/src/@types/global.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -7,6 +7,7 @@ import Button from './Button';
|
|||
|
||||
import BasicEditorDriver from '../utils/BasicEditorDriver';
|
||||
import Tooltip from './Tooltip';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
|
@ -36,17 +37,33 @@ export default class TextEditor extends Component {
|
|||
* Whether the editor is disabled.
|
||||
*/
|
||||
this.disabled = !!this.attrs.disabled;
|
||||
|
||||
/**
|
||||
* Whether the editor is loading.
|
||||
*/
|
||||
this.loading = true;
|
||||
|
||||
/**
|
||||
* Async operations to complete before the editor is ready.
|
||||
*/
|
||||
this._loaders = [];
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="TextEditor">
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
{this.loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<>
|
||||
<div className="TextEditor-editorContainer"></div>
|
||||
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
|
||||
</ul>
|
||||
<ul className="TextEditor-controls Composer-footer">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
<li className="TextEditor-toolbar">{this.toolbarItems().toArray()}</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -54,6 +71,12 @@ export default class TextEditor extends Component {
|
|||
oncreate(vnode) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
this._load().then(() => {
|
||||
setTimeout(this.onbuild.bind(this), 50);
|
||||
});
|
||||
}
|
||||
|
||||
onbuild() {
|
||||
this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]);
|
||||
}
|
||||
|
||||
|
@ -68,6 +91,13 @@ export default class TextEditor extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
_load() {
|
||||
return Promise.all(this._loaders.map((loader) => loader())).then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
buildEditorParams() {
|
||||
return {
|
||||
classNames: ['FormControl', 'Composer-flexible', 'TextEditor-editor'],
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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++ };
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
@ -5,8 +5,6 @@ import Page, { IPageAttrs } from '../../common/components/Page';
|
|||
import ItemList from '../../common/utils/ItemList';
|
||||
import DiscussionHero from './DiscussionHero';
|
||||
import DiscussionListPane from './DiscussionListPane';
|
||||
import PostStream from './PostStream';
|
||||
import PostStreamScrubber from './PostStreamScrubber';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import SplitDropdown from '../../common/components/SplitDropdown';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
@ -26,6 +24,11 @@ export interface IDiscussionPageAttrs extends IPageAttrs {
|
|||
* the discussion list pane, the hero, the posts, and the sidebar.
|
||||
*/
|
||||
export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = IDiscussionPageAttrs> extends Page<CustomAttrs> {
|
||||
protected loading: boolean = true;
|
||||
|
||||
protected PostStream: any = null;
|
||||
protected PostStreamScrubber: any = null;
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*/
|
||||
|
@ -83,7 +86,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||
return (
|
||||
<div className="DiscussionPage">
|
||||
<DiscussionListPane state={app.discussions} />
|
||||
<div className="DiscussionPage-discussion">{this.discussion ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
|
||||
<div className="DiscussionPage-discussion">{!this.loading ? this.pageContent().toArray() : this.loadingItems().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -140,7 +143,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||
items.add(
|
||||
'poststream',
|
||||
<div className="DiscussionPage-stream">
|
||||
<PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
|
||||
<this.PostStream discussion={this.discussion} stream={this.stream} onPositionChange={this.positionChanged.bind(this)} />
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
|
@ -152,18 +155,23 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||
* Load the discussion from the API or use the preloaded one.
|
||||
*/
|
||||
load(): void {
|
||||
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
Promise.all([import('./PostStream'), import('./PostStreamScrubber')]).then(([PostStreamImport, PostStreamScrubberImport]) => {
|
||||
this.PostStream = PostStreamImport.default;
|
||||
this.PostStreamScrubber = PostStreamScrubberImport.default;
|
||||
|
||||
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
const preloadedDiscussion = app.preloadedApiDocument<Discussion>();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.show.bind(this, preloadedDiscussion), 0);
|
||||
} else {
|
||||
const params = this.requestParams();
|
||||
|
||||
app.store.find<Discussion>('discussions', m.route.param('id'), params).then(this.show.bind(this));
|
||||
}
|
||||
});
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
|
@ -183,6 +191,8 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||
* Initialize the component to display the given discussion.
|
||||
*/
|
||||
show(discussion: ApiResponseSingle<Discussion>): void {
|
||||
this.loading = false;
|
||||
|
||||
app.history.push('discussion', discussion.title());
|
||||
app.setTitle(discussion.title());
|
||||
app.setTitleCount(0);
|
||||
|
@ -249,7 +259,7 @@ export default class DiscussionPage<CustomAttrs extends IDiscussionPageAttrs = I
|
|||
);
|
||||
}
|
||||
|
||||
items.add('scrubber', <PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
|
||||
items.add('scrubber', <this.PostStreamScrubber stream={this.stream} className="App-titleControl" />, -100);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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') },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import app from '../../forum/app';
|
||||
import DiscussionPage from '../components/DiscussionPage';
|
||||
import ReplyComposer from '../components/ReplyComposer';
|
||||
import LogInModal from '../components/LogInModal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Separator from '../../common/components/Separator';
|
||||
import RenameDiscussionModal from '../components/RenameDiscussionModal';
|
||||
|
@ -168,12 +166,15 @@ const DiscussionControls = {
|
|||
if (app.session.user) {
|
||||
if (this.canReply()) {
|
||||
if (!app.composer.composingReplyTo(this) || forceRefresh) {
|
||||
app.composer.load(ReplyComposer, {
|
||||
user: app.session.user,
|
||||
discussion: this,
|
||||
});
|
||||
app.composer
|
||||
.load(() => import('../components/ReplyComposer'), {
|
||||
user: app.session.user,
|
||||
discussion: this,
|
||||
})
|
||||
.then(() => app.composer.show());
|
||||
} else {
|
||||
app.composer.show();
|
||||
}
|
||||
app.composer.show();
|
||||
|
||||
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
|
||||
app.current.get('stream').goToNumber('reply');
|
||||
|
@ -185,7 +186,7 @@ const DiscussionControls = {
|
|||
}
|
||||
}
|
||||
|
||||
app.modal.show(LogInModal);
|
||||
app.modal.show(() => import('../components/LogInModal'));
|
||||
|
||||
return reject();
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
border-radius: var(--border-radius);
|
||||
line-height: 1.5;
|
||||
|
||||
--loading-indicator-color: var(--alert-color);
|
||||
background: var(--alert-bg);
|
||||
|
||||
&,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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%);
|
||||
|
|
|
@ -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')) {
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -17,6 +17,7 @@ use Illuminate\Contracts\Container\Container;
|
|||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Arr;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\Visibility;
|
||||
use RuntimeException;
|
||||
|
||||
class FilesystemServiceProvider extends AbstractServiceProvider
|
||||
|
@ -33,8 +34,9 @@ class FilesystemServiceProvider extends AbstractServiceProvider
|
|||
return [
|
||||
'flarum-assets' => function (Paths $paths, UrlGenerator $url) {
|
||||
return [
|
||||
'root' => "$paths->public/assets",
|
||||
'url' => $url->to('forum')->path('assets')
|
||||
'root' => "$paths->public/assets",
|
||||
'url' => $url->to('forum')->path('assets'),
|
||||
'visibility' => Visibility::PUBLIC
|
||||
];
|
||||
},
|
||||
'flarum-avatars' => function (Paths $paths, UrlGenerator $url) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -11,7 +11,7 @@ namespace Flarum\Frontend\Compiler;
|
|||
|
||||
interface CompilerInterface
|
||||
{
|
||||
public function getFilename(): string;
|
||||
public function getFilename(): ?string;
|
||||
|
||||
public function setFilename(string $filename): void;
|
||||
|
||||
|
|
42
framework/core/src/Frontend/Compiler/Concerns/HasSources.php
Normal file
42
framework/core/src/Frontend/Compiler/Concerns/HasSources.php
Normal 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;
|
||||
}
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
137
framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php
Normal file
137
framework/core/src/Frontend/Compiler/JsDirectoryCompiler.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,4 +14,6 @@ interface VersionerInterface
|
|||
public function putRevision(string $file, ?string $revision): void;
|
||||
|
||||
public function getRevision(string $file): ?string;
|
||||
|
||||
public function allRevisions(): array;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -53,5 +53,7 @@ class RecompileFrontendAssets
|
|||
foreach ($this->locales->getLocales() as $locale => $name) {
|
||||
$this->assets->makeLocaleJs($locale)->flush();
|
||||
}
|
||||
|
||||
$this->assets->makeJsDirectory()->flush();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
110
js-packages/webpack-config/src/RegisterAsyncChunksPlugin.cjs
Normal file
110
js-packages/webpack-config/src/RegisterAsyncChunksPlugin.cjs
Normal 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;
|
83
js-packages/webpack-config/src/autoChunkNameLoader.cjs
Normal file
83
js-packages/webpack-config/src/autoChunkNameLoader.cjs
Normal 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;
|
||||
};
|
|
@ -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
|
||||
|
|
3
js-packages/webpack-config/src/extensionId.cjs
Normal file
3
js-packages/webpack-config/src/extensionId.cjs
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = (name) => name === 'flarum/core'
|
||||
? 'core'
|
||||
: name.replace('/flarum-ext-', '-').replace('/flarum-', '').replace('/', '-')
|
|
@ -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: [
|
||||
|
|
Loading…
Reference in New Issue
Block a user