diff --git a/CLAUDE.md b/CLAUDE.md
index 2a03414b6..1fa0d327f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -238,6 +238,25 @@ the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` / `Tempora
Both tools handle `.ts` with no extra configuration, and the backend's oxlint config already enables
the `typescript` plugin. oxlint does not type-check — run `npm run typecheck` for that.
+**Never put two statements in a Vue template attribute.** `@click="doOne(); doTwo()"` builds today
+and is a build error the moment the file is formatted, because `semi: false` and Vue disagree about
+the same character. Vue's `transformOn` decides whether an inline handler is a statement block or an
+expression from `exp.content.includes(';')` — with the semicolon it emits `$event => { … }`,
+without it `$event => ( … )`. oxfmt breaks the handler across lines and drops the semicolon, so Vue
+parenthesises two statements and the template fails to compile (`Error parsing JavaScript
+expression: Unexpected token`). Write a named handler instead — `@click="closeAndRefresh"` — as
+`EditorMarkdown.vue` and `PageRelationDialog.vue` do.
+
+Neither side of that is worth reconfiguring, so don't try: the `includes(';')` check has no compiler
+option behind it, and the parse error is raised by the built-in `transformExpression`, which
+`baseCompile` runs *before* any `nodeTransforms` you could add — and Volar runs the same compiler,
+so a build-time workaround would still leave the editor showing errors. On the formatter side,
+`embeddedLanguageFormatting: "off"` does leave attribute expressions alone but also stops formatting
+every `
+
+
diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue
index 60733ed12..aa4bff1e8 100644
--- a/frontend/src/components/EditorMarkdown.vue
+++ b/frontend/src/components/EditorMarkdown.vue
@@ -29,12 +29,12 @@
t('editor.markup.insertTable')
}}
-
+
{{
t('editor.markup.insertTabset')
}}
-
+
{{
t('editor.markup.insertBlock')
}}
@@ -44,7 +44,7 @@
t('editor.markup.insertDiagram')
}}
-
+
{{
t('editor.markup.insertFootnote')
}}
@@ -276,6 +276,7 @@ import { useI18n } from 'vue-i18n'
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
+import { blockMarkdown } from '@/helpers/blocks'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue'
@@ -398,6 +399,55 @@ function insertEmoji(shortcode) {
insertAtCursor({ content: `:${shortcode}:` })
}
+function insertBlock() {
+ siteStore.$patch({
+ overlay: 'BlockPicker'
+ })
+}
+
+/**
+ * The tabset, without going through the picker.
+ *
+ * A shortcut to picking Tabs from the block list and inserting it as it stands, so the markup is
+ * built from the same definition rather than written out a second time here — a change to the block's
+ * starter body reaches both. It still asks the server which blocks this site has: a shortcut to a
+ * block an administrator switched off would insert something the page cannot draw.
+ */
+async function insertTabset() {
+ try {
+ const blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? []
+ const tabs = blocks.find((block) => block.block === `tabs` && block.isEnabled)
+ if (!tabs) {
+ notify({
+ type: 'warning',
+ message: t('editor.blockPicker.blockUnavailable')
+ })
+ return
+ }
+ insertBlockClb(blockMarkdown(tabs))
+ } catch (err) {
+ notify({
+ type: 'negative',
+ message: t('editor.blockPicker.loadFailed'),
+ caption: err.message
+ })
+ }
+}
+
+/**
+ * The block the picker built, on its own lines.
+ *
+ * MDC's block syntax only opens a component when `::` starts a line, so a cursor mid-sentence breaks
+ * out of it first — the same rule the table follows.
+ */
+function insertBlockClb(markdown) {
+ const position = editor.getPosition()
+ const line = editor.getModel().getLineContent(position.lineNumber)
+ const before = line.slice(0, position.column - 1).trim().length > 0 ? '\n\n' : ''
+ const after = line.slice(position.column - 1).trim().length > 0 ? '\n\n' : '\n'
+ insertAtCursor({ content: `${before}${markdown}${after}` })
+}
+
function insertTable() {
siteStore.$patch({
overlay: 'TableEditor'
@@ -429,6 +479,59 @@ function insertTableClb(markdown) {
* `{target="_blank"}` is markdown-it-attrs syntax, and `target` is one of the three attributes the
* stored render is allowed to keep — see `renderers/markdown.js` and `models/rendering.ts`.
*/
+/**
+ * The number to give the next footnote.
+ *
+ * Markdown numbers footnotes in the order they are referenced, not by their labels, so these are
+ * names rather than positions — but an author reading the source expects them to count up, and two
+ * notes sharing a name would collapse into one. Anything the author named themselves is left alone
+ * and simply counted past.
+ */
+function nextFootnoteLabel(text) {
+ let highest = 0
+ for (const [, label] of text.matchAll(/\[\^([^\]\s]+)\]/g)) {
+ if (/^\d+$/.test(label)) {
+ highest = Math.max(highest, Number.parseInt(label, 10))
+ }
+ }
+ return String(highest + 1)
+}
+
+/**
+ * A footnote: the marker where the cursor is, and the note itself at the foot of the source.
+ *
+ * Both halves in one edit, because either alone is broken — a marker with no note renders as literal
+ * text, and a note nothing refers to renders as nothing at all. The cursor ends on the note, since
+ * writing it is what the author was about to do; the marker is already where they left it.
+ */
+function insertFootnote() {
+ const model = editor.getModel()
+ const label = nextFootnoteLabel(model.getValue())
+ const cursor = editor.getPosition()
+ const lastLine = model.getLineCount()
+ const lastLineLength = model.getLineContent(lastLine).length
+ // -> On a line of its own at the end, one blank line clear of whatever the page ends with
+ const lead = lastLineLength > 0 ? `\n\n` : ``
+
+ editor.executeEdits('', [
+ {
+ range: new Range(cursor.lineNumber, cursor.column, cursor.lineNumber, cursor.column),
+ text: `[^${label}]`,
+ forceMoveMarkers: true
+ },
+ {
+ range: new Range(lastLine, lastLineLength + 1, lastLine, lastLineLength + 1),
+ text: `${lead}[^${label}]: `,
+ forceMoveMarkers: true
+ }
+ ])
+
+ const noteLine = model.getLineCount()
+ editor.setPosition({ lineNumber: noteLine, column: model.getLineContent(noteLine).length + 1 })
+ editor.revealLineInCenterIfOutsideViewport(noteLine)
+ editor.focus()
+}
+
function insertLink() {
dialog({ component: LinkPickerDialog }).onOk(({ href, openInNewTab, title }) => {
const selection = editor.getSelection()
@@ -920,6 +1023,7 @@ onMounted(async () => {
EVENT_BUS.on('insertAsset', insertAssetClb)
EVENT_BUS.on('insertTable', insertTableClb)
+ EVENT_BUS.on('insertBlock', insertBlockClb)
EVENT_BUS.on('openEditorSettings', openEditorSettings)
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
@@ -959,6 +1063,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
EVENT_BUS.off('insertAsset', insertAssetClb)
EVENT_BUS.off('insertTable', insertTableClb)
+ EVENT_BUS.off('insertBlock', insertBlockClb)
EVENT_BUS.off('openEditorSettings', openEditorSettings)
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true)
diff --git a/frontend/src/components/MainOverlayDialog.vue b/frontend/src/components/MainOverlayDialog.vue
index 0d124ca6b..54231202d 100644
--- a/frontend/src/components/MainOverlayDialog.vue
+++ b/frontend/src/components/MainOverlayDialog.vue
@@ -17,6 +17,10 @@ import { useSiteStore } from '../stores/site'
import LoadingGeneric from './LoadingGeneric.vue'
const overlays = {
+ BlockPicker: defineAsyncComponent({
+ loader: () => import('./BlockPickerOverlay.vue'),
+ loadingComponent: LoadingGeneric
+ }),
EditorMarkdownConfig: defineAsyncComponent({
loader: () => import('./EditorMarkdownUserSettingsOverlay.vue'),
loadingComponent: LoadingGeneric
diff --git a/frontend/src/components/PageToc.vue b/frontend/src/components/PageToc.vue
index a8429368c..cb7549beb 100644
--- a/frontend/src/components/PageToc.vue
+++ b/frontend/src/components/PageToc.vue
@@ -26,6 +26,7 @@