diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts
index 5ef2f2651..3e1a192d4 100644
--- a/backend/models/rendering.ts
+++ b/backend/models/rendering.ts
@@ -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 }
}
diff --git a/frontend/src/components/EditorAsciidoc.vue b/frontend/src/components/EditorAsciidoc.vue
index fdff7ac3d..f45ddc69b 100644
--- a/frontend/src/components/EditorAsciidoc.vue
+++ b/frontend/src/components/EditorAsciidoc.vue
@@ -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
diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue
index 06b2dbe66..17abfdf10 100644
--- a/frontend/src/components/EditorMarkdown.vue
+++ b/frontend/src/components/EditorMarkdown.vue
@@ -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
diff --git a/frontend/src/components/EditorVisual.vue b/frontend/src/components/EditorVisual.vue
index 467f67358..564bc576d 100644
--- a/frontend/src/components/EditorVisual.vue
+++ b/frontend/src/components/EditorVisual.vue
@@ -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()
})
diff --git a/frontend/src/components/PageBlog.vue b/frontend/src/components/PageBlog.vue
index fb613e93e..a9895d0a0 100644
--- a/frontend/src/components/PageBlog.vue
+++ b/frontend/src/components/PageBlog.vue
@@ -72,7 +72,18 @@
color="primary"
padding="xs md"
:label="t(`common.blog.newPost`)">
-
+
+
@@ -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.
*
diff --git a/frontend/src/components/PageNewMenu.vue b/frontend/src/components/PageNewMenu.vue
index 6e945dbb6..0ae29b814 100644
--- a/frontend/src/components/PageNewMenu.vue
+++ b/frontend/src/components/PageNewMenu.vue
@@ -9,10 +9,14 @@
Narrowed by `only` where a caller has a reason to offer fewer -- see the prop.
-->
-
-
- {{ t(`common.createPage.${editor}`) }}
-
+
+
+
+
+
+ {{ t(`common.createPage.${editor}`) }}
+
+
@@ -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.`, 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.`, 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) {
diff --git a/frontend/src/components/WelcomeOverlay.vue b/frontend/src/components/WelcomeOverlay.vue
index 2481ab83e..57cfb88db 100644
--- a/frontend/src/components/WelcomeOverlay.vue
+++ b/frontend/src/components/WelcomeOverlay.vue
@@ -8,26 +8,25 @@