mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 14:01:58 -05:00
fix: content blocks conflicting prop casing with the sanitizer + avoid sending the render if untouched
This commit is contained in:
11 files changed
+231
-64
No files matched your search
@@ -457,7 +457,24 @@ class Rendering {
|
||||
}
|
||||
const tag = `block-${definition.block}`
|
||||
tags.push(tag)
|
||||
attributes[tag] = (definition.props ?? []).map((prop) => prop.name)
|
||||
/*
|
||||
Every prop under both spellings, because the two ends of this see different ones.
|
||||
|
||||
A prop is declared in camelCase (`showIcons`), which is what the renderer writes and what the
|
||||
sanitiser is therefore asked about on the way in. But `postProcess` re-parses its own output
|
||||
with cheerio, and an HTML parser lowercases attribute names -- so what is STORED is
|
||||
`showicons`, and a later save that sends that render back up offers the sanitiser a name its
|
||||
allow list has never heard of, which is dropped. A page properties change made with no editor
|
||||
open sends exactly that, so the props of every block on the page vanished the first time
|
||||
anybody retagged it, silently and for good: nothing renders them again until the page is
|
||||
saved from an editor, which produces the render from the source afresh.
|
||||
|
||||
Matching both is what makes this pass idempotent. Nothing is lost by it either, since HTML
|
||||
attribute names are case-insensitive and Lit observes the lowercased form regardless.
|
||||
*/
|
||||
attributes[tag] = [
|
||||
...new Set((definition.props ?? []).flatMap((prop) => [prop.name, prop.name.toLowerCase()]))
|
||||
]
|
||||
}
|
||||
return { tags, attributes }
|
||||
}
|
||||
|
||||
@@ -1177,9 +1177,7 @@ async function processContent(newContent) {
|
||||
(el) => el.active ?? 0
|
||||
)
|
||||
|
||||
pageStore.$patch({
|
||||
render: html
|
||||
})
|
||||
pageStore.setRender(html)
|
||||
await nextTick()
|
||||
// -> With the preview pane closed there is no DOM to attend to. The render is stored either way, so
|
||||
// the store still holds what a save would send
|
||||
|
||||
@@ -1346,9 +1346,7 @@ function processContent(newContent) {
|
||||
(el) => el.active ?? 0
|
||||
)
|
||||
|
||||
pageStore.$patch({
|
||||
render: html
|
||||
})
|
||||
pageStore.setRender(html)
|
||||
nextTick(async () => {
|
||||
// -> With the preview pane closed there is no DOM to attend to. The render is stored either way, so
|
||||
// the store still holds what a save would send
|
||||
|
||||
@@ -1189,9 +1189,9 @@ const syncContentToStore = debounce(() => {
|
||||
content: markdown,
|
||||
// -> What the author has written IS the source, whatever the load did or did not deliver; see
|
||||
// the guard in `pageSave`
|
||||
contentLoaded: true,
|
||||
render: editor.getRender()
|
||||
contentLoaded: true
|
||||
})
|
||||
pageStore.setRender(editor.getRender())
|
||||
}, 500)
|
||||
|
||||
/**
|
||||
@@ -1362,7 +1362,7 @@ onMounted(async () => {
|
||||
created has no render at all until this runs.
|
||||
*/
|
||||
nextTick(() => {
|
||||
pageStore.$patch({ render: editor.getRender() })
|
||||
pageStore.setRender(editor.getRender())
|
||||
refreshActive()
|
||||
editor.view.focus()
|
||||
})
|
||||
|
||||
@@ -72,7 +72,18 @@
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:label="t(`common.blog.newPost`)">
|
||||
<page-new-menu hide-asset-btn :only="POST_EDITORS" :base-path="pageStore.path" />
|
||||
<!--
|
||||
What a post may be written with: the editors that author an ARTICLE, which is what a
|
||||
post is. Not `redirect` and not `blog` — both write a page with no body, and
|
||||
`models/blogs.ts` does not count either as a post, so creating one here would add
|
||||
nothing to this listing. Read from the store rather than listed here, because the
|
||||
same partition decides where `PageNewMenu` draws its divider and because a list
|
||||
written out again is one that can fall behind an editor being added.
|
||||
-->
|
||||
<page-new-menu
|
||||
hide-asset-btn
|
||||
:only="siteStore.articleEditors"
|
||||
:base-path="pageStore.path" />
|
||||
</w-btn>
|
||||
</template>
|
||||
</div>
|
||||
@@ -178,16 +189,6 @@ import { useUserStore } from '@/stores/user'
|
||||
|
||||
import PageNewMenu from '@/components/PageNewMenu.vue'
|
||||
|
||||
/**
|
||||
* What a post may be written with, offered by the New Post button on an empty blog.
|
||||
*
|
||||
* The editors that author an ARTICLE, which is what a post is. Not `redirect` and not `blog` — both
|
||||
* write a page with no body, and `models/blogs.ts` does not count either as a post: a redirection is
|
||||
* a doorway and a nested blog is its own blog, so creating one here would add nothing to this
|
||||
* listing. Filtered against what the site has enabled, so this is a ceiling and not a list.
|
||||
*/
|
||||
const POST_EDITORS = ['markdown', 'visual']
|
||||
|
||||
/**
|
||||
* A blog's front page: its posts, rather than an article.
|
||||
*
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
|
||||
Narrowed by `only` where a caller has a reason to offer fewer -- see the prop.
|
||||
-->
|
||||
<w-item v-for="editor of offeredEditors" :key="editor" clickable @click="create(editor)">
|
||||
<blueprint-icon :icon="EDITOR_ICONS[editor]" />
|
||||
<w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section>
|
||||
</w-item>
|
||||
<template v-for="editor of offeredEditors" :key="editor">
|
||||
<!-- -> Where the editors that write an article end; see `dividedAt` -->
|
||||
<w-separator v-if="editor === dividedAt" class="my-2" inset />
|
||||
<w-item clickable @click="create(editor)">
|
||||
<blueprint-icon :icon="EDITOR_ICONS[editor]" />
|
||||
<w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
<template v-if="props.hideAssetBtn === false">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item clickable @click="openFileManager">
|
||||
@@ -36,30 +40,18 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { loading } from '@/composables/loading'
|
||||
/*
|
||||
The icons, shared with the other two screens that offer editors. WHICH are offered, and in what
|
||||
order, is still `siteStore.activeEditors` alone. The wording lives in the locale file as
|
||||
`common.createPage.<editor>`, keyed by the same ids, so an editor added to that list needs an icon
|
||||
there and a string there.
|
||||
*/
|
||||
import { EDITOR_ICONS } from '@/helpers/editors'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* The icon each editor is offered under.
|
||||
*
|
||||
* WHICH of them are offered, and in what order, is `siteStore.activeEditors` -- only that list
|
||||
* decides. The wording lives in the locale file as `common.createPage.<editor>`, keyed by the same
|
||||
* ids, so an editor added to that list needs an icon here and a string there.
|
||||
*
|
||||
* `redirect` is not an editor the site can turn off, because it authors nothing: a redirection is a
|
||||
* page with a target instead of a body.
|
||||
*/
|
||||
const EDITOR_ICONS = {
|
||||
markdown: 'markdown',
|
||||
visual: 'google-presentation',
|
||||
asciidoc: 'asciidoc',
|
||||
channel: 'chat',
|
||||
blog: 'typewriter-with-paper',
|
||||
api: 'api',
|
||||
redirect: 'advance'
|
||||
}
|
||||
|
||||
// PROPS
|
||||
|
||||
@@ -123,6 +115,28 @@ const offeredEditors = computed(() =>
|
||||
: siteStore.activeEditors
|
||||
)
|
||||
|
||||
/**
|
||||
* The editor the divider is drawn above, or null where there is nothing to divide.
|
||||
*
|
||||
* It separates the editors that put an author in front of a blank article from the ones that write a
|
||||
* settings document instead -- a blog, a redirection -- because those are a different kind of thing
|
||||
* to be choosing between. `siteStore.articleEditors` is where that partition lives; it is the
|
||||
* server's own, and the blog's New Post button is drawn from the same answer.
|
||||
*
|
||||
* Read off the list rather than fixed at a position, since which editors a site has on is the
|
||||
* store's answer and this menu can be narrowed further by `only`. The first offered editor that is
|
||||
* not an article editor -- and only when something above it was one, so the divider can never open
|
||||
* the menu.
|
||||
*
|
||||
* Null for a menu that is all of one kind: a blog's New Post button offers article editors and
|
||||
* nothing else, and a site with every article editor switched off has nothing above the line.
|
||||
*/
|
||||
const dividedAt = computed(() => {
|
||||
const offered = offeredEditors.value
|
||||
const first = offered.findIndex((editor) => !siteStore.articleEditors.includes(editor))
|
||||
return first > 0 ? offered[first] : null
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
async function create(editor) {
|
||||
|
||||
@@ -8,26 +8,25 @@
|
||||
<div class="welcome-actions">
|
||||
<w-btn push color="primary" :label="t(`welcome.createHome`)" icon="la:plus" no-caps>
|
||||
<w-menu class="translucent-menu" auto-close anchor="top left" self="bottom left">
|
||||
<!--
|
||||
The editors that author an ARTICLE, which is what a home page is -- so a blog and a
|
||||
redirection are not offered, and nor is anything the site has switched off.
|
||||
`siteStore.articleEditors` is the same answer the New Page menu draws its top group
|
||||
from and the same one a blog's New Post button uses, which is what keeps the three
|
||||
screens showing one set rather than three lists that drift.
|
||||
|
||||
The wording is this screen's own (`welcome.createHome<Editor>`, "Using the Markdown
|
||||
Editor" rather than "New Markdown Page"), because here the choice is how to write the
|
||||
wiki's first page rather than what to add to it.
|
||||
-->
|
||||
<w-list padding>
|
||||
<w-item clickable @click="createHomePage(`visual`)" v-if="siteStore.editors.visual">
|
||||
<blueprint-icon icon="google-presentation" />
|
||||
<w-item-section class="pr-2">{{ t(`welcome.createHomeVisual`) }}</w-item-section>
|
||||
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
|
||||
</w-item>
|
||||
<w-item
|
||||
v-for="editor of siteStore.articleEditors"
|
||||
:key="editor"
|
||||
clickable
|
||||
@click="createHomePage(`markdown`)"
|
||||
v-if="siteStore.editors.markdown">
|
||||
<blueprint-icon icon="markdown" />
|
||||
<w-item-section class="pr-2">{{ t(`welcome.createHomeMarkdown`) }}</w-item-section>
|
||||
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
|
||||
</w-item>
|
||||
<w-item
|
||||
clickable
|
||||
@click="createHomePage(`asciidoc`)"
|
||||
v-if="siteStore.editors.asciidoc">
|
||||
<blueprint-icon icon="asciidoc" />
|
||||
<w-item-section class="pr-2">{{ t(`welcome.createHomeAsciidoc`) }}</w-item-section>
|
||||
@click="createHomePage(editor)">
|
||||
<blueprint-icon :icon="EDITOR_ICONS[editor]" />
|
||||
<w-item-section class="pr-2">{{ t(labelFor(editor)) }}</w-item-section>
|
||||
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
@@ -59,14 +58,16 @@ import { loading } from '@/composables/loading'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
|
||||
import { useFlagsStore } from '@/stores/flags'
|
||||
import { EDITOR_ICONS } from '@/helpers/editors'
|
||||
|
||||
import { capitalize } from 'es-toolkit/string'
|
||||
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// STORES
|
||||
|
||||
const flagsStore = useFlagsStore()
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -87,6 +88,19 @@ useMeta(() => ({
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* The locale key naming an editor on this screen.
|
||||
*
|
||||
* Composed rather than tabulated, the same way the admin area names an editor
|
||||
* (`admin.editors.<id>Name`) and the search filter reads it back. The three that exist —
|
||||
* `createHomeMarkdown`, `createHomeVisual`, `createHomeAsciidoc` — are keyed by the editor id with
|
||||
* its first letter raised, so an article editor added later needs a string of its own here or the
|
||||
* menu shows the key.
|
||||
*/
|
||||
function labelFor(editor) {
|
||||
return `welcome.createHome${capitalize(editor)}`
|
||||
}
|
||||
|
||||
async function createHomePage(editor) {
|
||||
loading.show()
|
||||
siteStore.overlay = ''
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The icon each editor is offered under.
|
||||
*
|
||||
* Shared because three screens now offer the same editors and would otherwise each keep their own
|
||||
* copy of this table: the New Page menu, the New Post button on a blog, and the Welcome screen a
|
||||
* wiki with no home page yet greets its first author with.
|
||||
*
|
||||
* WHICH editors are offered, and in what order, is never decided here — `siteStore.activeEditors` and
|
||||
* `siteStore.articleEditors` are the only things that answer that, so what a page can be created with
|
||||
* and what a search can be filtered by cannot drift apart. This is only what each one looks like.
|
||||
*
|
||||
* `redirect` is in the table though no site can create one without turning it on, because the table
|
||||
* has to cover every editor a page may already have been written with.
|
||||
*
|
||||
* The names are blueprint icons rather than Iconify references — `BlueprintIcon` resolves one to
|
||||
* `img:/_assets/icons/ultraviolet-<name>.svg` — so they are outside the build-time icon scan, and
|
||||
* composing one from a variable is safe here in a way it would not be for an `mdi:` name.
|
||||
*/
|
||||
export const EDITOR_ICONS = {
|
||||
markdown: 'markdown',
|
||||
visual: 'google-presentation',
|
||||
asciidoc: 'asciidoc',
|
||||
channel: 'chat',
|
||||
blog: 'typewriter-with-paper',
|
||||
api: 'api',
|
||||
redirect: 'advance'
|
||||
}
|
||||
@@ -635,6 +635,16 @@ const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value &&
|
||||
*/
|
||||
const isBlog = computed(() => pageStore.editor === 'blog')
|
||||
|
||||
/**
|
||||
* Whether the page on screen is a redirection, which is a page with nowhere to read: it takes the
|
||||
* reader on rather than showing them anything -- see `PageRedirect.vue`, which is drawn in place of
|
||||
* the article column entirely.
|
||||
*
|
||||
* Its own computed rather than the comparison written out at each site, because three things now ask
|
||||
* it and they have to keep agreeing: the two view tabs and the sidebar.
|
||||
*/
|
||||
const isRedirect = computed(() => pageStore.editor === 'redirect')
|
||||
|
||||
/**
|
||||
* Whether a blog wants the column beside its listing at all.
|
||||
*
|
||||
@@ -656,7 +666,7 @@ const showSidebar = computed(() => {
|
||||
// -> Contents, tags and a rating, all of a page that is not there
|
||||
!pageStore.notFound &&
|
||||
// -> Nor of one nobody stays on: a redirection has no headings to list and is gone in a moment
|
||||
pageStore.editor !== 'redirect' &&
|
||||
!isRedirect.value &&
|
||||
// -> A blog keeps the column but fills it with its own thing, and only where it asked for one
|
||||
(!isBlog.value || blogWantsSidebar.value)
|
||||
)
|
||||
@@ -750,6 +760,13 @@ const showTalkTab = computed(
|
||||
// -> A blog's front page is a listing rather than an article: there is nothing here to discuss,
|
||||
// and the discussion a reader wants belongs on the post they are reading
|
||||
!isBlog.value &&
|
||||
/*
|
||||
Nor a redirection, for a stronger version of the same reason: it is a doorway rather than a
|
||||
page, the reader is on their way through it, and the discussion they want is about wherever it
|
||||
leads. There is no article column for a second view to sit beside either -- `PageRedirect`
|
||||
replaces it -- so the strip was chrome over a page nobody is reading.
|
||||
*/
|
||||
!isRedirect.value &&
|
||||
userStore.pagePermissions.includes('read:comments')
|
||||
)
|
||||
|
||||
@@ -777,6 +794,8 @@ const showLinksTab = computed(
|
||||
!editorStore.isActive &&
|
||||
// -> As above: the strip is gone on a blog, and a tab with no strip to sit in cannot be reached
|
||||
!isBlog.value &&
|
||||
// -> And on a redirection, which is drawn in place of the column the tab would switch
|
||||
!isRedirect.value &&
|
||||
Boolean(pageStore.id)
|
||||
)
|
||||
|
||||
|
||||
@@ -127,6 +127,20 @@ export const usePageStore = defineStore('page', {
|
||||
*/
|
||||
localeRelations: [],
|
||||
render: '',
|
||||
/**
|
||||
* Whether `render` above is HTML an editor in this session produced, rather than the page's own.
|
||||
*
|
||||
* Every page load fills `render` from the server, and what comes back has already been through
|
||||
* `postProcess` — so sending it up again re-sanitizes an output rather than an input, which is not
|
||||
* the no-op it looks like. It is done with the SAVING session's permissions, so a save by somebody
|
||||
* without `write:scripts` on the page strips the author's `<script>` out of a render nobody
|
||||
* touched. And the properties panel, the tags editor and the locale relations dialog all reach the
|
||||
* Save button with no editor open at all, which is exactly that case.
|
||||
*
|
||||
* So the render goes up only when an editor made one — `updatePage` leaves the column alone for a
|
||||
* key it was not sent. See the guard in `pageSave`, which is the same shape as `contentLoaded`'s.
|
||||
*/
|
||||
renderProduced: false,
|
||||
scriptJsLoad: '',
|
||||
scriptJsUnload: '',
|
||||
scriptCss: '',
|
||||
@@ -257,6 +271,8 @@ export const usePageStore = defineStore('page', {
|
||||
// -> The field is present exactly when the source came with the page, which is what makes
|
||||
// the copy in this store safe to save; a view-mode load leaves the previous one in place
|
||||
contentLoaded: Object.hasOwn(pageData, 'content'),
|
||||
// -> `...pageData` above brought the stored render with it; see `renderProduced`
|
||||
renderProduced: false,
|
||||
relations: pageData.relations.map((r) =>
|
||||
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
|
||||
),
|
||||
@@ -321,6 +337,7 @@ export const usePageStore = defineStore('page', {
|
||||
this.$patch({
|
||||
...pageData,
|
||||
contentLoaded: Object.hasOwn(pageData, 'content'),
|
||||
renderProduced: false,
|
||||
relations: pageData.relations.map((r) =>
|
||||
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
|
||||
),
|
||||
@@ -411,6 +428,7 @@ export const usePageStore = defineStore('page', {
|
||||
content: '',
|
||||
contentLoaded: false,
|
||||
render: '',
|
||||
renderProduced: false,
|
||||
toc: [],
|
||||
tags: [],
|
||||
relations: [],
|
||||
@@ -608,6 +626,7 @@ export const usePageStore = defineStore('page', {
|
||||
// -> A page being created has no stored source to lose: whatever it starts with IS the source
|
||||
contentLoaded: true,
|
||||
render: '',
|
||||
renderProduced: false,
|
||||
/*
|
||||
A redirection is in neither the browse menu nor search by default: the first because it is a
|
||||
doorway rather than a page to land on, the second because a result for one would stand in
|
||||
@@ -886,6 +905,20 @@ export const usePageStore = defineStore('page', {
|
||||
this.$patch({ title })
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Take the HTML an editor has just produced for the page in this store.
|
||||
*
|
||||
* The one way `render` is written from the client, so that the flag saying it may be saved cannot
|
||||
* drift from the value it describes — see `renderProduced`.
|
||||
*
|
||||
* @param {string} html
|
||||
*/
|
||||
setRender(html) {
|
||||
this.$patch({
|
||||
render: html,
|
||||
renderProduced: true
|
||||
})
|
||||
},
|
||||
/**
|
||||
* PAGE SAVE
|
||||
*/
|
||||
@@ -951,6 +984,21 @@ export const usePageStore = defineStore('page', {
|
||||
console.warn('Page source was never loaded; saving without touching the stored content.')
|
||||
}
|
||||
|
||||
/*
|
||||
And never send back a render this store did not make.
|
||||
|
||||
Every page load fills `render` from the server, so unless an editor has replaced it the store
|
||||
is holding the page's own stored HTML — already sanitized, already anchored, already reduced
|
||||
to a table of contents. Sending that up runs `postProcess` over its own output, with this
|
||||
session's permissions and not the author's, which is how changing a tag from the page view
|
||||
strips the `<script>` out of a page somebody else wrote. Dropping the key leaves the column
|
||||
alone, and the only save that has nothing to say about the render is one that did not touch
|
||||
the source.
|
||||
*/
|
||||
if (!this.renderProduced) {
|
||||
delete body.render
|
||||
}
|
||||
|
||||
let pageData
|
||||
if (editorStore.mode === 'create') {
|
||||
const resp = unwrap(
|
||||
@@ -993,6 +1041,8 @@ export const usePageStore = defineStore('page', {
|
||||
pick(r, ['locale', 'path', 'title'])
|
||||
),
|
||||
tocDepth: pick(pageData.tocDepth, ['min', 'max']),
|
||||
// -> The reply carries the stored render, as any other load does; see `renderProduced`
|
||||
renderProduced: false,
|
||||
// -> What was pending is now what is stored, which is the whole of what a save means here
|
||||
storedProps: storedPropsOf(pageData)
|
||||
})
|
||||
|
||||
@@ -53,6 +53,20 @@ function describeLocales(codes, installed) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The editors whose pages have no article of their own.
|
||||
*
|
||||
* The client's copy of `BODYLESS_EDITORS` in `backend/models/pages.ts`, which is what decides on the
|
||||
* server whether an empty content column is an error and — through `isBodylessEditor` — which pages
|
||||
* under a blog count as its posts. The two have to agree: an editor this list calls bodyless while
|
||||
* the server does not is one the blog offers to write a post with and then never lists.
|
||||
*
|
||||
* Longer than the server's by the two behind the experimental flag, which have no editor there to
|
||||
* have an opinion about them yet. Both plainly belong here: a discussion and a set of API docs are
|
||||
* not somebody being put in front of a blank article, which is the whole of what this divides.
|
||||
*/
|
||||
const BODYLESS_EDITORS = ['blog', 'channel', 'api', 'redirect']
|
||||
|
||||
export const useSiteStore = defineStore('site', {
|
||||
state: () => ({
|
||||
id: null,
|
||||
@@ -299,6 +313,21 @@ export const useSiteStore = defineStore('site', {
|
||||
...(this.editors.redirect ? ['redirect'] : [])
|
||||
]
|
||||
},
|
||||
/**
|
||||
* The active editors that put an author in front of a blank article.
|
||||
*
|
||||
* `activeEditors` minus the ones that write a settings document instead — a blog's front page is
|
||||
* a listing of the posts under it, a redirection is a target. Derived from that list rather than
|
||||
* written out again, so an editor a site has switched off is missing from both and the order is
|
||||
* the one the menu already uses.
|
||||
*
|
||||
* Two callers, and they want it for the same reason. A blog's **New Post** button offers exactly
|
||||
* these, since a post is an article and `models/blogs.ts` does not count anything else as one.
|
||||
* And `PageNewMenu` draws its divider where this list ends.
|
||||
*/
|
||||
articleEditors() {
|
||||
return this.activeEditors.filter((editor) => !BODYLESS_EDITORS.includes(editor))
|
||||
},
|
||||
/** Whether `code` is one of the locales this site has enabled. */
|
||||
isActiveLocale: (state) => (code) => state.locales.active.some((lc) => lc.code === code),
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user