mirror of https://github.com/requarks/wiki
parent
f2ca01a08d
commit
a9940477df
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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}`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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 `✓` and `❏`, 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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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>`
|
||||
}
|
||||
Loading…
Reference in new issue