feat: asciidoc editor

pull/8104/head
NGPixel 2 weeks ago
parent f2ca01a08d
commit a9940477df
No known key found for this signature in database

@ -47,10 +47,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
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',

@ -311,6 +311,18 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
}
},
redirect: {
type: 'object',
properties: {
isActive: {
type: 'boolean'
},
config: {
type: 'object',
additionalProperties: true
}
}
},
visual: {
type: 'object',
properties: {

@ -178,7 +178,7 @@ tsDictMappings:
yi: yiddish
editors:
asciidoc:
contentType: html
contentType: adoc
config: {}
markdown:
contentType: markdown

@ -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<string, any>
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
}))

@ -18,7 +18,7 @@ import type { StoragePageContent, StoragePageRef } from './storage.ts'
const EDITOR_CONTENT_TYPES: Record<string, string> = {
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<string, string> = {
markdown: 'md',
html: 'html',
asciidoc: 'adoc',
adoc: 'adoc',
redirect: 'json',
blog: 'json'
}

@ -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 `<input>` submits nothing outside a `<form>`, 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<string, string[]> = {
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 `<colgroup>` 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<void> {
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,

@ -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

@ -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() {

@ -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() {

@ -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,

@ -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",

@ -92,10 +92,10 @@
class="px-4 pt-4"
:fields="state.selected.props"
:values="state.values" />
<!-- -> The markup itself, since that is what lands in the page -->
<!-- -> The markup itself, since that is what lands in the page -- in whichever syntax is open -->
<div class="w-section-header mt-6">{{ t('editor.blockPicker.markdown') }}</div>
<!-- The same 16px all round, so it sits inside the panel the way the fields do -->
<pre class="block-picker-output m-4">{{ markdown }}</pre>
<pre class="block-picker-output m-4">{{ source }}</pre>
</template>
</div>
</w-scroll-area>
@ -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()
}

File diff suppressed because it is too large Load Diff

@ -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
})
}

@ -25,7 +25,7 @@
<w-item
clickable
@click="createHomePage(`asciidoc`)"
v-if="flagsStore.experimental && siteStore.editors.asciidoc">
v-if="siteStore.editors.asciidoc">
<blueprint-icon icon="asciidoc" />
<w-item-section class="pr-2">{{ t(`welcome.createHomeAsciidoc`) }}</w-item-section>
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>

@ -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 `<noscript>` block and never carries `page-contents` at all.
It exists because Asciidoctor's HTML is shaped differently from markdown-it's, and that difference
divides cleanly in two:
- **Wrappers.** Asciidoctor puts every block inside a classed `<div>` -- `.paragraph > p`,
`.sect1 > .sectionbody`. The elements inside are the same elements markdown produces, so all the
typography already applies; what the wrappers break is the LAYOUT rules that reach between
siblings or at the edges of the article. The first section below makes them disappear from the box
tree so those rules land where they did. (The LIST wrappers are gone before they get here -- the
renderer drops them, because a rule reaching a list as a direct child needs more than a box
removed. See `convert_ulist`.)
- **Constructs markdown has not.** Callouts, example and sidebar blocks, verse, quote attributions,
block titles on anything, alphabetic and roman list numbering, table frame and grid variants.
None of these has a markdown counterpart to borrow a look from, so each gets one here.
What is NOT here is anything Asciidoctor and markdown-it both produce and this wiki has already
designed: admonitions, task lists, code blocks, footnotes, icons, images and external links are all
rewritten by `renderers/asciidoc.js` into the very markup the markdown pipeline emits, because those
are this wiki's own components rather than a markup language's -- one set of hues, one icon set, one
copy button, one set of print and dark-mode rules. See `WikiConverter`, which says so of each.
Colours come from the custom properties `_page-contents.scss` declares on the root block, so dark
mode and a re-themed site follow without a second palette.
*/
@use 'palette' as *;
.page-contents.is-asciidoc {
/* -- Wrappers ---------------------------------------------------------------- */
/*
Out of the box tree entirely.
`display: contents` rather than zeroed margins, because the problem is not that these divs are
visible -- they carry nothing at all -- but that they STAND BETWEEN the rules in
`_page-contents.scss` and the elements those rules were written for. A heading followed by a
paragraph is two wrapped divs, so a sibling rule matches neither; a table inside a wrapper cannot
scroll itself sideways; the article's first and last block give up margins the wrapper never had.
Removing the box is what puts every one of those back at once.
`.sect1` and friends are in the list for the same reason a section wrapper exists at all -- to
hold a heading and its body -- which is precisely the grouping the content rules do not want.
The LIST wrappers are not here, because taking their boxes away is not enough for them: several
rules reach a list as a direct child (`block-steps > ol`, `li > ul`, `dd > ol`) and a wrapper
still in the tree matches none of them, whatever its `display` says. `convert_ulist` and
`convert_olist` drop those wrappers outright -- see the note there.
*/
.paragraph,
.dlist,
.colist,
.sect1,
.sect2,
.sect3,
.sect4,
.sect5,
.sectionbody,
.openblock,
.openblock > .content,
.literalblock > .content,
.imageblock > .content,
.videoblock > .content,
.audioblock > .content {
display: contents;
}
/*
The article's own edges, restored through the wrappers.
`_page-contents.scss` zeroes the top margin of the first block and the bottom of the last, which
it can write as `> :first-child` because markdown puts the block there itself. Here the first
child is a wrapper, so the same rule has to reach one and two levels further in -- a first
paragraph is `.paragraph > p`, and a first heading is `.sect1 > h2`.
Written against the wrappers above rather than as a bare `> :first-child > :first-child`, so that
a block with a box of its own -- an example block, a sidebar -- keeps the margin it draws with.
*/
> :first-child,
> :first-child > :first-child,
> :first-child > :first-child > :first-child {
margin-top: 0;
}
> :last-child,
> :last-child > :last-child,
> :last-child > :last-child > :last-child {
margin-bottom: 0;
}
/* -- Block titles ------------------------------------------------------------ */
/*
A caption above whatever it names.
AsciiDoc lets any block carry a title (`.Figure one` on the line above it), which markdown can
only do for a fenced code block -- so this is the general case of the bar `codeblock-titled`
draws, and it is deliberately quieter: it sits over a table, an image, a list or an example block,
where a filled bar would read as part of the thing rather than as a label on it.
Not inside a listing block: `convert_listing` draws its own titled box and never produces one of
these. Not in an admonition either, where the title IS the admonition's heading line.
*/
.title {
margin: 0 0 0.4em;
color: var(--content-ink-muted);
font-size: 0.9em;
font-weight: 600;
}
/* -> The image caption goes UNDER the picture, which is where AsciiDoc puts it in the markup too */
.imageblock > .title {
margin: 0.5em 0 0;
text-align: center;
}
/*
A table's title, which Asciidoctor puts INSIDE the table as a `<caption>` rather than above it as
a div. A margin does nothing on `display: table-caption`, so the separation has to be padding --
and it takes the cells' own horizontal padding, so the caption starts where the first column does
rather than hard against the table's border.
*/
caption.title {
padding: 0.5em 0.8em;
text-align: left;
}
/* -- Boxed blocks ------------------------------------------------------------ */
/*
Example and sidebar blocks: an aside with a panel of its own.
Neither has a markdown counterpart. An example block is "here is one of those", a sidebar is
"related, but step out of the flow for it" -- so both are drawn as a tinted panel, and the sidebar
is set apart from the example by a rule down its edge rather than by a second colour, which would
only invite the reader to guess at a meaning.
*/
.exampleblock,
.sidebarblock {
margin: 1.5em 0;
padding: var(--code-pad-y) var(--code-pad-x);
border: 1px solid var(--content-rule);
border-radius: 6px;
background-color: var(--content-surface-alt);
}
.sidebarblock {
border-left: 4px solid var(--content-rule-strong);
}
/* -> The panel's padding is the margin of what it holds, as it is at the top level */
.exampleblock > .content > :first-child,
.sidebarblock > .content > :first-child,
.exampleblock > .content > :first-child > :first-child,
.sidebarblock > .content > :first-child > :first-child {
margin-top: 0;
}
.exampleblock > .content > :last-child,
.sidebarblock > .content > :last-child,
.exampleblock > .content > :last-child > :last-child,
.sidebarblock > .content > :last-child > :last-child {
margin-bottom: 0;
}
/*
A literal block -- text kept exactly as typed, with no language and nothing to highlight.
Deliberately NOT drawn as a code block: `pre.codeblock` carries a gutter, a copy button and the
highlight.js theme, all of which say "this is source". A literal block is a terminal transcript
or a fragment of a file, so it gets the same tinted panel and nothing else.
*/
.literalblock pre {
margin: 1.5em 0;
padding: var(--code-pad-y) var(--code-pad-x);
border-radius: 6px;
background-color: var(--content-surface-code);
color: var(--content-ink);
overflow-x: auto;
}
/* -- Quotes and verse -------------------------------------------------------- */
/*
A quote with an attribution, which markdown cannot express at all -- `[quote, Ada Lovelace, 1843]`
names who said it and where. The quote itself is a `blockquote` and is already styled; what is
added here is the line under it.
*/
/*
-> No em dash added here: `convert_quote` already writes one in front of the name, which is the
typographic convention for an attribution and which a rule of our own would only double.
*/
.quoteblock .attribution {
margin-top: 0.6em;
color: var(--content-ink-muted);
font-size: 0.9em;
font-style: normal;
cite {
font-style: italic;
}
}
/*
Verse: a quote whose line breaks are the point -- a poem, an address, a fragment of a licence.
Asciidoctor emits it as a `pre`, so the only thing it needs is to stop looking like code.
*/
.verseblock pre {
margin: 1.5em 0;
padding: 0 0 0 var(--code-pad-x);
/* -> `border` before `border-left`, so the box `_page-contents.scss` gives every `pre` goes */
border: 0;
border-left: 3px solid var(--content-rule);
background: none;
color: var(--content-ink-muted);
font-family: inherit;
font-size: 1em;
white-space: pre-wrap;
}
/* -- Callouts ---------------------------------------------------------------- */
/*
Callouts: numbered markers in a code sample, and the list that explains them. AsciiDoc's own, with
no markdown equivalent to borrow from.
The marker inside the code is left as the author typed it -- `<1>` reaches the page as text,
because the code is highlighted by highlight.js and a marker turned into markup first would be
highlighted as part of the language. So this styles the LIST, which is what carries the meaning,
and draws its numbers as the discs a marker would have been.
*/
.colist {
margin: -0.8em 0 1.5em;
font-size: 0.9375em;
ol {
padding-left: 0;
list-style: none;
counter-reset: wiki-callout;
}
li {
position: relative;
padding-left: 2em;
counter-increment: wiki-callout;
&::before {
content: counter(wiki-callout);
position: absolute;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5em;
height: 1.5em;
border-radius: 50%;
background-color: var(--content-ink-faint);
color: #fff;
font-size: 0.8em;
font-weight: 600;
}
}
}
/* -> The same disc, for a marker that did reach the page as markup rather than as text */
b.conum {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5em;
height: 1.5em;
border-radius: 50%;
background-color: var(--content-ink-faint);
color: #fff;
font-size: 0.8em;
font-weight: 600;
}
/* -- Lists ------------------------------------------------------------------- */
/*
The numbering styles AsciiDoc can ask for by name (`[loweralpha]`), which markdown has no way to
request -- it only ever counts in arabic numerals, and the nesting defaults in
`_page-contents.scss` are what vary it by depth. An explicit style overrides those.
On the list itself rather than on a wrapper, because `convert_olist` leaves it none.
These only ever reach a list whose author NAMED a style. Asciidoctor gives every ordered list one
whether it was written or not, and the renderer drops the ones it worked out for itself -- so
nothing here can put back a marker something else has taken off, which is what
`block-steps > ol { list-style: none }` found out the hard way: `ol.arabic` outranks it, and every
step drew its number twice, once on its disc and once as a marker beside it.
*/
ol.arabic {
list-style-type: decimal;
}
ol.loweralpha {
list-style-type: lower-alpha;
}
ol.upperalpha {
list-style-type: upper-alpha;
}
ol.lowerroman {
list-style-type: lower-roman;
}
ol.upperroman {
list-style-type: upper-roman;
}
/*
A description list laid out across rather than down: `[horizontal]` puts each term beside its
definition instead of above it, which Asciidoctor renders as a two-column table.
The table styling has to be undone rather than inherited -- this is a layout, not data, so the
panel, the grid and the dark header bar a real table gets would all be wrong.
*/
.hdlist > table,
.hdlist table {
display: table;
width: 100%;
margin: 0 0 1.15em;
border: 0;
border-radius: 0;
box-shadow: none;
font-size: 1em;
> tbody > tr > td {
padding: 0 1em 0.35em 0;
border: 0;
vertical-align: top;
}
.hdlist1 {
font-weight: 600;
}
}
/*
A question-and-answer list, which AsciiDoc spells `[qanda]` and renders as an ordered list whose
terms are the questions. The question carries the weight; the answer is ordinary prose under it.
*/
.qanda > ol > li > p:first-child {
font-weight: 600;
}
/* -- Tables ------------------------------------------------------------------ */
/*
Asciidoctor says far more about a table than markdown can, and most of it is a request this wiki
should honour rather than a look to keep: a width, an alignment per cell, whether the grid and the
frame are drawn at all.
The panel, the radius, the dark header bar and the cell rules all come from `_page-contents.scss`
and are left alone -- a table is a table on this wiki whichever syntax built it.
*/
/* -> `stretch` is the default and means the full column width; `fit-content` is the other */
table.stretch {
width: 100%;
}
/* -> Per-cell alignment, which AsciiDoc spells in the column spec (`[cols="<,^,>"]`) */
.tableblock.halign-left {
text-align: left;
}
.tableblock.halign-center {
text-align: center;
}
.tableblock.halign-right {
text-align: right;
}
.tableblock.valign-top {
vertical-align: top;
}
.tableblock.valign-middle {
vertical-align: middle;
}
.tableblock.valign-bottom {
vertical-align: bottom;
}
/*
A cell's own paragraphs. Asciidoctor wraps every cell's text in `p.tableblock`, so without this
each one carries the paragraph margin and a single-line row stands taller than it should -- the
same adjustment `_page-contents.scss` makes for a multi-line markdown cell.
*/
p.tableblock {
margin: 0;
+ p.tableblock {
margin-top: 0.6em;
}
}
/* -> `[grid=none]` and `[frame=none]`, which are a request to draw the data without the ruling */
table.grid-none > * > tr > * {
border-right: 0;
border-bottom: 0;
}
table.frame-none {
border: 0;
box-shadow: none;
}
/* -- Inline ------------------------------------------------------------------ */
/*
The roles AsciiDoc ships and a page can ask for by name. `line-through` is how AsciiDoc spells
what markdown writes as `~~struck~~`; the rest have no markdown spelling at all.
*/
.underline {
text-decoration: underline;
}
.line-through {
text-decoration: line-through;
}
.nowrap {
white-space: nowrap;
}
.big {
font-size: 1.15em;
}
.small {
font-size: 0.875em;
}
/*
A key sequence and a menu path -- `kbd:[Ctrl+S]`, `menu:File[Save As]`. The keys themselves are
already drawn by the `kbd` rule in `_page-contents.scss`; these are the joiners between them.
*/
.keyseq,
.menuseq {
white-space: nowrap;
}
.menuseq .caret {
margin: 0 0.25em;
color: var(--content-ink-faint);
}
/* -> `btn:[OK]`, drawn as the control it names */
.button {
display: inline-block;
padding: 0.1em 0.5em;
border: 1px solid var(--content-rule-strong);
border-radius: 4px;
background-color: var(--content-surface-alt);
font-size: 0.9em;
}
/* -- Footnotes --------------------------------------------------------------- */
/*
Asciidoctor collects footnotes into a `#footnotes` block of its own at the end of the document,
where markdown-it emits the `.footnotes` list `_page-contents.scss` styles. The two are the same
thing under different names, so this is that styling again against these names rather than a
second design -- including the landing highlight, which is a behaviour (`helpers/anchors.js`) and
not a look.
*/
#footnotes {
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid var(--content-rule);
color: var(--content-ink-muted);
font-size: 0.9em;
/* -> The renderer's own rule above the block, which this replaces with the border */
hr {
display: none;
}
}
.footnote {
margin-top: 0.5em;
&:target {
background-color: var(--content-surface-alt);
}
}
/* -- Unstyled leftovers ------------------------------------------------------ */
/*
Asciidoctor's own icon fallback, which nothing should reach: `convert_inline_image` draws an
`icon:` macro as an `<iconify-icon>`. One of these is an icon the converter did not claim, and it
is drawn plainly rather than left looking like a broken image.
*/
.icon {
color: var(--content-ink-muted);
}
}

