feat: wikilinks syntax + more compact ToC rail

pull/8104/head
NGPixel 3 days ago
parent 7ca0c302f7
commit f64d643123
No known key found for this signature in database

@ -224,6 +224,7 @@ editors:
lineBreaks: true lineBreaks: true
typographer: false typographer: false
underline: false underline: false
wikiLinks: true
tabWidth: 2 tabWidth: 2
latexEngine: katex latexEngine: katex
kroki: true kroki: true

@ -437,6 +437,8 @@
"admin.editors.markdown.typographerHint": "Enable some language-neutral replacement + quotes beautification.", "admin.editors.markdown.typographerHint": "Enable some language-neutral replacement + quotes beautification.",
"admin.editors.markdown.underline": "Underline Emphasis", "admin.editors.markdown.underline": "Underline Emphasis",
"admin.editors.markdown.underlineHint": "Enable text underlining by using _underline_ syntax.", "admin.editors.markdown.underlineHint": "Enable text underlining by using _underline_ syntax.",
"admin.editors.markdown.wikiLinks": "Wikilinks",
"admin.editors.markdown.wikiLinksHint": "Link to pages by name with the [[Page Name]] and [[Page Name{'|'}text]] syntax. Spaces become dashes and letters are lowercased.",
"admin.editors.markdownDescription": "Use the Markdown syntax to write content. Includes real-time preview and code completion features.", "admin.editors.markdownDescription": "Use the Markdown syntax to write content. Includes real-time preview and code completion features.",
"admin.editors.markdownName": "Markdown Editor", "admin.editors.markdownName": "Markdown Editor",
"admin.editors.redirectDescription": "Create redirections to other pages / external links.", "admin.editors.redirectDescription": "Create redirections to other pages / external links.",

