From a9940477df03c99c420a3d879167f00f4d3868f3 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Fri, 18 Sep 2026 02:49:55 -0400 Subject: [PATCH] feat: asciidoc editor --- backend/api/schemas/block.ts | 7 +- backend/api/schemas/site.ts | 12 + backend/base.yml | 2 +- backend/models/blocks.ts | 15 + backend/models/pages.ts | 4 +- backend/models/rendering.ts | 44 +- backend/models/sites.ts | 20 + blocks/block-steps/component.js | 16 +- blocks/block-tabs/component.js | 16 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + .../src/components/BlockPickerOverlay.vue | 27 +- frontend/src/components/EditorAsciidoc.vue | 1808 +++++++++++++++++ .../src/components/PageHistoryOverlay.vue | 1 + frontend/src/components/WelcomeOverlay.vue | 2 +- frontend/src/css/_page-contents-asciidoc.scss | 498 +++++ frontend/src/css/app.scss | 2 + frontend/src/editor/visual/nodeviews.js | 3 +- frontend/src/helpers/asciidocBlocks.js | 360 ++++ frontend/src/helpers/blocks.js | 111 +- frontend/src/helpers/monacoAsciidoc.js | 184 ++ frontend/src/helpers/pageVersions.js | 23 +- frontend/src/pages/AdminEditors.vue | 18 +- frontend/src/pages/Index.vue | 11 + frontend/src/pages/PageVersion.vue | 4 + frontend/src/pages/Search.vue | 6 +- frontend/src/renderers/asciidoc.js | 587 ++++++ frontend/src/renderers/headless.js | 37 +- frontend/src/renderers/markdown.js | 278 +-- frontend/src/renderers/shared.js | 291 +++ frontend/src/stores/site.js | 25 +- 31 files changed, 4096 insertions(+), 327 deletions(-) create mode 100644 frontend/src/components/EditorAsciidoc.vue create mode 100644 frontend/src/css/_page-contents-asciidoc.scss create mode 100644 frontend/src/helpers/asciidocBlocks.js create mode 100644 frontend/src/helpers/monacoAsciidoc.js create mode 100644 frontend/src/renderers/asciidoc.js create mode 100644 frontend/src/renderers/shared.js diff --git a/backend/api/schemas/block.ts b/backend/api/schemas/block.ts index 7c1a02835..99c4dea00 100644 --- a/backend/api/schemas/block.ts +++ b/backend/api/schemas/block.ts @@ -47,10 +47,15 @@ export async function registerSchemas(app: FastifyInstance): Promise { description: 'Body the editor writes between the opening and closing lines when inserting the block, for a block whose content is other blocks. Empty for a block that takes none.' }, + asciidocTemplate: { + type: 'string', + description: + 'The same starter body written in AsciiDoc, for a block whose template spells structure that syntax writes differently — nested blocks, or a list with paragraphs attached to its items. Empty for almost every block: a body that is one fenced source is rewritten mechanically, and one that is plain prose reads the same in both syntaxes.' + }, contentEditor: { type: 'string', description: - "Names an editor for the block's body, which the markdown editor offers as an \"Edit Content\" lens above the block alongside \"Edit Block Parameters\". A key the frontend resolves to a component, for a block whose body is a fenced source the props form cannot describe. Empty for a block that names none, which is most of them." + 'Names an editor for the block\'s body, which the markdown editor offers as an "Edit Content" lens above the block alongside "Edit Block Parameters". A key the frontend resolves to a component, for a block whose body is a fenced source the props form cannot describe. Empty for a block that names none, which is most of them.' }, props: { type: 'array', diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index 59a030b90..7702d5b78 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -311,6 +311,18 @@ export async function registerSchemas(app: FastifyInstance): Promise { } } }, + redirect: { + type: 'object', + properties: { + isActive: { + type: 'boolean' + }, + config: { + type: 'object', + additionalProperties: true + } + } + }, visual: { type: 'object', properties: { diff --git a/backend/base.yml b/backend/base.yml index 035db1bbb..85cc19ed0 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -178,7 +178,7 @@ tsDictMappings: yi: yiddish editors: asciidoc: - contentType: html + contentType: adoc config: {} markdown: contentType: markdown diff --git a/backend/models/blocks.ts b/backend/models/blocks.ts index 506614641..e781e4d15 100644 --- a/backend/models/blocks.ts +++ b/backend/models/blocks.ts @@ -39,6 +39,17 @@ export interface BlockDefinition { isChild?: boolean /** Body the editor writes between the opening and closing lines when inserting the block. */ template?: string + /** + * The same starter body in AsciiDoc, for a block whose template cannot be derived from the markdown + * one. + * + * Absent for almost every block, and absent is a complete answer: a body that is one fenced source + * is rewritten mechanically, and a body that is plain prose reads identically in both syntaxes. + * What needs this is a template that spells STRUCTURE — nested blocks, or a list with paragraphs + * attached to its items — since AsciiDoc writes both differently. See `asciidocTemplate` in + * `frontend/src/helpers/blocks.js`. + */ + asciidocTemplate?: string /** * Names an editor for the block's BODY, which the markdown editor then offers as a second lens * above the block — "Edit Content", beside "Edit Block Parameters". @@ -65,6 +76,8 @@ export interface SiteBlock { config: Record props: BlockProp[] template: string + /** Empty for a block whose starter body needs no AsciiDoc spelling of its own — see the definition. */ + asciidocTemplate: string /** Empty for a block that names no body editor, which is most of them. */ contentEditor: string /** @@ -309,6 +322,7 @@ class Blocks { ...row, props: definition?.props ?? [], template: definition?.template ?? '', + asciidocTemplate: definition?.asciidocTemplate ?? '', contentEditor: definition?.contentEditor ?? '', isChild: false } @@ -338,6 +352,7 @@ class Blocks { config: {}, props: definition.props ?? [], template: definition.template ?? '', + asciidocTemplate: definition.asciidocTemplate ?? '', contentEditor: definition.contentEditor ?? '', isChild: true })) diff --git a/backend/models/pages.ts b/backend/models/pages.ts index 13d52e75c..26f574856 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -18,7 +18,7 @@ import type { StoragePageContent, StoragePageRef } from './storage.ts' const EDITOR_CONTENT_TYPES: Record = { markdown: 'markdown', visual: 'markdown', - asciidoc: 'asciidoc', + asciidoc: 'adoc', redirect: 'redirect', blog: 'blog' } @@ -53,7 +53,7 @@ export function interchangeableEditors(editor: string): string[] { export const PAGE_FILE_EXTENSIONS: Record = { markdown: 'md', html: 'html', - asciidoc: 'adoc', + adoc: 'adoc', redirect: 'json', blog: 'json' } diff --git a/backend/models/rendering.ts b/backend/models/rendering.ts index 47dda1e3c..5ef2f2651 100644 --- a/backend/models/rendering.ts +++ b/backend/models/rendering.ts @@ -34,6 +34,16 @@ import type { IconifyIconCustomisations } from '@iconify/utils' * browser. That is a job rather than part of a request: see `queuePage` and `drainQueue`. */ +/** + * The editors whose source the renderer bundle knows how to turn into HTML. + * + * Both pipelines live in the frontend and both are reached through the same `__wikiRender`, which + * picks one by the editor it is handed -- so this list is the server's copy of what that function + * will accept, and the two have to agree. An editor missing from it is refused before anything is + * queued rather than after a browser has been started for it. + */ +const RENDERABLE_EDITORS = new Set(['markdown', 'asciidoc']) + /** How long the renderer bundle gets to load itself in the headless browser, in milliseconds. */ const RENDER_READY_TIMEOUT = 30000 @@ -84,10 +94,13 @@ export interface PostProcessResult { */ interface PageRenderer { /** - * Markdown in, the editor's own HTML out — before `postProcess` gets to it. + * A page's source in, the editor's own HTML out — before `postProcess` gets to it. * - * `context` carries what the source cannot say about itself, currently the page's own path: a - * relative image in a page resolves against the folder it sits in, as it would in a repository. + * `context` carries what the source cannot say about itself: the page's own path, since a relative + * image in a page resolves against the folder it sits in as it would in a repository, and the + * `editor` it was written with, which is what picks the pipeline on the other side. A source is not + * self-describing — `= Title` is a heading in one syntax and an attribute list in neither — so + * nothing can be guessed from the text. */ render( content: string, @@ -137,6 +150,19 @@ const BASE_ALLOWED_TAGS = [ */ 'iconify-icon', 'img', + /* + The checkbox of a task list, and nothing else worth having. + + `BASE_ALLOWED_ATTRIBUTES` has always named the three attributes one may carry -- `type`, `checked` + and `disabled` -- but the tag itself was never on this list, so sanitizing dropped every one of + them and a task list came out as two lines of prose. Both editors draw them: `markdown-it-task-lists` + writes one, and `convert_checklist` in the AsciiDoc pipeline writes the same element. + + It cannot do anything. An `` submits nothing outside a `
`, which is not on this list, + and the renderer marks every one of these disabled; `on*` handlers are gated behind + `write:scripts` like everywhere else. + */ + 'input', 'ins', 'kbd', 'mark', @@ -257,6 +283,12 @@ const BASE_ALLOWED_ATTRIBUTES: Record = { img: ['src', 'srcset', 'alt', 'width', 'height', 'loading', 'decoding'], input: ['type', 'checked', 'disabled'], ol: ['start', 'reversed', 'type'], + /* + Column widths, which the AsciiDoc pipeline emits for every table it draws (`[cols="1,2"]` becomes + a `` of percentages). Presentational and inert -- a width is a number and a unit, and + `col` is a void element with nothing in it to carry anything else. + */ + col: ['width', 'span'], source: ['src', 'srcset', 'type', 'media'], td: ['colspan', 'rowspan', 'align'], th: ['colspan', 'rowspan', 'align', 'scope'], @@ -829,7 +861,7 @@ class Rendering { * render would leave a page's HTML lying about its content. */ async ensureCanRender(editor: string): Promise { - if (editor !== 'markdown') { + if (!RENDERABLE_EDITORS.has(editor)) { throw new CustomError( 'renderUnsupportedEditor', `Server-side rendering is not implemented for the ${editor} editor.` @@ -995,7 +1027,7 @@ class Rendering { // for a page that went between the claim and here. continue } - if (page.editor !== 'markdown') { + if (!RENDERABLE_EDITORS.has(page.editor)) { WIKI.logger.warn( `Cannot render page ${page.id}: server-side rendering is not implemented for the ${page.editor} editor.` ) @@ -1004,7 +1036,7 @@ class Rendering { const html = await renderer.render( page.content ?? '', WIKI.sites[entry.siteId]?.config?.editors?.[page.editor]?.config ?? {}, - { pagePath: page.path } + { pagePath: page.path, editor: page.editor } ) await WIKI.models.pages.storeRender(entry.siteId, page.id, html, { scripts: entry.allowScripts, diff --git a/backend/models/sites.ts b/backend/models/sites.ts index b53f08442..4dabe2dd0 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -225,6 +225,16 @@ class Sites { underline: true } }, + /* + No config of its own: a redirection is a page with a target instead of a body, so there is + nothing about how it is written to configure. This flag is only whether the site offers + `New Redirection` at all — turning it off leaves the redirections a site already has + working and editable, and stops new ones being made. + */ + redirect: { + isActive: true, + config: {} + }, /* No config of its own, deliberately. The Visual editor writes markdown and its preview is rendered by the markdown pipeline, so it reads `markdown.config` — two settings blobs that @@ -501,6 +511,16 @@ class Sites { underline: true } }, + /* + No config of its own: a redirection is a page with a target instead of a body, so there is + nothing about how it is written to configure. This flag is only whether the site offers + `New Redirection` at all — turning it off leaves the redirections a site already has + working and editable, and stops new ones being made. + */ + redirect: { + isActive: true, + config: {} + }, /* No config of its own, deliberately. The Visual editor writes markdown and its preview is rendered by the markdown pipeline, so it reads `markdown.config` — two settings blobs that diff --git a/blocks/block-steps/component.js b/blocks/block-steps/component.js index 2bdab4836..5471b5e43 100644 --- a/blocks/block-steps/component.js +++ b/blocks/block-steps/component.js @@ -41,7 +41,21 @@ export class BlockStepsElement extends HTMLElement { What to do next. -3. Done` +3. Done`, + /* + The same three steps in AsciiDoc, which spells both halves differently: a `.` opens an ordered + item however deep it is, and a paragraph is attached to the item above it with a `+` on a line + of its own rather than by being indented under it. + */ + asciidocTemplate: `. First step ++ +What to do, and anything else that belongs with it. + +. Second step ++ +What to do next. + +. Done` } connectedCallback() { diff --git a/blocks/block-tabs/component.js b/blocks/block-tabs/component.js index 688dcf422..9acb50376 100644 --- a/blocks/block-tabs/component.js +++ b/blocks/block-tabs/component.js @@ -46,7 +46,21 @@ Content of the first tab. ::block-tab{label="Second tab"} Content of the second tab. -::` +::`, + /* + The same starter body in AsciiDoc, which cannot be derived from the one above: AsciiDoc nests a + block inside another by ALTERNATING the delimiter rather than by growing a fence, so a tab is a + `--` open block inside the tabset's `====`. + */ + asciidocTemplate: `[block-tab, label="First tab"] +-- +Content of the first tab. +-- + +[block-tab, label="Second tab"] +-- +Content of the second tab. +--` } static get styles() { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c367bdade..89976e616 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "wiki-ux", "version": "3.0.0", "dependencies": { + "@asciidoctor/core": "4.0.11", "@simplewebauthn/browser": "13.3.0", "@tailwindcss/vite": "4.3.3", "@twemoji/api": "17.0.2", @@ -89,6 +90,15 @@ "node": ">= 26.0" } }, + "node_modules/@asciidoctor/core": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-4.0.11.tgz", + "integrity": "sha512-5uWPTLTiPJVQp9q4MllSROM2avdECzoT7oeVw/onT9H2PIdCCtQwKrSTvztwxMbSu/8cqHqZox/2qTBrQN454g==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 5f5047c09..8c1e38d15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "visual:check": "node scripts/check-visual-roundtrip.mjs" }, "dependencies": { + "@asciidoctor/core": "4.0.11", "@simplewebauthn/browser": "13.3.0", "@tailwindcss/vite": "4.3.3", "@twemoji/api": "17.0.2", diff --git a/frontend/src/components/BlockPickerOverlay.vue b/frontend/src/components/BlockPickerOverlay.vue index 826472729..3cd3fe34e 100644 --- a/frontend/src/components/BlockPickerOverlay.vue +++ b/frontend/src/components/BlockPickerOverlay.vue @@ -92,10 +92,10 @@ class="px-4 pt-4" :fields="state.selected.props" :values="state.values" /> - +
{{ t('editor.blockPicker.markdown') }}
-
{{ markdown }}
+
{{ source }}
@@ -110,10 +110,11 @@ import { computed, onMounted, reactive } from 'vue' import { useI18n } from 'vue-i18n' import { notify } from '@/composables/notify' -import { blockMarkdown, blockPropsFilled } from '@/helpers/blocks' +import { blockAsciidoc, blockMarkdown, blockPropsFilled } from '@/helpers/blocks' import BlockPropsForm from '@/components/BlockPropsForm.vue' +import { useEditorStore } from '@/stores/editor' import { useSiteStore } from '@/stores/site' /** @@ -133,6 +134,7 @@ const TOOLBAR_BLOCKS = ['tabs'] // STORES +const editorStore = useEditorStore() const siteStore = useSiteStore() // I18N @@ -167,7 +169,22 @@ const blocks = computed(() => ) ) -const markdown = computed(() => (state.selected ? blockMarkdown(state.selected, state.values) : '')) +/** + * The block as source, in the syntax of the editor that asked for it. + * + * The picker is one screen serving every editor, and a block is one element either way — the same + * definition, the same props, the same form above this line. All that differs is the punctuation + * around them, which is why the two writers sit side by side in `helpers/blocks.js` rather than one + * of them living here. + */ +const source = computed(() => { + if (!state.selected) { + return '' + } + return editorStore.editor === 'asciidoc' + ? blockAsciidoc(state.selected, state.values) + : blockMarkdown(state.selected, state.values) +}) // -> A required prop with nothing in it would insert a block that cannot draw anything const canInsert = computed( @@ -183,7 +200,7 @@ function select(block) { } function insert() { - EVENT_BUS.emit('insertBlock', markdown.value) + EVENT_BUS.emit('insertBlock', source.value) close() } diff --git a/frontend/src/components/EditorAsciidoc.vue b/frontend/src/components/EditorAsciidoc.vue new file mode 100644 index 000000000..fdff7ac3d --- /dev/null +++ b/frontend/src/components/EditorAsciidoc.vue @@ -0,0 +1,1808 @@ + + + + + diff --git a/frontend/src/components/PageHistoryOverlay.vue b/frontend/src/components/PageHistoryOverlay.vue index 2e77c9d45..56dd0746e 100644 --- a/frontend/src/components/PageHistoryOverlay.vue +++ b/frontend/src/components/PageHistoryOverlay.vue @@ -505,6 +505,7 @@ async function renderOf(version) { // the page view rather than against the site root return renderVersionSource(version, { markdownConfig: editorStore.editors.markdown, + asciidocConfig: editorStore.editors.asciidoc, pagePath: pageStore.path }) } diff --git a/frontend/src/components/WelcomeOverlay.vue b/frontend/src/components/WelcomeOverlay.vue index c2daa1ece..2481ab83e 100644 --- a/frontend/src/components/WelcomeOverlay.vue +++ b/frontend/src/components/WelcomeOverlay.vue @@ -25,7 +25,7 @@ + v-if="siteStore.editors.asciidoc"> {{ t(`welcome.createHomeAsciidoc`) }} diff --git a/frontend/src/css/_page-contents-asciidoc.scss b/frontend/src/css/_page-contents-asciidoc.scss new file mode 100644 index 000000000..5b7a759ae --- /dev/null +++ b/frontend/src/css/_page-contents-asciidoc.scss @@ -0,0 +1,498 @@ +/* + THE RENDERED PAGE, WHERE ASCIIDOC WROTE IT + ========================================== + + A companion to `_page-contents.scss`, not a replacement: every selector here is nested under + `.page-contents.is-asciidoc`, so a page carries the whole typographic system and then this on top. + The flag is set at the three surfaces that draw a render and know which editor wrote it -- the + article column (`pages/Index.vue`), a history version (`pages/PageVersion.vue`) and the editor's + preview pane. Not on the copy the server puts in the document for a crawler: that one is drawn as + plain prose by `frontend/index.html`'s `