From c00b7007eba46ab18690e57ec7f66b3e198a415c Mon Sep 17 00:00:00 2001 From: NGPixel Date: Fri, 7 Aug 2026 22:17:35 -0400 Subject: [PATCH] feat: strip blocks that are disabled for a site and warn in the preview --- backend/locales/en.json | 1 + backend/models/blocks.ts | 19 ++++ backend/models/pages.ts | 16 +-- backend/models/rendering.ts | 69 ++++++++++++- frontend/src/components/EditorMarkdown.vue | 112 ++++++++++++++++++++- 5 files changed, 205 insertions(+), 12 deletions(-) diff --git a/backend/locales/en.json b/backend/locales/en.json index 62a679a12..66c99fdb5 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1659,6 +1659,7 @@ "editor.assets.uploadAssetsDropZone": "Browse or Drop files here...", "editor.assets.uploadFailed": "File upload failed.", "editor.backToEditor": "Back to Editor", + "editor.blockNotEnabled": "This block is not enabled for this site, and will be removed when the page is saved. Blocks are managed in the administration area.", "editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.", "editor.blockPicker.insert": "Insert Block", "editor.blockPicker.loadFailed": "Failed to load the list of blocks.", diff --git a/backend/models/blocks.ts b/backend/models/blocks.ts index 8d858c8e2..e37215b31 100644 --- a/backend/models/blocks.ts +++ b/backend/models/blocks.ts @@ -283,6 +283,25 @@ class Blocks { }) } + /** + * The keys of the blocks a site has switched on. + * + * Read from the database on every call rather than kept in a cache like this model's definitions. + * What this answer gates is which blocks survive a page being saved, and a stale `false` silently + * strips an author's block out of their page — a wrong answer here destroys content rather than + * merely showing the wrong list. One indexed read of a handful of rows, on a path that has just + * sanitised a whole document, is not worth that risk. + * + * Child blocks never appear: they have no row of their own, and follow the block they sit in. + */ + async getEnabledKeys(siteId: string): Promise> { + const rows = await WIKI.db + .select({ block: blocksTable.block }) + .from(blocksTable) + .where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.isEnabled, true))) + return new Set(rows.map((row) => row.block)) + } + /** * Enable or disable blocks in bulk. * diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 292bda2a2..64a2af28c 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -392,10 +392,14 @@ class Pages { } const alias = await this.validateAlias(siteId, input.alias) - const { render, toc, text } = WIKI.models.rendering.postProcess(input.render ?? '', { - scripts: hasPermission(actor, 'write:scripts'), - styles: hasPermission(actor, 'write:styles') - }) + const { render, toc, text } = await WIKI.models.rendering.postProcess( + siteId, + input.render ?? '', + { + scripts: hasPermission(actor, 'write:scripts'), + styles: hasPermission(actor, 'write:styles') + } + ) const pathParts = path.split('/') const inserted = await WIKI.db @@ -551,7 +555,7 @@ class Pages { // -> A render only means anything next to the content it came from, so the two move together if (patch.render !== undefined) { - const { render, toc, text } = WIKI.models.rendering.postProcess(patch.render, { + const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, patch.render, { scripts: hasPermission(actor, 'write:scripts'), styles: hasPermission(actor, 'write:styles') }) @@ -786,7 +790,7 @@ class Pages { html: string, permissions: RenderPermissions ): Promise { - const { render, toc, text } = WIKI.models.rendering.postProcess(html, permissions) + const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, html, permissions) const updated = await WIKI.db .update(pagesTable) diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts index 91acba13b..cb8d8bb2d 100644 --- a/backend/models/rendering.ts +++ b/backend/models/rendering.ts @@ -284,17 +284,24 @@ class Rendering { /** * Clean up a render that came from a client, and pull out what is derived from it. * + * @param siteId Whose blocks decide which block elements may stay — see `blockAllowances` * @param html The HTML the editor produced * @param permissions What the author may embed. Anything not granted is stripped rather than * rejected: an author pasting a snippet with a tracking script should get their * page saved without it, not an error they cannot act on. */ - postProcess(html: string, permissions: RenderPermissions): PostProcessResult { - const clean = this.sanitize(html ?? '', permissions) + async postProcess( + siteId: string, + html: string, + permissions: RenderPermissions + ): Promise { + const enabledBlocks = await WIKI.models.blocks.getEnabledKeys(siteId) + const clean = this.sanitize(html ?? '', permissions, enabledBlocks) const $ = cheerio.load(clean, null, false) this.stripEditorArtifacts($) + this.unwrapOrphanedChildBlocks($) const toc = this.anchorHeadings($) return { @@ -313,11 +320,27 @@ class Rendering { * each tag gets exactly the attributes its component declares as props, which is the same set the * editor's block picker offers. The markup is inert either way: what makes a block do anything is * the component fetched from `/_blocks` at view time. + * + * Installed is not sufficient: the block also has to be switched on for this site. Leaving the + * picker to decide that would only cover the authors who use it — the content is markdown, so + * `::block-diagram` is a thing anybody can type, and a block an administrator turned off would + * otherwise render for every reader of that page. Being stripped on the way in is also what makes + * turning a block off take effect on the pages that already embed it, since each is re-rendered + * through here. + * + * Child blocks are exempt, having no switch of their own: a tab is part of the tabs it sits in, + * and is gated by `unwrapOrphanedChildBlocks` once the parent's fate is known. */ - private blockAllowances(): { tags: string[]; attributes: Record } { + private blockAllowances(enabledBlocks: Set): { + tags: string[] + attributes: Record + } { const tags: string[] = [] const attributes: Record = {} for (const definition of WIKI.models.blocks.definitions) { + if (!definition.isChild && !enabledBlocks.has(definition.block)) { + continue + } const tag = `block-${definition.block}` tags.push(tag) attributes[tag] = (definition.props ?? []).map((prop) => prop.name) @@ -325,11 +348,47 @@ class Rendering { return { tags, attributes } } + /** + * Unwrap child blocks that no longer sit inside a block. + * + * A child block is allowed through the sanitiser unconditionally, because whether it may stay is + * not a question about itself: it is part of its parent, and the parent is what an administrator + * switches on and off. By this point the answer is visible in the document — a parent that was + * disabled has already been dropped, leaving its children behind as orphans — so a child with no + * block above it is one whose parent was turned off, or one an author typed on its own. + * + * Unwrapped rather than deleted, which is what the sanitiser does to every other tag it refuses: + * the element goes, the content the author wrote inside it stays. + */ + private unwrapOrphanedChildBlocks($: cheerio.CheerioAPI): void { + const definitions = WIKI.models.blocks.definitions + const childTags = definitions.filter((d) => d.isChild).map((d) => `block-${d.block}`) + if (childTags.length < 1) { + return + } + /* + Every non-child block, not merely the enabled ones: a disabled block is not in the document to + be matched, and naming the full set keeps this a question about nesting rather than a second + copy of the enabled-block rule that could disagree with the first. + */ + const parentTags = definitions.filter((d) => !d.isChild).map((d) => `block-${d.block}`) + $(childTags.join(',')).each((_, el) => { + if (parentTags.length > 0 && $(el).parents(parentTags.join(',')).length > 0) { + return + } + $(el).replaceWith($(el).contents()) + }) + } + /** * Strip everything the author is not allowed to embed. */ - private sanitize(html: string, permissions: RenderPermissions): string { - const blocks = this.blockAllowances() + private sanitize( + html: string, + permissions: RenderPermissions, + enabledBlocks: Set + ): string { + const blocks = this.blockAllowances(enabledBlocks) const allowedTags = [...BASE_ALLOWED_TAGS, ...blocks.tags] const allowedAttributes: Record = { ...BASE_ALLOWED_ATTRIBUTES, diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 77248da07..1b5e1944f 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -358,6 +358,19 @@ let pasteCaptureNode = null const monacoRef = ref(null) const editorPreviewContainerRef = ref(null) +/** + * Blocks this site has switched off, as the tags they are written as. + * + * The preview fetches a component for every element it does not recognise, so a disabled block would + * draw here and then disappear the moment the page was saved — the server strips one that is not + * enabled out of the render. Naming them lets the preview leave the element undefined, which is what + * the saved page comes back as: the block gone, the content the author wrote inside it still there. + * + * Only what the site lists as off. A tag that is not in the list at all is a child block, which has no + * switch of its own, or an unknown one — this decides nothing about either. + */ +const disabledBlockTags = ref(new Set()) + /* Listed rather than built as `mdi:format-header-${lvl}`: a concatenated icon name is invisible to the build-time icon scan, so it would ship as six blank squares. @@ -758,6 +771,53 @@ async function toggleMarkup({ start, end }) { editor.executeEdits('', edits) } +/** + * Read which blocks this site has switched off, once, before the first preview is drawn. + * + * Order matters more than it looks: a component only has to be fetched once to be defined for the + * rest of the session, so a list that arrives after the first render is too late to keep a disabled + * block from drawing. + */ +async function loadDisabledBlocks() { + try { + const blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? [] + disabledBlockTags.value = new Set( + blocks.filter((block) => !block.isEnabled).map((block) => `block-${block.block}`) + ) + } catch (err) { + /* + Left empty, which draws everything as it did before. The preview being too generous is the + better failure: the server strips a disabled block on save either way, so the cost is a preview + that flatters the page, against hiding blocks the site really does have. + */ + console.warn(`Could not read which blocks this site has enabled: ${err.message}`) + } +} + +/** + * Say why a block is sitting there doing nothing. + * + * A disabled block is left undefined, so it draws as its own contents and otherwise says nothing — + * which reads as a block that is broken rather than one that is switched off. The notice names the + * reason and what saving will do about it; what the author wrote stays underneath, because that is + * what the saved page keeps once the server has stripped the element. + * + * Written into the preview's DOM rather than into the render, which is deliberate: `pageStore.render` + * is what `pageSave` sends, and a notice added to it would be a notice saved into the page. The + * preview is rebuilt from that string on every keystroke, so this is re-applied each time and nothing + * has to be cleaned up — the same footing `enhanceRenderedContent` works on. + */ +function markDisabledBlock(el) { + if (el.dataset.blockDisabled !== undefined) { + return + } + el.dataset.blockDisabled = '' + const notice = document.createElement('p') + notice.className = 'block-disabled-notice' + notice.textContent = t('editor.blockNotEnabled') + el.prepend(notice) +} + function processContent(newContent) { /* A render that throws must not become a render that is empty. @@ -784,7 +844,13 @@ function processContent(newContent) { }) nextTick(() => { for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) { - commonStore.loadBlocks([block.tagName.toLowerCase()]) + const tag = block.tagName.toLowerCase() + // -> Left undefined on purpose, so the preview shows what saving is about to leave behind + if (disabledBlockTags.value.has(tag)) { + markDisabledBlock(block) + continue + } + commonStore.loadBlocks([tag]) } // -> The render was just replaced, so the copy buttons went with it enhanceRenderedContent(editorPreviewContainerRef.value) @@ -903,6 +969,9 @@ onMounted(async () => { hideSideNav: true }) + // -> Awaited here so it is settled well before the first preview render at the end of this hook + await loadDisabledBlocks() + md = new MarkdownRenderer(editorStore.editors.markdown) // -> Define Monaco Theme @@ -1296,6 +1365,47 @@ $editor-height-mobile: calc(100vh - 112px - 16px); p.line { overflow-wrap: break-word; } + /* + A block this site has switched off, marked by `markDisabledBlock`. Editor-only styling: the + server strips the element on save, so no reader ever meets one of these. + + Built from the admonition palette `.page-contents` already declares -- the preview pane + carries that class, so both themes are covered by the tokens rather than by a rule here. + */ + [data-block-disabled] { + display: block; + margin: 1rem 0; + padding: 0.75rem 1rem; + border-left: 4px solid var(--content-danger); + border-radius: 3px; + background-color: var(--content-danger-wash); + color: var(--content-ink-muted); + } + .block-disabled-notice { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0; + color: var(--content-danger); + font-size: 0.85rem; + font-weight: 600; + + /* -> `mdi:alert`, drawn as a mask so it takes the colour above rather than one of its own */ + &::before { + content: ''; + flex: 0 0 auto; + width: 1.1rem; + height: 1.1rem; + background-color: currentColor; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z'/%3E%3C/svg%3E"); + mask-repeat: no-repeat; + mask-size: contain; + } + } + /* -> Whatever the author wrote inside, which is what the saved page is left holding */ + [data-block-disabled] > .block-disabled-notice + * { + margin-top: 0.5rem; + } .tabset { background-color: $teal-7; color: $teal-2 !important;