From 023711cd1a7953c8c8728341fbca0ac37fe705ee Mon Sep 17 00:00:00 2001 From: NGPixel Date: Fri, 18 Sep 2026 03:23:08 -0400 Subject: [PATCH] fix: content blocks conflicting prop casing with the sanitizer + avoid sending the render if untouched --- backend/models/rendering.ts | 19 ++++++- frontend/src/components/EditorAsciidoc.vue | 4 +- frontend/src/components/EditorMarkdown.vue | 4 +- frontend/src/components/EditorVisual.vue | 6 +-- frontend/src/components/PageBlog.vue | 23 +++++---- frontend/src/components/PageNewMenu.vue | 60 +++++++++++++--------- frontend/src/components/WelcomeOverlay.vue | 52 ++++++++++++------- frontend/src/helpers/editors.js | 27 ++++++++++ frontend/src/pages/Index.vue | 21 +++++++- frontend/src/stores/page.js | 50 ++++++++++++++++++ frontend/src/stores/site.js | 29 +++++++++++ 11 files changed, 231 insertions(+), 64 deletions(-) create mode 100644 frontend/src/helpers/editors.js 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}`) }} - +