@ -2,5 +2,7 @@
@use 'animation';
@use 'page-chrome';
@use 'page-contents';
/* -> After it, and reads as a layer on top: every selector in it is `.page-contents.is-asciidoc` */
@use 'page-contents-asciidoc';
/* -> Last, so that where a print rule ties with a screen rule on specificity, print wins */
@use 'print';

@ -1,6 +1,7 @@
import { TextSelection } from 'prosemirror-state'
import { fileSrc, twemojiHtml } from '@/renderers/markdown'
import { twemojiHtml } from '@/renderers/markdown'
import { fileSrc } from '@/renderers/shared'
import { schema } from './schema'

@ -0,0 +1,360 @@
import { asciidocQuoteValue, blockAttributes } from '@/helpers/blocks'
/**
* The blocks a page written in AsciiDoc already carries, read back and rewritten.
*
* The twin of `markdownBlocks.js`, answering the same four questions for the other syntax so that the
* AsciiDoc editor can offer the same two code lenses -- "Edit Block Parameters" over any block that
* declares props, and "Edit Content" over one whose body is a single source block. Everything about
* WHAT those lenses do lives in the editor; this is only how a block is spelled.
*
* Where markdown writes
*
* ::block-tabs{a="1"}
* …
* ::
*
* AsciiDoc writes an attribute line above a delimited block:
*
* [block-tabs, a="1"]
* ====
* …
* ====
*
* Two consequences run through this whole file. A block's opening line is the ATTRIBUTE LINE, one
* above the delimiter, so a line number here means the attribute line and the body starts two lines
* down. And nesting works by ALTERNATING the delimiter rather than by growing it -- a tabset is
* `====` with each tab a `--` inside it -- so a closing delimiter has to be matched against the one
* that opened its own block rather than by length.
*/
/**
* A line that is nothing but a delimiter of one of the four blocks that can hold other blocks.
*
* Four of them, which is the ceiling on how deeply the wiki's blocks can nest: a fifth level would
* have no delimiter left to alternate to. Example, sidebar and quote take four characters or more;
* an open block is exactly two hyphens, which is why it is not written as a run.
*/
const DELIMITER = /^(={4,}|\*{4,}|_{4,}|--)[ \t]*$/
/**
* A verbatim delimiter -- a listing, a literal, a passthrough or a comment block.
*
* Nothing inside one is markup, so a `[block-x]` written in a code sample is a code sample. Matched
* by its own repeated character, since AsciiDoc allows a run of four or more.
*/
const VERBATIM_DELIMITER = /^(-{4,}|\.{4,}|\+{4,}|\/{4,})[ \t]*$/
/**
* The attribute line that names one of the wiki's blocks: `[block-tabs, label="One"]`.
*
* Anchored to the start of the line, as AsciiDoc anchors a block attribute list. The style is the
* first positional attribute, and everything after the first comma is the block's own props.
*/
const OPENING = /^\[(block-[a-z\d-]+)[ \t]*(?:,[ \t]*(.*))?\][ \t]*$/
/**
* A source block inside a wiki block's body: `[source,mermaid]` over a `----` fence.
*
* The language is the second positional attribute. `[source]` with no language is matched too, since
* a block's body editor is chosen by the block's definition rather than by what the fence says.
*/
const SOURCE_OPENING = /^\[source[ \t]*(?:,[ \t]*([^\s,\]]*))?[^\]]*\][ \t]*$/
/** The fence a source block is written between. */
const SOURCE_FENCE = /^(-{4,})[ \t]*$/
/**
* One entry in an AsciiDoc attribute list: `name="value"`, `name='value'`, `name=value`, or a bare
* positional value. `.role` and `#id` shorthands are matched too, since AsciiDoc accepts both.
*
* Ordered so a quoted value wins over the unquoted reading, which would stop at the comma.
*/
const ATTRIBUTE =
/([.#][^\s,"'=]+)|([\w-]+)[ \t]*=[ \t]*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^,]*))|([^,\s][^,]*)/g
/** `\"` inside a quoted AsciiDoc value is a literal quote. */
function unescapeValue(value) {
return value.replace(/\\(["'])/g, '$1')
}
/**
* Split an attribute list into what it says.
*
* `name` is null for a `.role`, a `#id` or a bare positional value, none of which belongs to a prop.
* `raw` is what was written, kept so that anything the block does not declare survives a rewrite
* untouched -- see `blockOpeningLine`.
*
* @param {string} source The inside of the brackets, after the style.
* @returns {Array<{ name: string|null, value: string|null, raw: string }>}
*/
function parseAttributes(source) {
return [...source.matchAll(ATTRIBUTE)]
.filter((match) => match[0].trim())
.map((match) => ({
name: match[2] ?? null,
value:
match[3] !== undefined
? unescapeValue(match[3])
: match[4] !== undefined
? unescapeValue(match[4])
: (match[5]?.trim() ?? null),
raw: match[0].trim()
}))
}
/** Whether `line` closes a block opened with `delimiter`. */
function closes(line, delimiter) {
const match = DELIMITER.exec(line)
return Boolean(match) && match[1] === delimiter
}
/**
* Every block in the source, in the order they appear. Line numbers are 1-based, to be handed
* straight to the editor.
*
* `line` is the ATTRIBUTE line -- the one a lens is drawn over and the one `blockOpeningLine`
* rewrites -- and `delimiter` is what the block was opened with, which is what its own closing line
* has to match. `endLine` is that closing line, or the end of the source for a block still being
* written.
*
* A block written inside a verbatim block is a code sample and not a block, so those are skipped.
* Nesting needs no tracking of its own: an attribute line stands on its own whatever it is written
* inside, and the first line matching a block's own delimiter is always its close -- AsciiDoc does
* not allow a delimited block to nest inside another of the SAME kind, which is why they alternate.
*
* @param {string} text The page source.
* @returns {Array<{ block: string, line: number, endLine: number, delimiter: string,
* attributes: Array }>}
*/
export function findBlocks(text) {
const lines = text.split('\n')
const blocks = []
let verbatim = null
for (let index = 0; index < lines.length; index++) {
const line = lines[index]
if (verbatim) {
if (line.trimEnd() === verbatim) {
verbatim = null
}
continue
}
const fence = VERBATIM_DELIMITER.exec(line)
if (fence) {
verbatim = fence[1]
continue
}
const opening = OPENING.exec(line)
/*
The delimiter is read off the NEXT line rather than assumed, because it is what closes the
block and the four are interchangeable. An attribute line with no delimiter under it is a
styled paragraph, not a block with a body, and is not offered a lens.
*/
const delimiter = opening ? DELIMITER.exec(lines[index + 1] ?? '') : null
if (opening && delimiter) {
blocks.push({
block: opening[1].slice('block-'.length),
line: index + 1,
endLine: closingLineOf(lines, index + 1, delimiter[1]),
delimiter: delimiter[1],
attributes: parseAttributes(opening[2] ?? '')
})
}
}
return blocks
}
/**
* Where a block opened at `delimiterIndex` closes, as a 1-based line.
*
* The first line matching its own delimiter, for the reason `findBlocks` gives. An unterminated block
* is reported as running to the end of the source, which is what it looks like while it is being
* typed and is the answer the editor's preview wants.
*/
function closingLineOf(lines, delimiterIndex, delimiter) {
for (let index = delimiterIndex + 1; index < lines.length; index++) {
if (closes(lines[index], delimiter)) {
return index + 1
}
}
return lines.length
}
/**
* Every tabset in the source, in order, as the line range of each of its panels.
*
* The same shape -- and the same purpose -- as `tabsMap` in the markdown renderer: the editor's
* preview keeps which panel is open in each block's own state, and the preview is rebuilt from
* scratch on every keystroke, so without this, writing inside the second panel of a tabset would
* throw the author back to the first.
*
* Built here rather than in the renderer because this side already reads the source for the code
* lenses, and because the AsciiDoc renderer is async: a map produced as a side effect of rendering
* would be a render behind whenever the caret moved between one keystroke and the next.
*
* A panel belongs to the innermost tabset containing it, which nesting by containment gives for
* free: the panels are the `tab` blocks that fall inside a `tabs` block and inside no nearer one.
*
* @param {string} text The page source.
* @returns {Array<Array<[number, number]>>} Per tabset, the 1-based `[first, last]` line of each
* panel, both inclusive.
*/
export function findTabsets(text) {
const blocks = findBlocks(text)
const tabsets = blocks.filter((block) => block.block === 'tabs')
const tabs = blocks.filter((block) => block.block === 'tab')
return tabsets.map((tabset) =>
tabs
.filter((tab) => {
if (tab.line < tabset.line || tab.endLine > tabset.endLine) {
return false
}
// -> Not this tabset's, if a nearer one also contains it
return !tabsets.some(
(nearer) =>
nearer !== tabset &&
nearer.line >= tabset.line &&
nearer.endLine <= tabset.endLine &&
tab.line >= nearer.line &&
tab.endLine <= nearer.endLine
)
})
.map((tab) => [tab.line, tab.endLine])
)
}
/**
* What the form should open on: the block's props, filled in from what the page gave them.
*
* A prop the source says nothing about starts at the block's own default, which is what the block
* will do if left alone -- the same footing the picker starts a new block on.
*
* @param {{ attributes: Array }} found A block from `findBlocks`.
* @param {{ props?: Array }} definition The same block as the API describes it.
* @returns {Record<string, unknown>} Values by prop name.
*/
export function blockValues(found, definition) {
const written = new Map(
found.attributes.filter((attribute) => attribute.name).map((a) => [a.name, a.value])
)
return Object.fromEntries(
(definition.props ?? []).map((prop) => {
if (!written.has(prop.name)) {
return [prop.name, prop.default ?? '']
}
const value = written.get(prop.name)
switch (prop.type) {
/*
-> Read exactly as the markdown side reads one, and for the same reason: every prop reaches
the element as a string, so anything but the word false is true.
*/
case 'boolean':
return [prop.name, value === null ? true : value !== 'false']
case 'number': {
const number = Number(value)
return [prop.name, Number.isFinite(number) ? number : (prop.default ?? '')]
}
default:
return [prop.name, value ?? '']
}
})
)
}
/**
* The attribute line to write back, from what the form now holds.
*
* Anything in the original list that the block does not declare is carried over as written: a
* `.role`, or an attribute belonging to a version of the block that had a prop this one has not.
* None of them survives being saved -- the renderer allows a block exactly the attributes its
* definition declares -- but dropping them here would edit a line the author is still writing.
*
* @param {{ block: string, attributes: Array }} found A block from `findBlocks`.
* @param {{ props?: Array }} definition The same block as the API describes it.
* @param {Record<string, unknown>} values What the form holds, by prop name.
* @returns {string} The line, with no trailing newline.
*/
export function blockOpeningLine(found, definition, values) {
const declared = new Set((definition.props ?? []).map((prop) => prop.name))
const kept = found.attributes
.filter((attribute) => !attribute.name || !declared.has(attribute.name))
.map((attribute) => attribute.raw)
const attributes = [...blockAttributes(definition, values, asciidocQuoteValue), ...kept]
return `[block-${found.block}${attributes.length > 0 ? `, ${attributes.join(', ')}` : ''}]`
}
/**
* The source block in a block's body, for a block that declares a `contentEditor`.
*
* That kind of block holds one source block and nothing else -- a diagram, a drawing -- which is how
* every source body in this wiki is written: the fence is what keeps the markup off the text, so a
* line opening with `.` stays a line and not a block title.
*
* The whole thing is reported, its attribute line and both fences included, and `writeBlockContent`
* puts one back the same way. Editing only the lines BETWEEN the fences cannot express an empty body
* -- there are no lines there to replace.
*
* @param {string} text The page source.
* @param {{ line: number, delimiter: string }} found The block, from `findBlocks`.
* @returns {{ language: string, fence: string, source: string, startLine: number, endLine: number }|null}
* The body, or null for a block that does not hold exactly one source block.
*/
export function findBlockContent(text, found) {
const lines = text.split('\n')
let opening = null
// -> `found.line` is the attribute line and the delimiter is under it, so the body starts below both
for (let index = found.line + 1; index < lines.length; index++) {
const line = lines[index]
if (!opening) {
// -> The block ended before any source block began: its body is prose, which this cannot edit
if (closes(line, found.delimiter)) {
return null
}
const source = SOURCE_OPENING.exec(line)
const fence = source ? SOURCE_FENCE.exec(lines[index + 1] ?? '') : null
if (source && fence) {
opening = {
language: source[1] ?? '',
fence: fence[1],
// -> The attribute line, which is replaced along with the fences
attributeLine: index + 1,
startLine: index + 2
}
index++
}
continue
}
if (SOURCE_FENCE.test(line) && line.trimEnd() === opening.fence) {
return {
language: opening.language,
fence: opening.fence,
source: lines.slice(opening.startLine, index).join('\n'),
startLine: opening.attributeLine,
endLine: index + 1
}
}
}
// -> An unterminated fence is a block still being written; there is nothing whole to hand an editor
return null
}
/**
* The source block to write back, from what an editor now holds.
*
* The language is the one that was there, so nothing about the block moves except the text an editor
* was given. The fence is LENGTHENED where the new text contains a run of hyphens as long as it --
* otherwise a body carrying its own fence would close this one early and the rest of it would land in
* the page as AsciiDoc.
*
* @param {{ language: string, fence: string }} content The body, from `findBlockContent`.
* @param {string} source What the editor produced.
* @returns {string} The lines to put back, attribute line and fences included, no trailing newline.
*/
export function writeBlockContent(content, source) {
const longest = Math.max(0, ...[...source.matchAll(/-{4,}/g)].map((match) => match[0].length))
const fence = '-'.repeat(Math.max(content.fence.length, longest + 1))
return `[source${content.language ? `,${content.language}` : ''}]\n${fence}\n${source}\n${fence}`
}

@ -1,17 +1,32 @@
/**
* The MDC markup for a block, as the editor writes it into a page.
* The markup for a block, as an editor writes it into a page.
*
* Shared rather than living in the block picker, because the picker is not the only way a block gets
* inserted — the toolbar has a shortcut for the tabset, which has to produce exactly what picking
* Tabs from the list would have produced, and the "Edit Block Parameters" lens rewrites the opening
* line of a block already in the page.
*
* `::block-name{prop="value"}` is what the renderer turns into `<block-name prop="value">`, the
* element the component registers itself as.
* Both syntaxes end at the same element. `::block-name{prop="value"}` in markdown and
* `[block-name, prop="value"]` over a delimited block in AsciiDoc are each turned by their own
* renderer into `<block-name prop="value">`, which is what the component registers itself as — so
* which props are worth writing out is one question with one answer here, and only the punctuation
* around them differs.
*/
/**
* What an author filled in, as MDC attributes — one `name="value"` per prop worth writing out.
* A value as MDC has to write it: quoted, with any quote of its own turned into an apostrophe.
*
* Lossy, and unavoidably so — MDC has no escape for a double quote inside an attribute, so one left
* in would close the attribute and spill the rest of the value into the page as markup. AsciiDoc
* does have one, which is why this is an argument rather than the only way values are written; see
* `quoteValue` in `helpers/asciidocBlocks.js`.
*/
function mdcQuoteValue(value) {
return `"${String(value).replaceAll('"', "'")}"`
}
/**
* What an author filled in, as attributes — one `name="value"` per prop worth writing out.
*
* Separate from `blockMarkdown` because editing an existing block reuses only this half: its body is
* whatever the author has since written between the two fences, and rebuilding the whole block from
@ -19,9 +34,11 @@
*
* @param {{ props?: Array }} block A block as the API describes it.
* @param {Record<string, unknown>} [values] What the author filled in, by prop name.
* @param {(value: unknown) => string} [quote] How to write one value, for a syntax that quotes
* differently. Defaults to MDC's way; AsciiDoc passes its own.
* @returns {string[]} The attributes, in the order the block declares its props.
*/
export function blockAttributes(block, values = {}) {
export function blockAttributes(block, values = {}, quote = mdcQuoteValue) {
/*
Only what is worth writing out: anything given a value that is not already the block's own
default. A block reading its default from its own code does not need to be told it in every page.
@ -33,8 +50,7 @@ export function blockAttributes(block, values = {}) {
}
return String(value) !== String(prop.default ?? '')
})
// -> A double quote in a value would close the attribute; MDC has no escape for it, so it goes
return written.map((prop) => `${prop.name}="${String(values[prop.name]).replaceAll('"', "'")}"`)
return written.map((prop) => `${prop.name}=${quote(values[prop.name])}`)
}
/**
@ -60,6 +76,87 @@ export function blockMarkdown(block, values = {}) {
return `::block-${block.block}${suffix}\n::`
}
/**
* The delimiters a wiki block may be written between in AsciiDoc, outermost first.
*
* AsciiDoc nests delimited blocks by ALTERNATING the delimiter rather than by growing it the way MDC
* grows a fence, so which one a block gets depends on how deep it sits: a tabset takes `====` and
* each tab inside it takes `--`. Four of them, which is the ceiling -- a fifth level of blocks inside
* blocks has nothing left to alternate to, and nothing in this wiki goes near it.
*/
const ASCIIDOC_DELIMITERS = ['====', '--', '****', '____']
/**
* A block's starter body, carried across from the markdown it is declared in.
*
* A block declares one `template` (see `BlockDefinition`), written in markdown because that is the
* syntax the block system grew up in. Three shapes occur, and only two of them differ between the
* syntaxes:
*
* - **A fenced source** -- every block with a `contentEditor`, which is most of the ones that have a
* template at all. Rewritten to `[source,lang]` over a `----` fence.
* - **Plain prose** -- `block-spoiler`, `block-gallery`. The same text in both syntaxes; left alone.
* - **Nested blocks or a list** -- `block-tabs`, `block-steps`. Not mechanically translatable, and
* not guessed at: a block declares an `asciidocTemplate` for this case and it is used verbatim.
*
* A custom block that declares neither an `asciidocTemplate` nor a fenced body gets its markdown
* template as it stands, which is right for prose and visibly wrong for anything else -- the preview
* shows it immediately, which is the failure worth having over a translator quietly mangling a shape
* it was never shown.
*/
function asciidocTemplate(block, depth) {
if (block.asciidocTemplate) {
return block.asciidocTemplate
}
const template = block.template ?? ''
const fenced = /^```(\S*)\n([\s\S]*)\n```$/.exec(template.trim())
if (!fenced) {
return template
}
/*
The fence is four hyphens or more, and never the delimiter this block is being written between:
`--` opens an open block, so a two-hyphen fence inside one would close it. Lengthened past any
run of hyphens the body itself carries, exactly as `writeBlockContent` does.
*/
const longest = Math.max(0, ...[...fenced[2].matchAll(/-{4,}/g)].map((match) => match[0].length))
const fence = '-'.repeat(Math.max(4, longest + 1, ASCIIDOC_DELIMITERS[depth]?.length ?? 0))
return `[source${fenced[1] ? `,${fenced[1]}` : ''}]\n${fence}\n${fenced[2]}\n${fence}`
}
/**
* A block in AsciiDoc: its attribute line, its delimiters, and whatever body it starts with.
*
* The counterpart of `blockMarkdown`. A block with no template still gets a delimited body rather
* than an empty attribute line, because an attribute line with nothing under it is a styled paragraph
* as far as AsciiDoc is concerned and would not be a block at all.
*
* @param {{ block: string, props?: Array, template?: string, asciidocTemplate?: string }} block A
* block as the API describes it.
* @param {Record<string, unknown>} [values] What the author filled in, by prop name.
* @param {number} [depth] How deeply this block is being inserted, which decides its delimiter. Zero
* at the top level, which is every insertion the editor makes -- a nested block comes from a
* parent's own template and carries the delimiter written into it.
* @returns {string} The markup, attribute line and delimiters included.
*/
export function blockAsciidoc(block, values = {}, depth = 0) {
const attributes = blockAttributes(block, values, asciidocQuoteValue)
const head = `[block-${block.block}${attributes.length > 0 ? `, ${attributes.join(', ')}` : ''}]`
const delimiter = ASCIIDOC_DELIMITERS[depth] ?? ASCIIDOC_DELIMITERS.at(-1)
const body = asciidocTemplate(block, depth)
return `${head}\n${delimiter}\n${body}\n${delimiter}`
}
/**
* A value as AsciiDoc writes one: quoted, with a quote or a backslash inside it escaped.
*
* Unlike MDC, which has to mangle a double quote -- see `mdcQuoteValue`. Exported because the "Edit
* Block Parameters" lens rewrites an attribute line without going through `blockAsciidoc`, and the
* two must agree about quoting or an edit would change a value it was only meant to move.
*/
export function asciidocQuoteValue(value) {
return `"${String(value).replace(/(["\\])/g, '\\$1')}"`
}
/**
* Whether every prop the block insists on has been given something.
*

@ -0,0 +1,184 @@
import * as monaco from 'monaco-editor'
/**
* AsciiDoc, as a language Monaco knows how to colour.
*
* Monaco ships basic-language definitions for some sixty syntaxes and AsciiDoc is not among them, so
* without this the editor would open on plain undifferentiated text — which is the one thing a source
* editor is for. There is no AsciiDoc language service to speak of either; this is a Monarch
* tokenizer and a language configuration, and nothing more.
*
* Registered once for the page rather than per editor, the same footing Monaco's own languages are on:
* a language is global to the module, and registering a second time would stack another tokenizer
* behind the same id.
*
* The token names are Monaco's own vocabulary rather than anything AsciiDoc-specific, which is what
* lets the wiki's editor theme colour this the same way it colours markdown — `keyword` for the
* things that give a line its meaning, `string` for what a reader's eye should follow, `comment` for
* what is not content.
*/
/** The id the editor opens a model with. Monaco's conventional spelling for this syntax. */
export const ASCIIDOC_LANGUAGE_ID = 'asciidoc'
let registered = false
/**
* The verbatim delimiters, each of which suspends AsciiDoc entirely until its own closing line.
*
* Four or more of the character, which is AsciiDoc's rule, and the closing line has to be the same
* character — so each gets a state of its own rather than one shared "verbatim" state that any of
* them could close. Without that, a `----` listing holding a row of dots would end early.
*/
const VERBATIM = [
{ open: /^-{4,}\s*$/, close: /^-{4,}\s*$/, state: 'listing' },
{ open: /^\.{4,}\s*$/, close: /^\.{4,}\s*$/, state: 'literal' },
{ open: /^\+{4,}\s*$/, close: /^\+{4,}\s*$/, state: 'passthrough' },
{ open: /^\/{4,}\s*$/, close: /^\/{4,}\s*$/, state: 'comment' }
]
/** The tokenizer state each verbatim block puts the editor into, built from the table above. */
const verbatimStates = Object.fromEntries(
VERBATIM.map(({ close, state }) => [
state,
[
[close, { token: 'keyword', next: '@pop' }],
[/.*$/, state === 'comment' ? 'comment' : 'string']
]
])
)
export function registerAsciidocLanguage() {
if (registered) {
return
}
registered = true
monaco.languages.register({ id: ASCIIDOC_LANGUAGE_ID, extensions: ['.adoc', '.asciidoc'] })
monaco.languages.setLanguageConfiguration(ASCIIDOC_LANGUAGE_ID, {
comments: { lineComment: '//', blockComment: ['////', '////'] },
brackets: [
['[', ']'],
['{', '}']
],
autoClosingPairs: [
{ open: '[', close: ']' },
{ open: '{', close: '}' },
{ open: '"', close: '"' },
{ open: '`', close: '`' }
],
/*
The formatting marks are part of a word, so that toggling bold or italic with nothing selected
takes the marks off again instead of wrapping them a second time — the same reason the markdown
editor widens Monaco's default pattern. AsciiDoc's marks are `*`, `_`, `` ` ``, `#`, `^` and `~`.
*/
wordPattern:
/([*_`#^~]{1,2})?[\p{Alphabetic}\p{Number}\p{Nonspacing_Mark}]+(_+[\p{Alphabetic}\p{Number}\p{Nonspacing_Mark}]+)*\1/gu
})
monaco.languages.setMonarchTokensProvider(ASCIIDOC_LANGUAGE_ID, {
defaultToken: '',
tokenizer: {
root: [
// -> Verbatim blocks first: nothing inside one is AsciiDoc
...VERBATIM.map(({ open, state }) => [open, { token: 'keyword', next: `@${state}` }]),
// -> A line comment, and the `//` that opens one
[/^\/\/.*$/, 'comment'],
/*
A section heading. Six levels, though a page's own title is held in a column of its own, so
content here starts at `==` — see `LOCKED_ATTRIBUTES` in `renderers/asciidoc.js`.
*/
[/^={1,6}\s+.*$/, 'keyword'],
// -> A document attribute entry: `:name: value`, `:name!:` to unset
[/^:[\w!-]+:.*$/, 'variable'],
/*
A block attribute line -- `[source,yaml]`, `[block-tabs, label="One"]`, `[NOTE]`. What names
a block, gives it its props, or turns a paragraph into an admonition, so it is the line that
carries the most meaning per character on the page.
*/
[/^\[.*\]\s*$/, 'type'],
// -> A block title: a lone `.` opening a line, then the caption
[/^\.[^.\s].*$/, 'string.escape'],
// -> The delimiters that hold content rather than suspend it, plus a table's
[/^(={4,}|\*{4,}|_{4,}|--|\|={3,})\s*$/, 'keyword'],
// -> An admonition written as a paragraph prefix
[/^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\s/, 'type'],
// -> List markers, ordered and unordered, at any depth; and a description list's `::`
[/^\s*[*.]{1,5}\s+/, 'keyword'],
[/^\s*-\s+/, 'keyword'],
[/^\s*\[[ x*]\]\s+/, 'keyword'],
[/^.*?(?=::\s|::$)::/, 'keyword'],
// -> The `+` that attaches a paragraph to the list item above it
[/^\+\s*$/, 'keyword'],
// -> A table cell separator at the start of a line
[/^\|/, 'keyword'],
{ include: '@inline' }
],
/*
What can appear anywhere in a line. Ordered so that the longest and most distinctive marks are
tried first -- a macro before an attribute reference, constrained formatting before a bare
character that happens to be a formatting mark.
*/
inline: [
// -> An inline passthrough, which suspends everything inside it
[/\+{3}[^+]*\+{3}/, 'string'],
[/`\+[^+]*\+`/, 'string'],
// -> Monospace, which wins over the emphasis marks so that `*` inside code stays code
[/`[^`\n]+`/, 'string'],
/*
A macro: `image:photo.png[…]`, `icon:mdi:home[]`, `kbd:[Ctrl+S]`, `link:/a[b]`,
`footnote:[…]`. The target may hold a colon, which is what lets an Iconify reference go in
as itself, so it is matched up to the bracket rather than up to the next colon.
*/
[/\b[a-z][\w-]*:[^[\s]*\[[^\]]*\]/, 'type'],
// -> A cross-reference, and an inline anchor
[/<<[^>]*>>/, 'type'],
[/\[\[[^\]]*\]\]/, 'type'],
// -> An attribute reference
[/\{[\w-]+\}/, 'variable'],
// -> A bare URL, which AsciiDoc always turns into a link
[/\b(https?|ftp|mailto):\/?\/?[^\s[\]]+/, 'type'],
// -> A callout marker inside a line of code that is not in a verbatim block
[/<\d+>/, 'number'],
/*
The formatting marks. Doubled first (`**bold**` is unconstrained and may sit mid-word), then
single. A single mark is required to abut a non-space on the inside, which is AsciiDoc's own
rule and what keeps an ordinary asterisk or underscore in prose from opening a run that
never closes.
*/
[/\*\*[^\n]+?\*\*/, 'strong'],
[/\*[^\s*][^\n]*?\*/, 'strong'],
[/__[^\n]+?__/, 'emphasis'],
[/_[^\s_][^\n]*?_/, 'emphasis'],
[/##[^\n]+?##/, 'regexp'],
[/#[^\s#][^\n]*?#/, 'regexp'],
[/\^[^\s^][^\n]*?\^/, 'number'],
[/~[^\s~][^\n]*?~/, 'number'],
// -> An escaped mark, which is literal text and must not open anything
[/\\./, '']
],
...verbatimStates
}
})
}

@ -18,6 +18,7 @@ import { MarkdownRenderer } from '@/renderers/markdown'
/** What a version's source is saved as, by the format it was written in. */
const FILE_TYPES = {
markdown: { ext: 'md', mime: 'text/markdown' },
adoc: { ext: 'adoc', mime: 'text/asciidoc' },
html: { ext: 'html', mime: 'text/html' }
}
@ -86,21 +87,33 @@ export async function saveVersionSource(version) {
* directory reaches for a store and this is not the file to start in. The caller has it already, and
* has to make sure it is loaded (`editorStore.fetchConfigs()`) before asking.
*
* Asynchronous because one of the two pipelines is: Asciidoctor's `convert` returns a promise, and it
* is reached through a dynamic import so that a reader looking at the history of a markdown page never
* downloads it.
*
* @param {object} version A version WITH its `content`.
* @param {object} options
* @param {object} options.markdownConfig `editorStore.editors.markdown` — per-site renderer settings
* (line breaks, typographer, …).
* @param {object} [options.asciidocConfig] `editorStore.editors.asciidoc`, for a version written in
* that syntax.
* @param {string} options.pagePath The page this HTML is FOR, which is what a relative image in it
* resolves against. Not always the version's own `path`: content being restored onto a page that
* has since moved belongs to where that page is now.
* @returns {string} The HTML, or the source unchanged for a format this does not render.
* @returns {Promise<string>} The HTML, or the source unchanged for a format this does not render.
*/
export function renderVersionSource(version, { markdownConfig, pagePath }) {
export async function renderVersionSource(version, { markdownConfig, asciidocConfig, pagePath }) {
const content = version?.content ?? ''
if (versionContentType(version) !== 'markdown') {
return content
switch (versionContentType(version)) {
case 'markdown':
return new MarkdownRenderer(markdownConfig ?? {}).render(content, { pagePath })
case 'adoc': {
const { AsciidocRenderer } = await import('@/renderers/asciidoc')
return new AsciidocRenderer(asciidocConfig ?? {}).render(content, { pagePath })
}
default:
return content
}
return new MarkdownRenderer(markdownConfig ?? {}).render(content, { pagePath })
}
/**

@ -130,7 +130,7 @@ const state = reactive({
blog: false,
channel: false,
markdown: false,
redirect: true,
redirect: false,
visual: false
}
})
@ -144,8 +144,11 @@ const editors = reactive([
{
id: 'asciidoc',
icon: 'asciidoc',
isDisabled: true,
hasConfig: true,
/*
No configuration screen, and none to have: every switch the markdown editor offers is either
hardwired in AsciiDoc -- its text replacements and its autolinking are not optional -- or
spelled per block rather than per site. See `AsciidocRenderer`.
*/
useRendering: true
},
{
@ -172,7 +175,11 @@ const editors = reactive([
{
id: 'redirect',
icon: 'advance',
isDisabled: true,
/*
No rendering pipeline of its own: a redirection has a target instead of a body, so there is
nothing to render and nothing to configure about how it is written. The switch is only whether
the site offers `New Redirection` — see `activeEditors` in `stores/site.js`.
*/
useRendering: false
},
{
@ -202,6 +209,7 @@ async function load() {
state.config.asciidoc = data?.asciidoc?.isActive ?? false
state.config.blog = data?.blog?.isActive ?? false
state.config.markdown = data?.markdown?.isActive ?? false
state.config.redirect = data?.redirect?.isActive ?? false
state.config.visual = data?.visual?.isActive ?? false
} catch (err) {
notify({
@ -223,6 +231,7 @@ async function save() {
asciidoc: { isActive: state.config.asciidoc },
blog: { isActive: state.config.blog },
markdown: { isActive: state.config.markdown },
redirect: { isActive: state.config.redirect },
visual: { isActive: state.config.visual }
}
}
@ -238,6 +247,7 @@ async function save() {
asciidoc: state.config.asciidoc,
blog: state.config.blog,
markdown: state.config.markdown,
redirect: state.config.redirect,
visual: state.config.visual
}
})

@ -195,6 +195,7 @@
-->
<div
class="page-contents"
:class="{ 'is-asciidoc': pageStore.editor === `asciidoc` }"
ref="pageContents"
v-show="activeView === `article` && !isBlog"
v-html="pageStore.render"
@ -513,6 +514,16 @@ const editorComponents = {
loader: () => import('../components/EditorMarkdown.vue'),
loadingComponent: LoadingGeneric
}),
/*
Its chunk carries Asciidoctor, which is about as large as the whole markdown pipeline -- so it is
fetched the first time somebody opens this editor and never at all on an instance that has no
AsciiDoc pages. Nothing else in the app imports `renderers/asciidoc`, which is what keeps that
true; the headless renderer reaches it through a dynamic import for the same reason.
*/
asciidoc: defineAsyncComponent({
loader: () => import('../components/EditorAsciidoc.vue'),
loadingComponent: LoadingGeneric
}),
visual: defineAsyncComponent({
loader: () => import('../components/EditorVisual.vue'),
loadingComponent: LoadingGeneric

@ -75,8 +75,11 @@
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on.
-->
<!-- -> `is-asciidoc` from the version's own editor, not the page's: a version is what the
page WAS, and a page converted between editors has history on both sides of it -->
<div
class="page-contents"
:class="{ 'is-asciidoc': state.version?.meta?.editor === `asciidoc` }"
ref="pageContents"
v-html="state.render"
@click="onContentClick" />
@ -363,6 +366,7 @@ async function renderFor(version, pagePath) {
}
return renderVersionSource(version, {
markdownConfig: editorStore.editors.markdown,
asciidocConfig: editorStore.editors.asciidoc,
pagePath
})
}

@ -348,7 +348,11 @@ const localeFilterLabel = computed(() => {
* names it.
*
* The list was hardcoded, so it offered editors the site had turned off or that do not exist here at
* all -- and left out `redirect`, which every site has. `activeEditors` is the one place that knows.
* all, and left out `redirect` entirely. `activeEditors` is the one place that knows.
*
* It follows that an editor a site has since turned off drops out of the filter while the pages
* written with it are still there. That is the right trade for a filter: it offers what the site
* writes now, and a page is found by what it says rather than by which editor made it.
*/
const editors = computed(() => [
{ label: t('search.editorAny'), value: '' },

@ -0,0 +1,587 @@
/**
* The AsciiDoc pipeline, beside the markdown one in `markdown.js`.
*
* Asciidoctor.js does the parsing; what is here is what makes its output a page of THIS wiki rather
* than a standalone AsciiDoc document:
*
* - **A converter** (`WikiConverter`), which overrides the handful of `convert_*` methods whose
* default output something else in the app has to read -- a stylesheet, a helper that hangs a
* button on it, the server's sanitizer, the editor's scroll sync. Each one says why it is there.
* - **An include processor**, which takes `include::` away completely. See `buildRegistry`.
* - **One substitution over the source**, which neuters `ifeval::`. See `disableIfeval`.
*
* Everything NOT in that list is left exactly as Asciidoctor draws it -- the wrappers (`.paragraph`,
* `.ulist`, `.listingblock`), the tables, the callouts, the block titles -- and is styled by
* `css/_page-contents-asciidoc.scss` under `.page-contents.is-asciidoc`. Reshaping it into what
* markdown-it happens to emit would mean overriding nearly every method to gain nothing, and would
* throw away the constructs AsciiDoc has and markdown has not.
*
* Asciidoctor 4 is a native ESM package with no default export, and its `convert` is ASYNC -- so
* `render` here is too, unlike `MarkdownRenderer`'s. Every caller awaits it.
*/
import { Extensions, Html5Converter, convert as asciidoctorConvert } from '@asciidoctor/core'
import { codeBlock, fileSrc, isExternalHref } from './shared'
import { escape } from 'es-toolkit/string'
/**
* The five admonition kinds, onto the classes the content stylesheet already draws.
*
* AsciiDoc's five and GitHub's five are the same five, so an admonition is the same object on the
* page whichever syntax it was written in -- one set of hues, one icon set, one set of print and
* dark-mode rules. The mapping is a copy of the one in `renderers/modules/github-alerts.js` and has
* to stay one; see `convert_admonition`.
*/
const ADMONITION_CLASSES = {
note: 'is-info',
tip: 'is-success',
important: 'is-important',
warning: 'is-warning',
caution: 'is-danger'
}
/**
* The style that names one of the wiki's blocks: `[block-tabs]` over a delimited block.
*
* Matched on shape rather than against a list, because this renderer has no list to check against --
* the server strips an element that is not an enabled block (`models/rendering.ts`) and the editor's
* preview marks one the site has switched off. A block added later needs nothing here.
*/
const WIKI_BLOCK_STYLE = /^block-[a-z\d-]+$/
/**
* Attributes Asciidoctor puts on a block whether or not the author wrote them, and which are
* therefore not among the block's own props.
*
* `style` is where the `[block-tabs]` went; `title`, `id`, `role` and `reftext` are AsciiDoc's own.
* The numbered keys are the positional attribute list the style was read out of, and are dropped in
* `blockProps` rather than named here.
*/
const RESERVED_BLOCK_ATTRIBUTES = new Set(['style', 'title', 'id', 'role', 'reftext'])
/**
* Document attributes this renderer sets and a page may not change.
*
* The `@` suffix is Asciidoctor's own notation for "a default the document may override"; its
* ABSENCE is what locks a value, which is the point of every entry here. An attribute entry is one
* line of ordinary-looking source (`:source-highlighter: pygments`), so without this a page could
* quietly re-point the whole conversion -- at another backend, at a highlighter that is not
* installed, at a stylesheet of its own -- and the result would look like a broken renderer rather
* than like something the page did.
*
* `showtitle` is deliberately absent, which is what SWALLOWS a document title. A page's title is held
* in a column of its own and drawn by the app as the page's `<h1>`, so content begins at `==` and
* comes out as `<h2>` -- the same level `##` produces in markdown.
*/
const LOCKED_ATTRIBUTES = {
backend: 'html5',
/*
Highlighting is `codeBlock`'s, which runs hljs against the theme an administrator picked in the
admin area. Asciidoctor's own highlighters emit a different set of classes, so a block drawn by
one of them would come out unstyled on every page of this wiki.
*/
'source-highlighter': null,
// -> The wiki builds its own contents from the stored HTML (`anchorHeadings`) and draws it in the
// sidebar, not in the article
toc: null,
/*
Unset, and it has to be unset rather than empty. `icon:mdi:home[]` parses into an inline image of
type `icon` whatever this says -- `convert_inline_image` is what draws it, and none of
Asciidoctor's three icon modes is wanted, since each reaches for something this wiki does not have
(a Font Awesome stylesheet, or an image file under an `iconsdir`).
What the attribute still decides is the CALLOUT LIST, which `convert_colist` draws as a table of
`<img>` elements whenever `icons` is merely DEFINED -- empty counts. Every callout on every page
came out as a pair of broken images pointing at `./images/icons/callouts/1.png`.
*/
icons: null,
// -> `kbd:[]`, `btn:[]` and `menu:[]`, which markdown can only spell as raw HTML
experimental: '',
// -> An unresolved attribute reference is left as the author wrote it rather than taking the line
// it sits on with it: a vanished line is a page that silently lost a sentence
'attribute-missing': 'skip',
// -> It would otherwise be joined onto the front of every image target, and where a picture loads
// from is `fileSrc`'s answer
imagesdir: ''
}
/**
* Attributes a page MAY set, with what it gets when it does not.
*
* Trailing `@`, so a document that sets one wins. These only change how a page reads.
*/
const DEFAULT_ATTRIBUTES = {
// -> Off, as markdown has no section numbering either
sectnums: null,
idprefix: '@',
idseparator: '-@',
tabsize: '2@'
}
/** The whole of an `ifeval::[…]` line, which is a directive only at the very start of one. */
const IFEVAL_DIRECTIVE = /^ifeval::\[.*\][ \t]*$/gm
/**
* A conditional that is never taken, standing in for one this wiki does not run.
*
* `ifndef` on an attribute nothing defines is always TRUE, so what the author wrote between the
* directive and its `endif::[]` is kept. That is the failure worth having: an `ifeval` was a choice
* between alternatives, and showing all of them is a page somebody can fix, where hiding them is a
* page that silently lost a paragraph.
*/
const IFEVAL_REPLACEMENT = 'ifndef::__wikijs_ifeval__[]'
/**
* Take `ifeval::` out of the source, leaving its `endif::[]` something to close.
*
* Done over the text rather than in a preprocessor extension because that is where Asciidoctor
* itself handles the directive: a preprocessor conditional is resolved before anything is parsed,
* inside a listing block as much as in a paragraph, so a substitution over the lines sees exactly
* what the reader would have seen. Deleting the line instead would orphan the `endif::[]` and put an
* error in the preview.
*
* Note this is not a security boundary and is not claimed as one: Asciidoctor.js 4 resolves an
* `ifeval` expression by parsing two literals and applying one of six comparison operators (see
* `#evalOp` in its reader), so there is no evaluation of anything to get out of. It is off because a
* page's own text deciding which of its parts exist is not something a wiki wants to reason about,
* and because with `LOCKED_ATTRIBUTES` in place there is almost nothing left for one to compare.
*/
function disableIfeval(src) {
return src.replace(IFEVAL_DIRECTIVE, IFEVAL_REPLACEMENT)
}
/**
* The extension registry.
*
* Built per renderer rather than once for the module: a registry holds the document it was last
* activated against, and the editor's preview renders on every keystroke while a page view may be
* rendering something else at the same moment.
*/
function buildRegistry() {
return Extensions.create('wikijs', function () {
/*
`include::` -- off, completely.
`safe: 'secure'` already refuses to READ the file, but what it renders instead is a link to the
path, which is meaningless on a page and puts an arbitrary local path in front of a reader.
Handling every include and pushing back a comment is what makes the directive disappear
instead.
There is nothing for one to include in any case. A wiki page is a row in a database with no
directory beside it, and the render happens in the AUTHOR'S BROWSER -- so the only thing a
target could ever reach is whatever this wiki serves at that URL, fetched with that author's
own session.
*/
this.includeProcessor(function () {
this.handles(() => true)
this.process(function (_doc, reader, target) {
reader.pushInclude(
`// include::${target}[] is not available in this wiki`,
target,
target,
1,
{}
)
})
})
})
}
/**
* A wiki block's own attributes, as the props to write onto its element.
*
* Asciidoctor hands back positional attributes under numeric keys and named ones under their names,
* plus a few of its own. A wiki block takes everything by name -- the one positional attribute is
* the style that named the block -- so the numbered keys are dropped along with the reserved ones.
*/
function blockProps(node) {
return Object.entries(node.getAttributes()).filter(
([name]) => !RESERVED_BLOCK_ATTRIBUTES.has(name) && !/^\d+$/.test(name)
)
}
/**
* How this wiki draws an AsciiDoc document.
*
* Every method here exists because something outside the renderer reads what it produces. A default
* that is merely shaped differently from markdown-it's is left alone.
*/
class WikiConverter extends Html5Converter {
/**
* @param {string} backend The backend Asciidoctor instantiated this for.
* @param {object} opts Asciidoctor's own converter options.
* @param {object} context What the source cannot say about itself -- `pagePath`, which a relative
* image resolves against.
*/
constructor(backend, opts = {}, context = {}) {
super(backend, opts)
this.wikiContext = context
}
/**
* The source line a node came from, as the attribute the editor's preview scrolls by.
*
* `sourcemap: true` is what puts a line number on a block; without it every node answers null and
* this contributes nothing, which is right for a render with no editor behind it.
*
* `data-line` only, without the `line` class the markdown renderer joins alongside it: the class is
* stripped again before a page is stored (`postProcess`), and the markup here always already
* carries a class of Asciidoctor's own -- two `class` attributes on one tag is not markup.
*/
lineAttr(node) {
const line = node.getLineNumber?.()
return line ? ` data-line="${line}"` : ''
}
/**
* One of the wiki's blocks, as the custom element that draws it.
*
* The counterpart of what MDC does for markdown: `[block-tabs]` in, `<block-tabs>` out, with the
* body parsed as ordinary AsciiDoc so a tab holds headings, lists, code and further blocks.
*
* Done in the converter rather than through a BlockProcessor extension, and that is the whole
* reason it works for every block: a processor is registered against ONE style name, so serving
* every block there is would mean knowing the list -- which the editor does and a headless render
* does not. A delimited block already parses correctly without one; all that is missing is what it
* converts to.
*
* Props go on as attributes with their values escaped. Note the markdown editor's own writer
* cannot do this and turns a double quote into a single one instead, because MDC has no escape for
* one; AsciiDoc does, so a value survives here that would not survive there.
*/
async wikiBlock(node) {
const tag = node.getStyle()
const attrs = blockProps(node)
.map(([name, value]) => ` ${name}="${escape(String(value))}"`)
.join('')
return `<${tag}${attrs}${this.lineAttr(node)}>\n${await node.content()}\n</${tag}>`
}
/**
* The four delimited blocks that can hold other blocks, each checked for a wiki block's style.
*
* All four, because this is how AsciiDoc nests: not by growing the fence the way MDC does, but by
* ALTERNATING the delimiter. A tabset is `====` (example) with each of its tabs a `--` (open)
* inside it, and a third level would reach for `****` (sidebar).
*/
async convert_example(node) {
return this.isWikiBlock(node) ? this.wikiBlock(node) : super.convert_example(node)
}
async convert_open(node) {
return this.isWikiBlock(node) ? this.wikiBlock(node) : super.convert_open(node)
}
async convert_sidebar(node) {
return this.isWikiBlock(node) ? this.wikiBlock(node) : super.convert_sidebar(node)
}
async convert_quote(node) {
return this.isWikiBlock(node) ? this.wikiBlock(node) : super.convert_quote(node)
}
isWikiBlock(node) {
return WIKI_BLOCK_STYLE.test(node.getStyle() ?? '')
}
/**
* An admonition, as the blockquote the content stylesheet draws.
*
* Asciidoctor's own markup is a two-cell `<table>` whose first cell holds the label -- a layout
* from before CSS could do it, and nothing like what this wiki draws. The five kinds map exactly
* onto the five classes a `> [!NOTE]` produces in markdown, so the two syntaxes reach the same
* object on the page.
*
* A block title becomes the `.alert-title` line, exactly as the words after a `[!NOTE]` marker do;
* with none, the kind's own label stands in. Both are English, because what is written here is
* stored as the page's HTML and no reader's locale can reach it afterwards.
*/
async convert_admonition(node) {
const name = node.getAttribute('name')
const className = ADMONITION_CLASSES[name]
if (!className) {
return super.convert_admonition(node)
}
const idAttr = node.id ? ` id="${node.id}"` : ''
const label = node.hasTitle() ? node.title : node.getAttribute('textlabel')
return `<blockquote${idAttr} class="${className}"${this.lineAttr(node)}>
<p class="alert-title">${label}</p>
${await node.content()}
</blockquote>`
}
/**
* A checklist, as the checkboxes the content stylesheet draws.
*
* Asciidoctor writes the two states as the characters `&#10003;` and `&#10063;`, which come out as
* whatever glyph the reader's font happens to have and are indistinguishable to a screen reader
* from any other tick. `markdown-it-task-lists` writes a disabled `<input type="checkbox">` inside
* an `li.task-list-item`, which is what `_page-contents.scss` styles and what the sanitizer allows.
*
* The label follows the input as a bare text node and is NOT wrapped in anything. That plugin
* leaves the raw `[x]` marker behind the box in a `<span>`, and the content stylesheet hides
* `.task-list-item-checkbox + span` to be rid of it -- so a label put in a span here is a label
* nobody can read, which is exactly what the first draft of this did.
*
* Reached from `convert_ulist`, which is where a list decides which of the two it is.
*/
async convert_checklist(node) {
const idAttr = node.id ? ` id="${node.id}"` : ''
const classes = ['contains-task-list', node.role].filter(Boolean).join(' ')
const title = node.hasTitle() ? `<div class="title">${node.title}</div>\n` : ''
const items = []
for (const item of node.getItems()) {
const checked = item.hasAttribute('checked') ? ' checked' : ''
const blocks = item.hasBlocks() ? `\n${await item.content()}` : ''
items.push(
`<li class="task-list-item"><input class="task-list-item-checkbox" type="checkbox" disabled${checked}>${item.getText()}${blocks}</li>`
)
}
return `${title}<ul${idAttr} class="${classes}"${this.lineAttr(node)}>\n${items.join('\n')}\n</ul>`
}
/**
* A bulleted list, as the bare `<ul>` markdown produces.
*
* The one place Asciidoctor's wrapper div has to go, and it is not about looks: several rules in
* `_page-contents.scss` reach a list as a DIRECT CHILD, and a wrapper standing between them matches
* none of them.
*
* - `block-steps > ol` is how a steps block numbers its steps, so a steps block written in AsciiDoc
* drew as an ordinary numbered list.
* - `li > ul` and `dd > ul` are how a nested list is tightened against the item above it.
* - `ul.links-list` is the row-per-link treatment, which AsciiDoc asks for with `[.links-list]` --
* and a role lands on the wrapper, so it never reached the list at all.
*
* `display: contents` on the wrapper cannot fix any of that: it takes the box out of the layout but
* leaves the element in the tree, where a child combinator still trips over it.
*
* A list's own title becomes a sibling above it, which is where the wrapper put it anyway.
*/
async convert_ulist(node) {
if (node.hasOption('checklist')) {
return this.convert_checklist(node)
}
const classes = [node.style, node.role].filter(Boolean).join(' ')
return this.listMarkup(node, 'ul', classes ? ` class="${classes}"` : '')
}
/**
* An ordered list, likewise unwrapped.
*
* The numbering style rides on the `<ol>` as a class, but ONLY when the author actually asked for
* one. Asciidoctor gives every ordered list a style whether or not it was written -- `arabic` at the
* top level, then `loweralpha` and `lowerroman` as they nest -- and those defaults are already what
* `_page-contents.scss` draws, so writing them out says nothing and costs something:
* `ol.arabic { list-style-type: decimal }` outranks `block-steps > ol { list-style: none }`, which
* put a grey marker beside every step of every steps block, next to the numbered disc the block had
* already drawn for it.
*
* The positional attribute is what tells the two apart: it holds the style only when the style was
* typed (`[loweralpha]`), and is null for one Asciidoctor worked out from the nesting. So an
* explicit `[arabic]` inside another list still overrides the depth default, which is the whole
* reason somebody would write it.
*
* `type`, `start` and `reversed` are carried over as the attributes they were -- all three are on
* the sanitizer's list for `ol`.
*/
async convert_olist(node) {
const keyword = node.listMarkerKeyword()
// -> `1` is AsciiDoc's name for the first positional attribute, which is where a written style lands
const written = node.getAttribute('1') ? node.style : null
const classes = [written, node.role].filter(Boolean).join(' ')
const attrs = [
classes ? ` class="${classes}"` : '',
keyword ? ` type="${keyword}"` : '',
node.hasAttribute('start') ? ` start="${escape(node.getAttribute('start'))}"` : '',
node.hasOption('reversed') ? ' reversed' : ''
].join('')
return this.listMarkup(node, 'ol', attrs)
}
/** The body both of them share: an optional title, then the list and its items. */
async listMarkup(node, tag, attrs) {
const idAttr = node.id ? ` id="${node.id}"` : ''
const title = node.hasTitle() ? `<div class="title">${node.title}</div>\n` : ''
const items = []
for (const item of node.getItems()) {
const itemAttrs = item.id
? ` id="${item.id}"${item.role ? ` class="${item.role}"` : ''}`
: item.role
? ` class="${item.role}"`
: ''
const blocks = item.hasBlocks() ? `\n${await item.content()}` : ''
items.push(`<li${itemAttrs}><p>${item.getText()}</p>${blocks}</li>`)
}
return `${title}<${tag}${idAttr}${attrs}${this.lineAttr(node)}>\n${items.join('\n')}\n</${tag}>`
}
/**
* A code block, drawn by the very function a markdown fence is drawn by.
*
* Three things the default cannot give. Asciidoctor does no highlighting at all without a
* `source-highlighter`, and the ones it ships emit a different set of classes from the `hljs` ones
* the administrator's chosen theme is injected against. The gutter and the washed rows are this
* wiki's own markup. And `helpers/renderedContent.js` hangs the copy button on `pre.codeblock`,
* which nothing else produces.
*
* The three extras a markdown fence spells in its info string are block attributes here, and mean
* the same things:
*
* .Some title here
* [source,yaml,start=3,highlight=1..2]
*
* `highlight` accepts AsciiDoc's `1..2` and markdown's `1-2` alike -- the dots are rewritten to a
* hyphen on the way in, since nobody arriving from a markdown page will write two of them.
*/
async convert_listing(node) {
/*
The trailing newline is put back, and it matters. `codeBlock` counts the lines of a block by
counting its newlines, the way markdown-it hands one over -- a fence's content always ends with
one. `getSource()` does not, so without this every AsciiDoc block came out a line short: a
two-line block counted as one, which is the threshold for drawing a gutter at all, so short
blocks silently lost their line numbers and the last row of a long one was never washed by a
`highlight` that named it.
*/
const html = codeBlock(`${node.getSource()}\n`, node.getAttribute('language') ?? '', {
linesstart: node.getAttribute('start'),
lineshighlight: (node.getAttribute('highlight') ?? '').replaceAll('..', '-')
})
const line = this.lineAttr(node)
const title = node.hasTitle() ? node.captionedTitle() : ''
/*
The bar sits OUTSIDE the scrolling panel, so a titled block is a box holding both -- the same
shape `markdown.js` builds and for the same reason: a header inside the `<pre>` would slide
sideways with the code and be indented by the gutter's padding.
*/
if (title) {
return `<div class="codeblock-titled hljs"${line}><div class="codeblock-title">${title}</div>${html}</div>`
}
// -> Every branch of `codeBlock` opens with `<pre`
return html.replace(/^<pre/, `<pre${line}`)
}
/**
* A block image, pointed at where the picture actually is.
*
* `fileSrc` is the whole reason this is here: an author addresses a picture the way a file sitting
* beside the page would be addressed, and uploads are served from `/_files/`. Resolving it at
* render time is what keeps the source readable outside this wiki.
*
* Done by rewriting what the superclass produced rather than by rebuilding it, because everything
* else about an image block -- the link wrapper, the SVG modes, the caption, the float and
* alignment classes -- is Asciidoctor's and worth keeping.
*/
async convert_image(node) {
const html = await super.convert_image(node)
return this.rewriteImageSources(html).replace(/^<div/, `<div${this.lineAttr(node)}`)
}
/**
* An inline image -- and an icon, which AsciiDoc spells as one.
*
* `icon:mdi:home[]` is the AsciiDoc spelling of the markdown renderer's `:mdi:home:` shortcode. The
* colon inside the target survives the macro's own parsing, so an Iconify reference goes in as
* itself, and it comes out as the element the rest of the app draws icons with: `models/icons.ts`
* resolves it, `renderIcons` draws it into the stored page, and the sanitizer allows it
* unconditionally.
*/
async convert_inline_image(node) {
if (node.type !== 'icon') {
return this.rewriteImageSources(await super.convert_inline_image(node))
}
const size = node.hasAttribute('size') ? ` height="${escape(node.getAttribute('size'))}"` : ''
const title = node.hasAttribute('title') ? ` title="${escape(node.getAttribute('title'))}"` : ''
return `<iconify-icon icon="${escape(node.target)}"${size}${title}></iconify-icon>`
}
/**
* A link, marked when it leaves the wiki.
*
* `is-external-link` is what the content stylesheet draws the outbound arrow from, and it cannot be
* decided in CSS: a selector can match the shape of an href but not compare its host with this
* page's own. The class survives being stored -- `postProcess` keeps `class` on every element.
*
* Only a `link`. A cross-reference is internal by construction, and a bibliography anchor has no
* href at all.
*/
async convert_inline_anchor(node) {
const html = await super.convert_inline_anchor(node)
if (node.type !== 'link' || !html || !isExternalHref(node.target)) {
return html
}
return html.includes(' class="')
? html.replace(' class="', ' class="is-external-link ')
: html.replace(/^<a /, '<a class="is-external-link" ')
}
/** `data-line`, so the preview can be scrolled to the paragraph the caret is in. */
async convert_paragraph(node) {
return (await super.convert_paragraph(node)).replace(/^<div/, `<div${this.lineAttr(node)}`)
}
/** The same, for a section -- which is the heading and everything under it. */
async convert_section(node) {
return (await super.convert_section(node)).replace(/^<div/, `<div${this.lineAttr(node)}`)
}
/**
* Every `src` in a fragment, resolved the way `fileSrc` resolves one.
*
* A pass over rendered text rather than over an attribute, because what the superclass handed back
* is a string -- the same shape `rewriteHtmlImages` works in on the markdown side, and safe for the
* same reason: every value written back has been through `URL`.
*/
rewriteImageSources(html) {
return html.replace(
/(\ssrc=")([^"]*)(")/g,
(_match, before, value, after) =>
`${before}${fileSrc(value, this.wikiContext.pagePath)}${after}`
)
}
}
export class AsciidocRenderer {
/**
* @param {object} [config] The site's AsciiDoc editor config. Empty today, and deliberately: every
* switch the markdown renderer offers is either hardwired in AsciiDoc (its
* text replacements, its autolinking) or spelled per block rather than per
* site. Taken all the same, so a setting added later needs no new
* signature.
*/
constructor(config = {}) {
this.config = config
this.registry = buildRegistry()
}
/**
* @param {string} src AsciiDoc source.
* @param {object} [options]
* @param {string} [options.pagePath] Path of the page this source belongs to, without a leading
* slash. What a relative image resolves against -- see `fileSrc`.
* @param {boolean} [options.sourcemap] Whether to stamp `data-line` onto every block, which the
* editor's preview scrolls by and a stored page has no use for. Off by default:
* the attribute is stripped again before a page is stored, so a render with no
* editor behind it would only be paying to produce it.
* @returns {Promise<string>} The HTML.
*/
async render(src, { pagePath = '', sourcemap = false } = {}) {
return asciidoctorConvert(disableIfeval(src ?? ''), {
standalone: false,
doctype: 'article',
/*
The strictest mode Asciidoctor has, and the one a wiki wants: no reading of files, no
docinfo, no data-uri of anything local. The include processor above closes the hole it leaves
-- in secure mode a refused include is rendered as a link to the path rather than dropped.
*/
safe: 'secure',
sourcemap,
extension_registry: this.registry,
converter_factory: {
createSync: (backend, opts) => new WikiConverter(backend, opts, { pagePath })
},
attributes: { ...DEFAULT_ATTRIBUTES, ...LOCKED_ATTRIBUTES, ...this.config.attributes }
})
}
}

@ -1,10 +1,10 @@
/**
* Headless rendering entry point.
*
* The server cannot render markdown — the pipeline lives here, in the browser, and duplicating it
* would mean two renderers that drift apart and an editor preview that stops matching the saved page.
* So when the server needs to re-render a page from its source, it drives a real browser instead:
* Puppeteer loads the `/_render` shell, which loads this bundle, and calls `__wikiRender`.
* The server cannot render a page — the pipelines live here, in the browser, and duplicating one
* would mean two renderers that drift apart and an editor preview that stops matching the saved
* page. So when the server needs to re-render a page from its source, it drives a real browser
* instead: Puppeteer loads the `/_render` shell, which loads this bundle, and calls `__wikiRender`.
*
* Built to a fixed filename (`_assets/renderer.js`, see `vite.config.js`) because the backend has to
* reference it from a static page and cannot resolve a hashed one.
@ -12,18 +12,29 @@
import { MarkdownRenderer } from './markdown'
/**
* Render markdown the way the editor does.
* A page's source, rendered the way the editor that wrote it would have rendered it.
*
* @param {string} content Markdown source
* @param {object} config The site's markdown editor config, so the result matches what an author
* would have produced in the editor
* Which pipeline is the caller's to say. A source is not self-describing — the server holds the
* editor in a column and passes it in, and guessing from the text would be guessing.
*
* Asciidoctor is reached through a dynamic import, so the chunk it lives in is fetched the first time
* an AsciiDoc page is rendered and never on an instance that has none. It is roughly as large as
* everything else in this bundle put together, and most wikis will never ask for it.
*
* @param {string} content The page source
* @param {object} config The site's config for that editor, so the result matches what an author
* would have produced in it
* @param {object} context What the source cannot say about itself: `pagePath`, which a relative image
* in it resolves against, exactly as the editor passes it
* @returns {string} Rendered HTML, before the server's own post-processing
* in it resolves against, and `editor`, which picks the pipeline
* @returns {Promise<string>} Rendered HTML, before the server's own post-processing
*/
window.__wikiRender = function (content, config = {}, context = {}) {
const renderer = new MarkdownRenderer(config)
return renderer.render(content ?? '', context)
window.__wikiRender = async function (content, config = {}, context = {}) {
const { editor = 'markdown', ...rest } = context
if (editor === 'asciidoc') {
const { AsciidocRenderer } = await import('./asciidoc')
return new AsciidocRenderer(config).render(content ?? '', rest)
}
return new MarkdownRenderer(config).render(content ?? '', rest)
}
// -> Polled by the caller: a module script is deferred, so the page can be "loaded" before this ran

@ -17,15 +17,18 @@ import mdImsize from './modules/markdown-it-imsize'
import mdGithubAlerts from './modules/github-alerts'
import twemoji from '@twemoji/api'
import hljs from 'highlight.js'
// -> Relative, like this file's other in-repo imports: it is also reachable from the headless
// renderer bundle, which is built on its own
import {
codeBlock,
fileSrc,
isExternalHref,
parseFenceAttributes,
rewriteHtmlImages
} from './shared'
import { escape } from 'es-toolkit/string'
// -> Relative, like this file's other in-repo imports: it is also the entry point of the headless
// renderer bundle, which is built on its own
import { isServerPath } from '../helpers/serverPaths'
import { FILES_PREFIX } from '../helpers/assets'
const quoteStyles = {
chinese: '””‘’',
english: '“”‘’',
@ -41,108 +44,6 @@ const quoteStyles = {
swedish: '””’’'
}
/**
* Whether a link leaves this wiki.
*
* Resolved against the page's own address, so a relative path, an absolute one and a protocol-relative
* URL are all judged the same way -- by the host they end up on. `mailto:`, `tel:` and the rest are not
* pages at all, and are left unmarked: they announce themselves by what they are.
*
* With no document to resolve against -- a render outside a browser -- only an absolute URL can be
* judged, and it is judged external; a relative one fails to parse and comes back internal.
*/
function isExternalHref(href) {
if (!href) {
return false
}
const here = globalThis.location?.href
try {
const url = new URL(href, here)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false
}
return here ? url.origin !== new URL(here).origin : true
} catch {
return false
}
}
/**
* Where an image in a page should actually load from.
*
* A page's source addresses a picture the way a file sitting next to it would -- `photo.png`,
* `img/photo.png`, `/media/photo.png` -- which is what the same markdown means in a repository, and
* what an author who wrote it elsewhere expects it to mean here. None of those is a URL this server
* answers: uploaded files live under `/_files/`. So the resolution happens at render time and the
* source is left holding the path that was written, which is what keeps the file readable on GitHub.
*
* Relative is relative to the page's FOLDER, as it would be to a file's directory in a repository, so
* a picture beside the page is found from a page at any depth. A path that starts at the root means
* the site root.
*
* Only images. A relative LINK is a link to another page and means exactly what it says, so the same
* treatment would break it -- an image is the one thing that is always a file.
*
* Left alone: anything carrying a scheme of its own (`http:`, `data:`, and the `blob:` a pending
* upload sits behind until the save that uploads it), a protocol-relative URL, a bare fragment, and a
* path the server already owns -- `/_files/` included, so rendering a render changes nothing.
*
* @param {string} src The source as written.
* @param {string} pagePath Path of the page being rendered, without a leading slash. The site root
* when it is not known, which is where a render with no page behind it --
* a review, a history entry -- resolves from.
* @returns {string} The source to render with.
*/
export function fileSrc(src, pagePath = '') {
const value = (src ?? '').trim()
if (
!value ||
value.startsWith('#') ||
value.startsWith('//') ||
/^[a-z][a-z\d+.-]*:/i.test(value)
) {
return src
}
if (isServerPath(value)) {
return src
}
/*
Resolved with `URL` so that `..`, `.`, a query and a fragment all behave the way they do
everywhere else, and so that a space in a file name comes out encoded. The origin is a
placeholder that never survives -- only the path it works out does.
*/
const folder = pagePath.split('/').slice(0, -1).join('/')
try {
const url = new URL(value, `http://page.invalid/${folder ? `${folder}/` : ''}`)
return `${FILES_PREFIX}${url.pathname.replace(/^\/+/, '')}${url.search}${url.hash}`
} catch {
return src
}
}
/**
* An `<img>` written as HTML rather than as markdown, matched on its `src` and nothing else.
*
* The whitespace before `src` is what keeps `data-src` -- and any other attribute ending in those
* three characters -- out of it, since a word boundary alone sits happily after the hyphen.
*/
const HTML_IMAGE_SRC = /(<img\b[^>]*?\ssrc\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi
/**
* The same resolution, for the images an author wrote as HTML.
*
* Raw HTML reaches the renderer as text -- markdown-it does not parse it -- so this is a pass over
* that text rather than over a token's attributes. It rewrites the `src` of an `img` tag and touches
* nothing else, and every value it produces has been through `URL`, so quoting it is safe.
*/
function rewriteHtmlImages(html, pagePath) {
return html.replace(HTML_IMAGE_SRC, (match, before, quoted, singleQuoted, bare) => {
const value = quoted ?? singleQuoted ?? bare
const resolved = fileSrc(value, pagePath)
return resolved === value ? match : `${before}"${resolved}"`
})
}
/**
* An `<iconify-icon>` written the way a Vue component is, `<iconify-icon icon="mdi:home" />`.
*
@ -215,167 +116,6 @@ function iconShortcode(state, silent) {
*/
const TASK_LIST_MARKER = /^\[[ xX]\] /
/**
* Everything a fence may say about itself beyond its language.
*
* ```yaml title="Some title here" linesStart="3" linesHighlight="1,3,5-8"
*
* markdown-it takes the first word of the info string as the language name and leaves the rest of the
* line alone, so this is a parse of that remainder and of nothing else. A value may be quoted with
* either quote or left bare, because bare is what anybody writes for a number; a key with no value is
* not matched at all, since none of the three means anything without one. A quoted value may hold the
* quote that delimits it if it is escaped -- `title="Say \\"hi\\""` -- which is why the backslash is
* consumed here rather than left for `unescapeAll` to find after the value has already been cut short.
*
* Keys are folded to lower case. The syntax is documented in camel case and reads better that way, but
* `linestart` is the same request typed by somebody who did not look closely, and there is nothing to
* be gained by refusing it.
*/
const FENCE_ATTRIBUTE =
/([a-z][\w-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s"']+))/gi
function parseFenceAttributes(source, unescape = (value) => value) {
const attributes = {}
for (const match of source.matchAll(FENCE_ATTRIBUTE)) {
attributes[match[1].toLowerCase()] = unescape(match[2] ?? match[3] ?? match[4])
}
return attributes
}
/** One entry of a `linesHighlight` list: a single line, or a range written with a hyphen. */
const LINE_RANGE = /^(\d+)(?:\s*-\s*(\d+))?$/
/**
* The lines a `linesHighlight` value names, kept as the ranges it lists rather than expanded into the
* set of numbers in them -- `1-40000000` is a plausible slip of the hand and a set built from it is a
* hung tab. Nothing needs the numbers themselves; every caller only ever asks whether one line is in.
*
* An entry that is neither a number nor a range is dropped rather than failing the fence. The whole
* feature is decoration over a block that renders perfectly well without it, and the render this runs
* in is the editor's preview -- one that throws is one the editor saves as an empty page.
*
* A range written backwards (`8-5`) is read as the range it plainly means.
*/
function parseLineRanges(value) {
const ranges = []
for (const entry of (value ?? '').split(',')) {
const match = LINE_RANGE.exec(entry.trim())
if (!match) {
continue
}
const from = Number(match[1])
const to = match[2] === undefined ? from : Number(match[2])
ranges.push([Math.min(from, to), Math.max(from, to)])
}
return ranges
}
/** Whether `line` falls in any of them. */
function inRanges(ranges, line) {
return ranges.some(([from, to]) => line >= from && line <= to)
}
/**
* The number the gutter counts from, which is also what `linesHighlight` is written against: a snippet
* lifted out of a file at line 30 shows 30 against its first row, and the line an author wants marked
* is the one they can read off the gutter rather than one they have to count to.
*
* Anything that is not a whole non-negative number falls back to 1, the number a gutter counts from
* when nobody said otherwise.
*/
function parseLineStart(value) {
const parsed = Number.parseInt(value ?? '', 10)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 1
}
/**
* The row layer of a code block: one empty `<span>` per line of code.
*
* Two things are drawn off these rows, and neither can be drawn off the code itself. The line numbers
* come from a counter, so the digits are never part of the text and a copied selection stays clean.
* And the wash behind a highlighted line is the row's own background: highlighting a line by wrapping
* it would mean splitting the highlighted HTML on its newlines and re-balancing whatever hljs left
* open across them -- a multi-line comment or string is one span -- where a row already lands on its
* line by geometry alone.
*
* Drawn for a block of more than one line, which is where a gutter is worth having, and for a
* single-line block that asked for a highlight. `aria-hidden`, because a screen reader is reading the
* code and these rows say nothing about it.
*/
function lineRows(lineCount, lineStart, highlights) {
const rows = []
for (let index = 0; index < lineCount; index++) {
rows.push(
inRanges(highlights, lineStart + index)
? '<span class="is-highlighted"></span>'
: '<span></span>'
)
}
return `<span aria-hidden="true" class="line-numbers-rows">${rows.join('')}</span>`
}
/**
* One fence, as the markup it is drawn as -- always rooted at a `<pre>`, whatever it holds.
*
* @param {string} str The code, as the author wrote it.
* @param {string} lang The first word of the info string.
* @param {object} attributes The rest of it, parsed -- see `parseFenceAttributes`. Ignored by the
* diagram branch, which is a source for something else to draw and has
* no gutter, no title bar and no lines to mark.
*/
function codeBlock(str, lang, attributes) {
if (['kroki', 'mermaid', 'plantuml'].includes(lang)) {
/*
Left as source, deliberately: a diagram is drawn by the block whose body it is —
`block-diagram` for mermaid, `block-plantuml` and `block-kroki` for the others — and each
reads the text out of this `pre`. A fence on its own outside a block keeps the panel the
stylesheet gives it, which says "a diagram nobody has drawn" rather than pretending to be
a code sample.
*/
return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
}
/*
`getLanguage` first, because `hljs.highlight` THROWS on a language it does not know --
`ignoreIllegals` only forgives illegal syntax within a language it does. markdown-it takes
the first word of a fence's info string as the language name, so a fence whose code starts
on the opening line (``` <!DOCTYPE rfc [) asks for a language called `<!DOCTYPE`, and the
throw took the entire render with it: an empty preview, and -- since the editor patches the
store with the result -- an empty render saved over the stored HTML.
Unknown language therefore falls back to plain code, and the fallback ESCAPES: `str` is the
author's raw source, and the unhighlighted branch used to interpolate it into the markup as
it stood. hljs escapes what it emits, so this only ever affected the unhighlighted path.
*/
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(str, { language: lang, ignoreIllegals: true })
: { value: escape(str) }
// -> `match` is null, not empty, when the code is a single line with no trailing newline
const lineCount = (highlighted.value.match(/\n/g) ?? []).length
const lineStart = parseLineStart(attributes.linesstart)
const highlights = parseLineRanges(attributes.lineshighlight)
/*
The gutter is for a block worth numbering; a one-line block is its own line number. A highlight
still needs its row, so the two are separate questions -- the class is what draws the digits, the
layer is what the wash is painted on.
*/
const numbered = lineCount > 1
const rows =
numbered || highlights.length > 0 ? lineRows(Math.max(lineCount, 1), lineStart, highlights) : ''
/*
Where the counter starts, as the value it is reset to -- one below the first line, since every row
increments before it draws. Left off entirely at the default, so that a block nobody has renumbered
carries no style attribute at all.
*/
const numbering = lineStart === 1 ? '' : ` style="--code-line-start: ${lineStart - 1}"`
// -> `lang` is escaped too: it is whatever the author typed after the backticks, and a quote
// in it would otherwise close the attribute and inject markup into the preview
return `<pre class="codeblock hljs${numbered ? ' line-numbers' : ''}"${numbering}><code class="language-${escape(lang)}">${highlighted.value}${rows}</code></pre>`
}
/**
* An emoji as the markup a page shows for it: a twemoji SVG served by this instance.
*

@ -0,0 +1,291 @@
/**
* What every page renderer in this app has to answer the same way, whatever syntax it parses.
*
* A page's HTML is produced in the author's browser and stored, so two pipelines writing the same
* page differently is not a cosmetic problem -- it is two pages. These are the four places the
* answer belongs to the WIKI rather than to a markup language:
*
* - where a picture actually loads from (`fileSrc`), which is a fact about how this server serves
* uploads and not about how a link was spelled;
* - whether a link leaves the wiki (`isExternalHref`), which is a comparison against the page's own
* host and cannot be made in CSS;
* - what a code block is drawn as (`codeBlock`), which carries the gutter, the highlighted rows and
* the `hljs` hooks the administrator's chosen theme is injected against, and the `pre.codeblock`
* that `helpers/renderedContent.js` hangs a copy button on;
* - how a fence's extra attributes are read (`parseFenceAttributes`), since the AsciiDoc pipeline
* spells the same three requests as block attributes and has to end up with the same object.
*
* Kept apart from `markdown.js` so that reaching for them does not reach for markdown-it and its
* fifteen plugins: the AsciiDoc renderer is a lazy chunk of its own and shares only this.
*/
import hljs from 'highlight.js'
import { escape } from 'es-toolkit/string'
// -> Relative, like the renderers' other in-repo imports: this module is reachable from the headless
// renderer bundle, which is built on its own
import { isServerPath } from '../helpers/serverPaths'
import { FILES_PREFIX } from '../helpers/assets'
/**
* Whether a link leaves this wiki.
*
* Resolved against the page's own address, so a relative path, an absolute one and a protocol-relative
* URL are all judged the same way -- by the host they end up on. `mailto:`, `tel:` and the rest are not
* pages at all, and are left unmarked: they announce themselves by what they are.
*
* With no document to resolve against -- a render outside a browser -- only an absolute URL can be
* judged, and it is judged external; a relative one fails to parse and comes back internal.
*/
export function isExternalHref(href) {
if (!href) {
return false
}
const here = globalThis.location?.href
try {
const url = new URL(href, here)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false
}
return here ? url.origin !== new URL(here).origin : true
} catch {
return false
}
}
/**
* Where an image in a page should actually load from.
*
* A page's source addresses a picture the way a file sitting next to it would -- `photo.png`,
* `img/photo.png`, `/media/photo.png` -- which is what the same markdown means in a repository, and
* what an author who wrote it elsewhere expects it to mean here. None of those is a URL this server
* answers: uploaded files live under `/_files/`. So the resolution happens at render time and the
* source is left holding the path that was written, which is what keeps the file readable on GitHub.
*
* Relative is relative to the page's FOLDER, as it would be to a file's directory in a repository, so
* a picture beside the page is found from a page at any depth. A path that starts at the root means
* the site root.
*
* Only images. A relative LINK is a link to another page and means exactly what it says, so the same
* treatment would break it -- an image is the one thing that is always a file.
*
* Left alone: anything carrying a scheme of its own (`http:`, `data:`, and the `blob:` a pending
* upload sits behind until the save that uploads it), a protocol-relative URL, a bare fragment, and a
* path the server already owns -- `/_files/` included, so rendering a render changes nothing.
*
* @param {string} src The source as written.
* @param {string} pagePath Path of the page being rendered, without a leading slash. The site root
* when it is not known, which is where a render with no page behind it --
* a review, a history entry -- resolves from.
* @returns {string} The source to render with.
*/
export function fileSrc(src, pagePath = '') {
const value = (src ?? '').trim()
if (
!value ||
value.startsWith('#') ||
value.startsWith('//') ||
/^[a-z][a-z\d+.-]*:/i.test(value)
) {
return src
}
if (isServerPath(value)) {
return src
}
/*
Resolved with `URL` so that `..`, `.`, a query and a fragment all behave the way they do
everywhere else, and so that a space in a file name comes out encoded. The origin is a
placeholder that never survives -- only the path it works out does.
*/
const folder = pagePath.split('/').slice(0, -1).join('/')
try {
const url = new URL(value, `http://page.invalid/${folder ? `${folder}/` : ''}`)
return `${FILES_PREFIX}${url.pathname.replace(/^\/+/, '')}${url.search}${url.hash}`
} catch {
return src
}
}
/**
* An `<img>` written as HTML rather than as markdown, matched on its `src` and nothing else.
*
* The whitespace before `src` is what keeps `data-src` -- and any other attribute ending in those
* three characters -- out of it, since a word boundary alone sits happily after the hyphen.
*/
const HTML_IMAGE_SRC = /(<img\b[^>]*?\ssrc\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi
/**
* The same resolution, for the images an author wrote as HTML.
*
* Raw HTML reaches the renderer as text -- markdown-it does not parse it -- so this is a pass over
* that text rather than over a token's attributes. It rewrites the `src` of an `img` tag and touches
* nothing else, and every value it produces has been through `URL`, so quoting it is safe.
*/
export function rewriteHtmlImages(html, pagePath) {
return html.replace(HTML_IMAGE_SRC, (match, before, quoted, singleQuoted, bare) => {
const value = quoted ?? singleQuoted ?? bare
const resolved = fileSrc(value, pagePath)
return resolved === value ? match : `${before}"${resolved}"`
})
}
/**
* Everything a fence may say about itself beyond its language.
*
* ```yaml title="Some title here" linesStart="3" linesHighlight="1,3,5-8"
*
* markdown-it takes the first word of the info string as the language name and leaves the rest of the
* line alone, so this is a parse of that remainder and of nothing else. A value may be quoted with
* either quote or left bare, because bare is what anybody writes for a number; a key with no value is
* not matched at all, since none of the three means anything without one. A quoted value may hold the
* quote that delimits it if it is escaped -- `title="Say \\"hi\\""` -- which is why the backslash is
* consumed here rather than left for `unescapeAll` to find after the value has already been cut short.
*
* Keys are folded to lower case. The syntax is documented in camel case and reads better that way, but
* `linestart` is the same request typed by somebody who did not look closely, and there is nothing to
* be gained by refusing it.
*/
const FENCE_ATTRIBUTE =
/([a-z][\w-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s"']+))/gi
export function parseFenceAttributes(source, unescape = (value) => value) {
const attributes = {}
for (const match of source.matchAll(FENCE_ATTRIBUTE)) {
attributes[match[1].toLowerCase()] = unescape(match[2] ?? match[3] ?? match[4])
}
return attributes
}
/** One entry of a `linesHighlight` list: a single line, or a range written with a hyphen. */
const LINE_RANGE = /^(\d+)(?:\s*-\s*(\d+))?$/
/**
* The lines a `linesHighlight` value names, kept as the ranges it lists rather than expanded into the
* set of numbers in them -- `1-40000000` is a plausible slip of the hand and a set built from it is a
* hung tab. Nothing needs the numbers themselves; every caller only ever asks whether one line is in.
*
* An entry that is neither a number nor a range is dropped rather than failing the fence. The whole
* feature is decoration over a block that renders perfectly well without it, and the render this runs
* in is the editor's preview -- one that throws is one the editor saves as an empty page.
*
* A range written backwards (`8-5`) is read as the range it plainly means.
*/
function parseLineRanges(value) {
const ranges = []
for (const entry of (value ?? '').split(',')) {
const match = LINE_RANGE.exec(entry.trim())
if (!match) {
continue
}
const from = Number(match[1])
const to = match[2] === undefined ? from : Number(match[2])
ranges.push([Math.min(from, to), Math.max(from, to)])
}
return ranges
}
/** Whether `line` falls in any of them. */
function inRanges(ranges, line) {
return ranges.some(([from, to]) => line >= from && line <= to)
}
/**
* The number the gutter counts from, which is also what `linesHighlight` is written against: a snippet
* lifted out of a file at line 30 shows 30 against its first row, and the line an author wants marked
* is the one they can read off the gutter rather than one they have to count to.
*
* Anything that is not a whole non-negative number falls back to 1, the number a gutter counts from
* when nobody said otherwise.
*/
function parseLineStart(value) {
const parsed = Number.parseInt(value ?? '', 10)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 1
}
/**
* The row layer of a code block: one empty `<span>` per line of code.
*
* Two things are drawn off these rows, and neither can be drawn off the code itself. The line numbers
* come from a counter, so the digits are never part of the text and a copied selection stays clean.
* And the wash behind a highlighted line is the row's own background: highlighting a line by wrapping
* it would mean splitting the highlighted HTML on its newlines and re-balancing whatever hljs left
* open across them -- a multi-line comment or string is one span -- where a row already lands on its
* line by geometry alone.
*
* Drawn for a block of more than one line, which is where a gutter is worth having, and for a
* single-line block that asked for a highlight. `aria-hidden`, because a screen reader is reading the
* code and these rows say nothing about it.
*/
function lineRows(lineCount, lineStart, highlights) {
const rows = []
for (let index = 0; index < lineCount; index++) {
rows.push(
inRanges(highlights, lineStart + index)
? '<span class="is-highlighted"></span>'
: '<span></span>'
)
}
return `<span aria-hidden="true" class="line-numbers-rows">${rows.join('')}</span>`
}
/**
* One fence, as the markup it is drawn as -- always rooted at a `<pre>`, whatever it holds.
*
* @param {string} str The code, as the author wrote it.
* @param {string} lang The first word of the info string.
* @param {object} attributes The rest of it, parsed -- see `parseFenceAttributes`. Ignored by the
* diagram branch, which is a source for something else to draw and has
* no gutter, no title bar and no lines to mark.
*/
export function codeBlock(str, lang, attributes) {
if (['kroki', 'mermaid', 'plantuml'].includes(lang)) {
/*
Left as source, deliberately: a diagram is drawn by the block whose body it is —
`block-diagram` for mermaid, `block-plantuml` and `block-kroki` for the others — and each
reads the text out of this `pre`. A fence on its own outside a block keeps the panel the
stylesheet gives it, which says "a diagram nobody has drawn" rather than pretending to be
a code sample.
*/
return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
}
/*
`getLanguage` first, because `hljs.highlight` THROWS on a language it does not know --
`ignoreIllegals` only forgives illegal syntax within a language it does. markdown-it takes
the first word of a fence's info string as the language name, so a fence whose code starts
on the opening line (``` <!DOCTYPE rfc [) asks for a language called `<!DOCTYPE`, and the
throw took the entire render with it: an empty preview, and -- since the editor patches the
store with the result -- an empty render saved over the stored HTML.
Unknown language therefore falls back to plain code, and the fallback ESCAPES: `str` is the
author's raw source, and the unhighlighted branch used to interpolate it into the markup as
it stood. hljs escapes what it emits, so this only ever affected the unhighlighted path.
*/
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(str, { language: lang, ignoreIllegals: true })
: { value: escape(str) }
// -> `match` is null, not empty, when the code is a single line with no trailing newline
const lineCount = (highlighted.value.match(/\n/g) ?? []).length
const lineStart = parseLineStart(attributes.linesstart)
const highlights = parseLineRanges(attributes.lineshighlight)
/*
The gutter is for a block worth numbering; a one-line block is its own line number. A highlight
still needs its row, so the two are separate questions -- the class is what draws the digits, the
layer is what the wash is painted on.
*/
const numbered = lineCount > 1
const rows =
numbered || highlights.length > 0 ? lineRows(Math.max(lineCount, 1), lineStart, highlights) : ''
/*
Where the counter starts, as the value it is reset to -- one below the first line, since every row
increments before it draws. Left off entirely at the default, so that a block nobody has renumbered
carries no style attribute at all.
*/
const numbering = lineStart === 1 ? '' : ` style="--code-line-start: ${lineStart - 1}"`
// -> `lang` is escaped too: it is whatever the author typed after the backticks, and a quote
// in it would otherwise close the attribute and inject markup into the preview
return `<pre class="codeblock hljs${numbered ? ' line-numbers' : ''}"${numbering}><code class="language-${escape(lang)}">${highlighted.value}${rows}</code></pre>`
}

@ -140,6 +140,7 @@ export const useSiteStore = defineStore('site', {
asciidoc: false,
blog: false,
markdown: false,
redirect: false,
visual: false
},
/** Every installed locale, as this wiki refers to it. Empty until the app has bootstrapped. */
@ -262,13 +263,18 @@ export const useSiteStore = defineStore('site', {
* The editors a page on this site may be written with, as the ids `pages.editor` stores — the
* order they are offered in, which is the order a reader meets them.
*
* Three questions at once, and all three have to be asked or the list is fiction: whether the
* site has the editor turned on (`editors`, the admin area's Editors screen), whether it is
* implemented at all — `channel` and `api` are names with no editor behind them yet, and
* `asciidoc` is half-built, so all three are behind the experimental flag — and `redirect`, which
* no site can turn off because it authors nothing: a redirection is a page with a target instead
* of a body. On a wiki with the flag off that leaves Markdown, Visual, Blog and Redirection, in
* that order: Markdown is what most pages are written with, so it is the one offered first.
* Two questions at once, and both have to be asked or the list is fiction: whether the site has
* the editor turned on (`editors`, the admin area's Editors screen), and whether it is
* implemented at all — `channel` and `api` are names with no editor behind them yet, so both are
* behind the experimental flag. On a wiki with the flag off that leaves Markdown, Visual,
* AsciiDoc, Blog and Redirection, in that order: Markdown is what most pages are written with, so
* it is the one offered first.
*
* Redirection is in the list on the same footing as the rest. It used to be unconditional, on the
* reasoning that a redirection authors nothing and so has nothing to turn off — but what the
* switch decides is whether the site OFFERS one, which is a question a wiki that does not want
* loose redirections lying about has every reason to answer no. Turning it off leaves the
* redirections a site already has working and editable; only `New Redirection` goes.
*
* Markdown and Visual are two views of the same markdown source, which is what lets a page move
* between them — see `interchangeableEditors` on the server.
@ -282,7 +288,7 @@ export const useSiteStore = defineStore('site', {
return [
...(this.editors.markdown ? ['markdown'] : []),
...(this.editors.visual ? ['visual'] : []),
...(experimental && this.editors.asciidoc ? ['asciidoc'] : []),
...(this.editors.asciidoc ? ['asciidoc'] : []),
/*
After the two that write pages and before the one that writes none: a blog's front page is
a page somebody creates deliberately and rarely, so it does not belong at the top of the
@ -290,7 +296,7 @@ export const useSiteStore = defineStore('site', {
*/
...(this.editors.blog ? ['blog'] : []),
...(experimental ? ['channel', 'api'] : []),
'redirect'
...(this.editors.redirect ? ['redirect'] : [])
]
},
/** Whether `code` is one of the locales this site has enabled. */
@ -413,6 +419,7 @@ export const useSiteStore = defineStore('site', {
asciidoc: siteInfo.editors.asciidoc?.isActive ?? false,
blog: siteInfo.editors.blog?.isActive ?? false,
markdown: siteInfo.editors.markdown?.isActive ?? false,
redirect: siteInfo.editors.redirect?.isActive ?? false,
visual: siteInfo.editors.visual?.isActive ?? false
},
// -> Spread over the state defaults, as `features` and `theme` above do, so a key the

Loading…
Cancel
Save