@ -231,7 +231,8 @@ class Sites {
quotes: 'english', quotes: 'english',
tabWidth: 2, tabWidth: 2,
typographer: false, typographer: false,
underline: true underline: true,
wikiLinks: true
} }
}, },
/* /*
@ -526,7 +527,8 @@ class Sites {
quotes: 'english', quotes: 'english',
tabWidth: 2, tabWidth: 2,
typographer: false, typographer: false,
underline: true underline: true,
wikiLinks: true
} }
}, },
/* /*

@ -79,13 +79,20 @@ const VERBOSE = process.argv.includes('--verbose')
* an editor that normalised it to `*x*` would be changing the page rather than reformatting it. * an editor that normalised it to `*x*` would be changing the page rather than reformatting it.
*/ */
const CONFIGS = { const CONFIGS = {
default: { allowHTML: true, linkify: true, lineBreaks: true, multimdTable: true }, default: {
allowHTML: true,
linkify: true,
lineBreaks: true,
multimdTable: true,
wikiLinks: true
},
underline: { underline: {
allowHTML: true, allowHTML: true,
linkify: true, linkify: true,
lineBreaks: true, lineBreaks: true,
multimdTable: true, multimdTable: true,
underline: true underline: true,
wikiLinks: true
}, },
plain: { plain: {
allowHTML: true, allowHTML: true,
@ -111,6 +118,10 @@ const CONSTRUCTS = {
*/ */
'link opening a new tab': 'A [new tab](https://example.com){target="_blank"} here.', 'link opening a new tab': 'A [new tab](https://example.com){target="_blank"} here.',
'link with id and class': 'A [classed](https://example.com){#x .cls} here.', 'link with id and class': 'A [classed](https://example.com){#x .cls} here.',
// -> A citation written as a link, which crashed the renderer before MDC's span stopped claiming
// the brackets inside it
'link whose text is bracketed': 'See [[1]](https://example.com) here.',
'link with a span in its text': 'A [text with [a span]{.x} in it](https://example.com) here.',
'link with a title and a target': 'A [both](https://example.com "Tip"){target="_blank"} here.', 'link with a title and a target': 'A [both](https://example.com "Tip"){target="_blank"} here.',
'image with size': '![alt](pic.png =100x200)', 'image with size': '![alt](pic.png =100x200)',
// -> Both halves of the suffix are optional, and a height on its own went missing on the way back // -> Both halves of the suffix are optional, and a height on its own went missing on the way back
@ -123,6 +134,7 @@ const CONSTRUCTS = {
'ordered list with parens': '1) one\n2) two', 'ordered list with parens': '1) one\n2) two',
'loose list': '- one\n\n- two', 'loose list': '- one\n\n- two',
'task list': '- [x] done\n- [ ] todo', 'task list': '- [x] done\n- [ ] todo',
'task starting with a link': '- [ ] [a link](/somewhere) to do',
blockquote: '> quoted\n>\n> second', blockquote: '> quoted\n>\n> second',
alert: '> [!WARNING] Mind the gap\n> Body of the alert.', alert: '> [!WARNING] Mind the gap\n> Body of the alert.',
'alert without a title': '> [!NOTE]\n> Body of the note.', 'alert without a title': '> [!NOTE]\n> Body of the note.',
@ -154,6 +166,18 @@ const CONSTRUCTS = {
'code containing backticks': '``a ` b``' 'code containing backticks': '``a ` b``'
} }
/**
* Only under a config that turns `wikiLinks` on. With it off, `[[x]]` is two of MDC's inline spans one
* inside the other, which is a different construct and not what these are here to check.
*/
const WIKILINK_CONSTRUCTS = {
wikilink: 'See [[Getting Started]] and [[Guides/Setup Guide|the setup guide]].',
'wikilink to a section': 'See [[Page Name#Some Heading]] and [[#Local Section]].',
'wikilink with formatted text': 'See [[Some Page|**bold** and *italic*]] here.',
'wikilink with escapes': 'See [[Star\\* Page]] here.',
'wikilink opening a new tab': 'See [[Some Page]]{target="_blank"} here.'
}
/** /**
* A render reduced to what it says. * A render reduced to what it says.
* *
@ -182,6 +206,27 @@ function check(name, source, config) {
} catch (err) { } catch (err) {
return { ok: false, why: `threw while round-tripping: ${err.message}` } return { ok: false, why: `threw while round-tripping: ${err.message}` }
} }
/*
And a second save must change nothing at all. The render comparison above cannot see a source that
only drifts in its whitespace -- it collapses whitespace on purpose -- and that is exactly the
failure that grows: a task item gained one more space after its checkbox on every save.
*/
let again
try {
again = serialize(parser.parse(rewritten, 'demo/page'))
} catch (err) {
return { ok: false, why: `threw on a second round trip: ${err.message}`, rewritten }
}
if (again !== rewritten) {
return {
ok: false,
why: 'changes again on a second round trip',
rewritten,
before: rewritten,
after: again
}
}
const before = meaningOf(render(source)) const before = meaningOf(render(source))
const after = meaningOf(render(rewritten)) const after = meaningOf(render(rewritten))
if (before === after) { if (before === after) {
@ -206,6 +251,9 @@ let checked = 0
for (const [configName, config] of Object.entries(CONFIGS)) { for (const [configName, config] of Object.entries(CONFIGS)) {
const cases = Object.entries(CONSTRUCTS) const cases = Object.entries(CONSTRUCTS)
if (config.wikiLinks) {
cases.push(...Object.entries(WIKILINK_CONSTRUCTS))
}
for (const page of SAMPLE_PAGES) { for (const page of SAMPLE_PAGES) {
if (page.content?.trim()) { if (page.content?.trim()) {
cases.push([`sample page: ${page.title ?? page.path}`, page.content]) cases.push([`sample page: ${page.title ?? page.path}`, page.content])

@ -186,6 +186,22 @@
:aria-label="t(`admin.editors.markdown.underline`)" /> :aria-label="t(`admin.editors.markdown.underline`)" />
</w-item-section> </w-item-section>
</w-item> </w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="tree-structure" />
<w-item-section>
<w-item-label>{{t(`admin.editors.markdown.wikiLinks`)}}</w-item-label>
<w-item-label caption>{{t(`admin.editors.markdown.wikiLinksHint`)}}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.wikiLinks"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:aria-label="t(`admin.editors.markdown.wikiLinks`)" />
</w-item-section>
</w-item>
</w-card> </w-card>
<w-inner-loading :showing="state.loading > 0"> <w-inner-loading :showing="state.loading > 0">
<w-spinner color="accent" size="lg" /> <w-spinner color="accent" size="lg" />
@ -233,7 +249,8 @@ function defaultConfig() {
quotes: 'english', quotes: 'english',
underline: true, underline: true,
tabWidth: 2, tabWidth: 2,
multimdTable: true multimdTable: true,
wikiLinks: true
} }
} }

@ -1,5 +1,5 @@
<template> <template>
<nav class="page-toc" aria-label="Table of contents"> <nav class="page-toc" :class="{ 'page-toc--joined': joined }" aria-label="Table of contents">
<ul <ul
class="page-toc-list" class="page-toc-list"
:class="{ 'page-toc-list--animated': markerAnimated }" :class="{ 'page-toc-list--animated': markerAnimated }"
@ -69,6 +69,15 @@ const props = defineProps({
selected: { selected: {
type: String, type: String,
default: null default: null
},
/**
* Whether the rail turns out to the left at each end to meet a border drawn down the column's left
* edge, rather than stopping level with the first and last label. Only the caller knows whether
* there is such a border to meet, and how far off it is: see `--page-toc-reach` in the stylesheet.
*/
joined: {
type: Boolean,
default: false
} }
}) })
@ -333,6 +342,23 @@ onBeforeUnmount(() => {
--page-toc-ink-soft: #{$grey-6}; --page-toc-ink-soft: #{$grey-6};
--page-toc-ink-hover: #{$grey-10}; --page-toc-ink-hover: #{$grey-10};
--page-toc-hover-surface: rgba(0, 0, 0, 0.04); --page-toc-hover-surface: rgba(0, 0, 0, 0.04);
/*
The space between the rail and a top-level label. The list is pulled left by the same amount, so
the labels start on this component's own left edge -- level with whatever else the caller lines up
there -- and the rail hangs out into the caller's padding.
*/
--page-toc-gutter: 9px;
/* How far left of the rail the border a joined rail meets is: what is left of the caller's `px-4` */
--page-toc-reach: calc(1rem - var(--page-toc-gutter));
/* The radius of a joined rail's two turns, a little inside the reach so each keeps a short straight */
--page-toc-turn: 6px;
/* The active marker's thickness, measured left from the rail's right-hand edge */
--page-toc-marker-w: 4px;
/*
What a joined rail encloses between itself and the border: half the article's own white, so the
strip reads as the edge of the page reaching into the column rather than as a gap in it.
*/
--page-toc-reach-fill: rgba(255, 255, 255, 0.5);
line-height: 1.4; line-height: 1.4;
@ -343,11 +369,13 @@ onBeforeUnmount(() => {
--page-toc-ink-soft: rgba(255, 255, 255, 0.45); --page-toc-ink-soft: rgba(255, 255, 255, 0.45);
--page-toc-ink-hover: #fff; --page-toc-ink-hover: #fff;
--page-toc-hover-surface: rgba(255, 255, 255, 0.06); --page-toc-hover-surface: rgba(255, 255, 255, 0.06);
/* -> Half the article's dark ground, `$dark-6`, for the same reason: white would glare here */
--page-toc-reach-fill: #{rgba($dark-6, 0.5)};
} }
&-list { &-list {
position: relative; position: relative;
margin: 0; margin: 0 0 0 calc(-1 * var(--page-toc-gutter));
padding: 0; padding: 0;
list-style: none; list-style: none;
@ -363,6 +391,38 @@ onBeforeUnmount(() => {
} }
} }
/*
The joined rail: in from the column's left border above the list, a quarter turn down, the rail,
and a quarter turn back out to the border below it -- the same line round the contents that the
history timeline draws round its entries.
As there, all three stretches are ONE border of ONE box -- the top, right and bottom edges of an
invisible rectangle whose left edge is the column's -- so that the straights and the turns cannot
come out at different thicknesses under fractional display scaling.
The turns sit outside the list, above and below it, so the whole height of the list is the
straight stretch and the marker never rides onto a curve. The caller's padding is what they sit
in. And it is drawn in the border's own colour rather than the rail's, since a line that changed
shade where it met the border would read as two lines touching.
*/
&--joined &-list::before {
/*
How far above the list the line comes in is the caller's to say, as `--page-toc-lead`: it is
wherever the line it continues is drawn, which nothing here can know. At least the turn, or the
curve would run into the first label.
*/
top: calc(-1 * max(var(--page-toc-lead, 0px), var(--page-toc-turn)));
bottom: calc(-1 * var(--page-toc-turn));
left: calc(-1 * var(--page-toc-reach));
box-sizing: border-box;
/* -> Its right-hand border lands where the plain rail is, on the list's own left edge */
width: calc(var(--page-toc-reach) + 1px);
border: 1px solid var(--page-chrome-rule, var(--page-toc-rail));
border-left: 0;
border-radius: 0 var(--page-toc-turn) var(--page-toc-turn) 0;
background-color: var(--page-toc-reach-fill);
}
&-item { &-item {
position: relative; position: relative;
/* Depth is carried as a custom property by the template, so one rule indents every level */ /* Depth is carried as a custom property by the template, so one rule indents every level */
@ -370,8 +430,10 @@ onBeforeUnmount(() => {
} }
/* /*
The active marker, drawn ON the rail rather than beside it: `left: 0` is the list's own border The active marker, drawn ON the rail rather than beside it: the rail is the list's first pixel,
box, which is where the rail is, so every depth marks the same line whatever its indentation. so every depth marks the same line whatever its indentation. Its right edge is the rail's right
edge, and the extra width grows out to the left -- toward the border a joined rail meets, and away
from the labels, which then sit the same distance from it as from the rail.
One element for the whole list rather than a pseudo on the active row, so that moving to the next One element for the whole list rather than a pseudo on the active row, so that moving to the next
heading is a slide down the rail instead of the marker being switched off one row and on another. heading is a slide down the rail instead of the marker being switched off one row and on another.
@ -382,10 +444,12 @@ onBeforeUnmount(() => {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; /* -> The rail is 1px wide from `left: 0` */
width: 2px; left: calc(1px - var(--page-toc-marker-w));
width: var(--page-toc-marker-w);
height: var(--page-toc-marker-h, 0); height: var(--page-toc-marker-h, 0);
border-radius: 1px; /* -> Square on the rail's side, so it sits flush along the line it marks */
border-radius: calc(var(--page-toc-marker-w) / 2) 0 0 calc(var(--page-toc-marker-w) / 2);
background-color: var(--color-primary); background-color: var(--color-primary);
opacity: var(--page-toc-marker-opacity, 0); opacity: var(--page-toc-marker-opacity, 0);
transform: translateY(var(--page-toc-marker-y, 0)); transform: translateY(var(--page-toc-marker-y, 0));
@ -401,8 +465,8 @@ onBeforeUnmount(() => {
&-link { &-link {
display: block; display: block;
/* 9px of gutter, not a caret column: the rail is the only thing to the left of a label */ /* A gutter, not a caret column: the rail is the only thing to the left of a label */
padding: 3px 8px 3px 9px; padding: 3px 8px 3px var(--page-toc-gutter);
border-radius: 4px; border-radius: 4px;
color: inherit; color: inherit;
font-size: inherit; font-size: inherit;

@ -390,6 +390,16 @@ $toc-overlay-max: 749.98px;
.page-sidebar { .page-sidebar {
flex: 0 0 300px; flex: 0 0 300px;
/*
Where the contents rail comes in from the article column's edge (`joined` on `PageToc`), as a
distance above the list: level with the rule along the bottom of the Article / Talk strip, so that
the strip's line carries straight on into the rail rather than turning down the article's edge and
back out a pixel lower. That rule is the bottom pixel of the 44px strip (`PageViewTabs.vue`), and
the list starts under the Contents heading's row -- 1rem of padding either side of a 1.25rem
caption line (`pages/Index.vue`).
*/
--page-toc-lead: calc(1rem + 1.25rem + 1rem - 43px);
/* /*
Narrower once the window is: 300px is pitched for a wide desktop, where it is a tenth of the width, and Narrower once the window is: 300px is pitched for a wide desktop, where it is a tenth of the width, and
by 1200px it is a quarter of what is left after the nav sidebar. 200px still holds a heading of a few by 1200px it is a quarter of what is left after the nav sidebar. 200px still holds a heading of a few

@ -76,6 +76,7 @@ export function findLink(state) {
text: state.doc.textBetween(from, to, '', ''), text: state.doc.textBetween(from, to, '', ''),
href: mark.attrs.href, href: mark.attrs.href,
title: mark.attrs.title ?? '', title: mark.attrs.title ?? '',
wikilink: mark.attrs.wikilink,
// -> Whatever `{…}` the link carries, so editing it does not drop a `target="_blank"` // -> Whatever `{…}` the link carries, so editing it does not drop a `target="_blank"`
mdAttrs: mark.attrs.mdAttrs, mdAttrs: mark.attrs.mdAttrs,
// -> Everything the run carries besides the link, so replacing the text does not drop its emphasis // -> Everything the run carries besides the link, so replacing the text does not drop its emphasis
@ -105,6 +106,9 @@ export function applyLink(view, range, { text, href, title, newTab }) {
const mark = type.create({ const mark = type.create({
href, href,
title: title || null, title: title || null,
// -> Still a wikilink while it still goes where its target says; a new address makes it an
// ordinary link, since the target is what the href would be derived from
wikilink: href === range.href ? (range.wikilink ?? null) : null,
mdAttrs: Object.keys(mdAttrs).length > 0 ? mdAttrs : null mdAttrs: Object.keys(mdAttrs).length > 0 ? mdAttrs : null
}) })
const tr = view.state.tr const tr = view.state.tr

@ -202,11 +202,23 @@ function normalizeInline(children, config) {
its text, and the item itself carries whether it is ticked, so there is nothing here for it to its text, and the item itself carries whether it is ticked, so there is nothing here for it to
contribute. contribute.
The plugin slices the `[x] ` off the text itself, marker and space together, so nothing is left The plugin slices three characters off the text after it -- the `[x]` and NOT the space that
to trim after it — see the guard in `renderers/markdown.js` that keeps MDC's inline span off follows, which it leaves in so the page draws a gap between the box and the words. That space
that marker, without which it silently sliced nothing and left the marker in the page. is the serialiser's to write, since it goes out with the marker, so it is taken off the text
here: left on, every save wrote `- [ ] ` and then the text's own leading space after it, and
the item gained another space each time the page was saved. See also the guard in
`renderers/markdown.js` that keeps MDC's inline span off that marker, without which it silently
sliced nothing and left the marker in the page.
*/ */
if (tok.type === 'html_inline' && tok.content.includes('task-list-item-checkbox')) { if (tok.type === 'html_inline' && tok.content.includes('task-list-item-checkbox')) {
const next = children[i + 1]
if (next?.type === 'text') {
next.content = next.content.trimStart()
// -> An item that starts with a link or an emoji has nothing else in that token
if (!next.content) {
i++
}
}
continue continue
} }
@ -521,6 +533,7 @@ const tokenSpecs = {
getAttrs: (tok) => ({ getAttrs: (tok) => ({
href: tok.attrGet('href'), href: tok.attrGet('href'),
title: tok.attrGet('title') || null, title: tok.attrGet('title') || null,
wikilink: tok.meta?.wikilink ?? null,
mdAttrs: readMdAttrs({ attrs: tok.meta?.props }) mdAttrs: readMdAttrs({ attrs: tok.meta?.props })
}) })
}, },

@ -543,9 +543,12 @@ export const schema = new Schema({
* `target="_blank"` is the one that matters and the reason this carries attributes at all: it is * `target="_blank"` is the one that matters and the reason this carries attributes at all: it is
* how both editors write "open in a new tab", and a link mark that could not hold it would drop it * how both editors write "open in a new tab", and a link mark that could not hold it would drop it
* from every page that had one, the first time the page was opened here and saved. * from every page that had one, the first time the page was opened here and saved.
*
* `wikilink` is the target of a `[[Page Name]]` link as it was written, and null for every other
* kind. It is what the serialiser writes the link back out as; the href is derived from it.
*/ */
link: { link: {
attrs: { href: {}, title: { default: null }, ...mdAttrs }, attrs: { href: {}, title: { default: null }, wikilink: { default: null }, ...mdAttrs },
inclusive: false, inclusive: false,
parseDOM: [ parseDOM: [
{ {

@ -355,12 +355,27 @@ const marks = {
*/ */
abbr: { open: '', close: '', mixable: true }, abbr: { open: '', close: '', mixable: true },
/**
* A link, written back in whichever syntax it arrived in.
*
* A wikilink whose text is still exactly its target is `[[Target]]`; one whose text differs, or
* carries any other formatting, is `[[Target|text]]`. The piped form is also what a run that is
* partly bold needs, since the bare form's text is a name and is not parsed as markdown. A title has
* nowhere to go in either, so a wikilink that gained one is written as an ordinary link.
*/
link: { link: {
open: '[', open: (_state, mark, parent, index) => {
if (!mark.attrs.wikilink || mark.attrs.title) {
return '['
}
return isBareWikiLink(mark, parent, index) ? '[[' : `[[${mark.attrs.wikilink}|`
},
close: (_state, mark) => close: (_state, mark) =>
`](${mark.attrs.href.replace(/[()"]/g, '\\$&')}${ mark.attrs.wikilink && !mark.attrs.title
mark.attrs.title ? ` "${mark.attrs.title.replace(/"/g, '\\"')}"` : '' ? `]]${writeMdAttrs(mark.attrs.mdAttrs)}`
})${writeMdAttrs(mark.attrs.mdAttrs)}`, : `](${mark.attrs.href.replace(/[()"]/g, '\\$&')}${
mark.attrs.title ? ` "${mark.attrs.title.replace(/"/g, '\\"')}"` : ''
})${writeMdAttrs(mark.attrs.mdAttrs)}`,
mixable: true mixable: true
}, },
@ -371,6 +386,25 @@ const marks = {
} }
} }
/**
* Whether a wikilink can be written as `[[Target]]`: its run of the paragraph is nothing but text
* that says exactly what the target says, with no mark on it besides the link itself.
*/
function isBareWikiLink(mark, parent, index) {
let text = ''
for (let i = index; i < parent.childCount; i++) {
const child = parent.child(i)
if (!mark.isInSet(child.marks)) {
break
}
if (!child.isText || child.marks.length > 1) {
return false
}
text += child.text
}
return text === mark.attrs.wikilink
}
/** A run of backticks long enough to delimit a code span containing backticks of its own. */ /** A run of backticks long enough to delimit a code span containing backticks of its own. */
function backticksFor(node, side) { function backticksFor(node, side) {
const pattern = /`+/g const pattern = /`+/g

@ -332,6 +332,7 @@
:nodes="pageStore.toc" :nodes="pageStore.toc"
:min-depth="pageStore.tocDepth.min" :min-depth="pageStore.tocDepth.min"
:max-depth="pageStore.tocDepth.max" :max-depth="pageStore.tocDepth.max"
:joined="tocJoinsArticleEdge"
v-model:selected="state.tocSelected" /> v-model:selected="state.tocSelected" />
</div> </div>
</template> </template>
@ -689,6 +690,15 @@ const tocPanelIsOpen = computed(() => tocIsPanel.value && showSidebar.value && s
*/ */
const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value && !state.tocPanelOpen) const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value && !state.tocPanelOpen)
/*
Whether the contents rail runs out to meet the article column's right-hand edge (`.page-article-col`),
which is only beside it while the contents are a column to the RIGHT of the article. On the left the
edge is on the article's far side, and the panel has a shadow rather than an edge to meet.
*/
const tocJoinsArticleEdge = computed(
() => !tocIsPanel.value && siteStore.theme.tocPosition !== 'left'
)
/** /**
* Whether the page on screen is a blog's front page, which is drawn as its posts rather than as an * Whether the page on screen is a blog's front page, which is drawn as its posts rather than as an
* article -- see `PageBlog.vue`. A blog POST is an ordinary page and is not this. * article -- see `PageBlog.vue`. A blog POST is an ordinary page and is not this.
@ -1440,17 +1450,20 @@ function goBack() {
<style lang="scss"> <style lang="scss">
/* /*
The Tags heading's edit toggle. `visibility` is transitioned alongside the opacity so it still fades The Last Edited By link, set as a top-level entry in the contents above it (`.page-toc-item--d0`):
BOTH ways: as a discrete property it flips at the end of the transition when going to hidden, and at its size and its ink. The ink is stated rather than inherited, since the column declares no text
the start when coming back, which is exactly the timing a fade wants. colour of its own and on the dark sidebar an inherited one came out black.
*/ */
.page-last-editor { .page-last-editor {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
color: inherit; color: $grey-9;
text-decoration: none; text-decoration: none;
/* -> The size of a top-level entry in the contents above it (`.page-toc-item--d0`) */ @at-root .body--dark & {
color: rgba(255, 255, 255, 0.87);
}
> span { > span {
font-size: 0.8125rem; font-size: 0.8125rem;
} }
@ -1460,6 +1473,11 @@ function goBack() {
} }
} }
/*
The Tags heading's edit toggle. `visibility` is transitioned alongside the opacity so it still fades
BOTH ways: as a discrete property it flips at the end of the transition when going to hidden, and at
the start when coming back, which is exactly the timing a fade wants.
*/
.tags-edit-btn { .tags-edit-btn {
transition: transition:
opacity 0.2s var(--ease-standard), opacity 0.2s var(--ease-standard),

@ -15,6 +15,7 @@ import mdMdc from 'markdown-it-mdc'
import mdUnderline from './modules/markdown-it-underline' import mdUnderline from './modules/markdown-it-underline'
import mdImsize from './modules/markdown-it-imsize' import mdImsize from './modules/markdown-it-imsize'
import mdGithubAlerts from './modules/github-alerts' import mdGithubAlerts from './modules/github-alerts'
import mdWikiLinks from './modules/markdown-it-wikilinks'
import twemoji from '@twemoji/api' import twemoji from '@twemoji/api'
// -> Relative, like this file's other in-repo imports: it is also reachable from the headless // -> Relative, like this file's other in-repo imports: it is also reachable from the headless
@ -213,6 +214,17 @@ export class MarkdownRenderer {
plugin looks for one. The trailing space is what keeps `[x]{.cls}` a span, since a marker cannot plugin looks for one. The trailing space is what keeps `[x]{.cls}` a span, since a marker cannot
be followed by a brace. be followed by a brace.
And it never answers a probe. Silent mode is only ever `skipToken`, which is how markdown-it
measures a link's label -- stepping over everything in it -- and MDC's rule answered yes there
while leaving `state.pos` where it was. A rule that claims a token has to move past it, and
markdown-it throws when one does not, so any link whose text held brackets (`[[1]](https://…)`,
the way citations are written) took the whole render down with `inline rule didn't increment
state.pos`, freezing the editor's preview on the last good render. Moving past the span would
not be the fix either: `parseLinkLabel` reads anything longer than one character that opens with
`[` as a link inside the link, and refuses the outer one. A span is not a link, so the honest
answer is no -- the bracket is then counted as plain text, as it is without MDC, and the label is
tokenized for real afterwards, span and all.
Reaching into `__rules__` is the only way to get hold of the original: markdown-it can replace a Reaching into `__rules__` is the only way to get hold of the original: markdown-it can replace a
rule by name but has no way to read one back out. rule by name but has no way to read one back out.
*/ */
@ -225,7 +237,10 @@ export class MarkdownRenderer {
if (state.pos === 0 && TASK_LIST_MARKER.test(state.src)) { if (state.pos === 0 && TASK_LIST_MARKER.test(state.src)) {
return false return false
} }
return inlineSpan(state, silent) if (silent) {
return false
}
return inlineSpan(state, false)
}) })
/* /*
@ -274,6 +289,11 @@ export class MarkdownRenderer {
this.md.use(mdUnderline) this.md.use(mdUnderline)
} }
// -> MediaWiki's `[[Page Name]]` / `[[Page Name|text]]` -- see the module for where they point
if (config.wikiLinks) {
this.md.use(mdWikiLinks)
}
/* /*
MultiMarkdown tables: multi-line cells, `^^` rowspans, and a table with no header row. MultiMarkdown tables: multi-line cells, `^^` rowspans, and a table with no header row.

@ -0,0 +1,168 @@
// -> Relative, like the renderers' other in-repo imports: this module is reachable from the headless
// renderer bundle, which is built on its own
import { normalizePagePath } from '../../helpers/pagePaths'
/**
* MediaWiki's link syntax: `[[Page Name]]`, `[[Page Name|shown text]]`, `[[Page Name#Section]]` and
* `[[#Section]]`.
*
* A wikilink is a link to a page by its NAME, so the target is turned into the path that name would
* be filed under -- `normalizePagePath`, the same spelling every path field in the app corrects to:
* spaces become dashes and everything is lowercased. `[[Getting Started]]` is `/getting-started`, and
* `[[Guides/Getting Started]]` is `/guides/getting-started`.
*
* Always from the root, as in MediaWiki, and never relative to the page it is written on. A site that
* brackets its URLs by locale reads an unprefixed path as its primary locale, which is where these
* land.
*
* The section is spelled the way `slugifyHeading` in `backend/models/rendering.ts` spells the id it
* gives a heading, so `[[Page#Some Heading]]` finds the heading called "Some Heading". That makes this
* one more copy of that rule, and the two have to agree.
*
* The tokens are an ordinary `link_open` / `link_close` pair, so everything that already reads links
* reads these too -- the external-link class, the server's backlinks, the Visual editor. The target
* as written is kept on the opening token's `meta.wikilink`, which is how the Visual editor knows to
* write the link back in this form rather than as `[text](/path)`.
*/
/**
* A heading as an anchor fragment. Mirrors `slugifyHeading` in `backend/models/rendering.ts`.
*/
function slugifySection(text) {
return (
text
.toLowerCase()
.trim()
.replaceAll(/[^\p{L}\p{N}\s-]/gu, '')
.replaceAll(/\s+/g, '-')
.replaceAll(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100) || 'section'
)
}
/**
* Where a wikilink target points, as an href.
*
* @param {string} target The target as written, backslash escapes already resolved.
* @returns {string} A root-relative path, optionally with a fragment; or the fragment alone for a
* link to a section of the page it is written on.
*/
export function wikiLinkHref(target) {
const hash = target.indexOf('#')
const page = hash < 0 ? target : target.slice(0, hash)
const section = hash < 0 ? '' : target.slice(hash + 1)
const fragment = section.trim() ? `#${slugifySection(section)}` : ''
if (!page.trim()) {
return fragment || '/'
}
return `/${normalizePagePath(page)}${fragment}`
}
/**
* What a target may not contain. MediaWiki refuses the same characters in a title, and every one of
* them is something else to the parser here: `|` is the label, `[` / `]` the brackets, `{` / `}` MDC
* and `markdown-it-attrs`, `<` / `>` HTML.
*/
const INVALID_TARGET = /[[\]{}<>|\n]/
/** A target that is already a URL, `[[https://…]]`, which is not a page name. */
const URL_TARGET = /^[a-z][a-z\d+.-]*:\/\//i
/**
* Find the `]]` that closes a wikilink opened at `start`, skipping backslash escapes.
*
* @returns {{ pipe: number, end: number } | null} The position of the first `|` (or -1) and of the
* closing `]]`, or null when there is none before the end of what is being tokenized.
*/
function scan(src, start, max) {
let pipe = -1
for (let pos = start; pos < max; pos++) {
const ch = src.charCodeAt(pos)
if (ch === 0x5c /* \ */) {
pos++
} else if (ch === 0x0a /* \n */) {
return null
} else if (ch === 0x7c /* | */ && pipe < 0) {
pipe = pos
} else if (ch === 0x5d /* ] */ && src.charCodeAt(pos + 1) === 0x5d && pos + 1 < max) {
return { pipe, end: pos }
}
}
return null
}
function wikilink(state, silent) {
const { src, posMax } = state
const start = state.pos
if (src.charCodeAt(start) !== 0x5b /* [ */ || src.charCodeAt(start + 1) !== 0x5b) {
return false
}
// -> A link inside a link is not markup, as with every other link syntax
if (state.linkLevel > 0) {
return false
}
const found = scan(src, start + 2, posMax)
if (!found) {
return false
}
// -> `[[1]](https://…)` is an ordinary link whose text happens to be `[1]`, the way citations are
// often written, and `[[x]][ref]` the same by reference
const next = src.charCodeAt(found.end + 2)
if (next === 0x28 /* ( */ || next === 0x5b /* [ */) {
return false
}
const targetEnd = found.pipe < 0 ? found.end : found.pipe
const rawTarget = src.slice(start + 2, targetEnd)
const target = state.md.utils.unescapeAll(rawTarget).trim()
if (!target || INVALID_TARGET.test(target) || URL_TARGET.test(target)) {
return false
}
const href = state.md.normalizeLink(wikiLinkHref(target))
if (!state.md.validateLink(href)) {
return false
}
const labelStart = found.pipe < 0 ? -1 : found.pipe + 1
// -> `[[Page|]]` has nothing to show, and is shown as the page name rather than as nothing
const hasLabel = labelStart >= 0 && src.slice(labelStart, found.end).trim().length > 0
if (!silent) {
const open = state.push('link_open', 'a', 1)
open.attrs = [['href', href]]
open.markup = 'wikilink'
open.meta = { wikilink: target }
if (hasLabel) {
// -> The label is markdown, as a link's text is: `[[Page|**bold** text]]`
const oldPosMax = state.posMax
state.pos = labelStart
state.posMax = found.end
state.linkLevel++
state.md.inline.tokenize(state)
state.linkLevel--
state.posMax = oldPosMax
} else {
// -> The target alone is shown as written, and is not parsed: it is a name, not markup
const text = state.push('text', '', 0)
text.content = target
}
const close = state.push('link_close', 'a', -1)
close.markup = 'wikilink'
}
state.pos = found.end + 2
return true
}
export default (md) => {
/*
Ahead of MDC's inline span, which claims every `[` it meets and would otherwise read `[[Page]]` as
a span holding `[Page]`. That rule is registered before `link`, so this is ahead of both.
*/
md.inline.ruler.before('mdc_inline_span', 'wikilink', wikilink)
}
Loading…
Cancel
Save