mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 02:21:48 +00:00
feat: wikilinks syntax + more compact ToC rail
This commit is contained in:
@@ -224,6 +224,7 @@ editors:
|
||||
lineBreaks: true
|
||||
typographer: false
|
||||
underline: false
|
||||
wikiLinks: true
|
||||
tabWidth: 2
|
||||
latexEngine: katex
|
||||
kroki: true
|
||||
|
||||
@@ -437,6 +437,8 @@
|
||||
"admin.editors.markdown.typographerHint": "Enable some language-neutral replacement + quotes beautification.",
|
||||
"admin.editors.markdown.underline": "Underline Emphasis",
|
||||
"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.markdownName": "Markdown Editor",
|
||||
"admin.editors.redirectDescription": "Create redirections to other pages / external links.",
|
||||
|
||||
@@ -231,7 +231,8 @@ class Sites {
|
||||
quotes: 'english',
|
||||
tabWidth: 2,
|
||||
typographer: false,
|
||||
underline: true
|
||||
underline: true,
|
||||
wikiLinks: true
|
||||
}
|
||||
},
|
||||
/*
|
||||
@@ -526,7 +527,8 @@ class Sites {
|
||||
quotes: 'english',
|
||||
tabWidth: 2,
|
||||
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.
|
||||
*/
|
||||
const CONFIGS = {
|
||||
default: { allowHTML: true, linkify: true, lineBreaks: true, multimdTable: true },
|
||||
default: {
|
||||
allowHTML: true,
|
||||
linkify: true,
|
||||
lineBreaks: true,
|
||||
multimdTable: true,
|
||||
wikiLinks: true
|
||||
},
|
||||
underline: {
|
||||
allowHTML: true,
|
||||
linkify: true,
|
||||
lineBreaks: true,
|
||||
multimdTable: true,
|
||||
underline: true
|
||||
underline: true,
|
||||
wikiLinks: true
|
||||
},
|
||||
plain: {
|
||||
allowHTML: true,
|
||||
@@ -111,6 +118,10 @@ const CONSTRUCTS = {
|
||||
*/
|
||||
'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.',
|
||||
// -> 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.',
|
||||
'image with size': '',
|
||||
// -> 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',
|
||||
'loose list': '- one\n\n- two',
|
||||
'task list': '- [x] done\n- [ ] todo',
|
||||
'task starting with a link': '- [ ] [a link](/somewhere) to do',
|
||||
blockquote: '> quoted\n>\n> second',
|
||||
alert: '> [!WARNING] Mind the gap\n> Body of the alert.',
|
||||
'alert without a title': '> [!NOTE]\n> Body of the note.',
|
||||
@@ -154,6 +166,18 @@ const CONSTRUCTS = {
|
||||
'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.
|
||||
*
|
||||
@@ -182,6 +206,27 @@ function check(name, source, config) {
|
||||
} catch (err) {
|
||||
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 after = meaningOf(render(rewritten))
|
||||
if (before === after) {
|
||||
@@ -206,6 +251,9 @@ let checked = 0
|
||||
|
||||
for (const [configName, config] of Object.entries(CONFIGS)) {
|
||||
const cases = Object.entries(CONSTRUCTS)
|
||||
if (config.wikiLinks) {
|
||||
cases.push(...Object.entries(WIKILINK_CONSTRUCTS))
|
||||
}
|
||||
for (const page of SAMPLE_PAGES) {
|
||||
if (page.content?.trim()) {
|
||||
cases.push([`sample page: ${page.title ?? page.path}`, page.content])
|
||||
|
||||
@@ -186,6 +186,22 @@
|
||||
:aria-label="t(`admin.editors.markdown.underline`)" />
|
||||
</w-item-section>
|
||||
</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-inner-loading :showing="state.loading > 0">
|
||||
<w-spinner color="accent" size="lg" />
|
||||
@@ -233,7 +249,8 @@ function defaultConfig() {
|
||||
quotes: 'english',
|
||||
underline: true,
|
||||
tabWidth: 2,
|
||||
multimdTable: true
|
||||
multimdTable: true,
|
||||
wikiLinks: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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
|
||||
class="page-toc-list"
|
||||
:class="{ 'page-toc-list--animated': markerAnimated }"
|
||||
@@ -69,6 +69,15 @@ const props = defineProps({
|
||||
selected: {
|
||||
type: String,
|
||||
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-hover: #{$grey-10};
|
||||
--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;
|
||||
|
||||
@@ -343,11 +369,13 @@ onBeforeUnmount(() => {
|
||||
--page-toc-ink-soft: rgba(255, 255, 255, 0.45);
|
||||
--page-toc-ink-hover: #fff;
|
||||
--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 {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
margin: 0 0 0 calc(-1 * var(--page-toc-gutter));
|
||||
padding: 0;
|
||||
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 {
|
||||
position: relative;
|
||||
/* 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
|
||||
box, which is where the rail is, so every depth marks the same line whatever its indentation.
|
||||
The active marker, drawn ON the rail rather than beside it: the rail is the list's first pixel,
|
||||
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
|
||||
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: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
/* -> The rail is 1px wide from `left: 0` */
|
||||
left: calc(1px - var(--page-toc-marker-w));
|
||||
width: var(--page-toc-marker-w);
|
||||
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);
|
||||
opacity: var(--page-toc-marker-opacity, 0);
|
||||
transform: translateY(var(--page-toc-marker-y, 0));
|
||||
@@ -401,8 +465,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
&-link {
|
||||
display: block;
|
||||
/* 9px of gutter, not a caret column: the rail is the only thing to the left of a label */
|
||||
padding: 3px 8px 3px 9px;
|
||||
/* A gutter, not a caret column: the rail is the only thing to the left of a label */
|
||||
padding: 3px 8px 3px var(--page-toc-gutter);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
@@ -390,6 +390,16 @@ $toc-overlay-max: 749.98px;
|
||||
.page-sidebar {
|
||||
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
|
||||
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, '', ''),
|
||||
href: mark.attrs.href,
|
||||
title: mark.attrs.title ?? '',
|
||||
wikilink: mark.attrs.wikilink,
|
||||
// -> Whatever `{…}` the link carries, so editing it does not drop a `target="_blank"`
|
||||
mdAttrs: mark.attrs.mdAttrs,
|
||||
// -> 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({
|
||||
href,
|
||||
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
|
||||
})
|
||||
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
|
||||
contribute.
|
||||
|
||||
The plugin slices the `[x] ` off the text itself, marker and space together, so nothing is left
|
||||
to trim after it — see 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.
|
||||
The plugin slices three characters off the text after it -- the `[x]` and NOT the space that
|
||||
follows, which it leaves in so the page draws a gap between the box and the words. That space
|
||||
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')) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -521,6 +533,7 @@ const tokenSpecs = {
|
||||
getAttrs: (tok) => ({
|
||||
href: tok.attrGet('href'),
|
||||
title: tok.attrGet('title') || null,
|
||||
wikilink: tok.meta?.wikilink ?? null,
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* `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: {
|
||||
attrs: { href: {}, title: { default: null }, ...mdAttrs },
|
||||
attrs: { href: {}, title: { default: null }, wikilink: { default: null }, ...mdAttrs },
|
||||
inclusive: false,
|
||||
parseDOM: [
|
||||
{
|
||||
|
||||
@@ -355,12 +355,27 @@ const marks = {
|
||||
*/
|
||||
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: {
|
||||
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) =>
|
||||
`](${mark.attrs.href.replace(/[()"]/g, '\\$&')}${
|
||||
mark.attrs.title ? ` "${mark.attrs.title.replace(/"/g, '\\"')}"` : ''
|
||||
})${writeMdAttrs(mark.attrs.mdAttrs)}`,
|
||||
mark.attrs.wikilink && !mark.attrs.title
|
||||
? `]]${writeMdAttrs(mark.attrs.mdAttrs)}`
|
||||
: `](${mark.attrs.href.replace(/[()"]/g, '\\$&')}${
|
||||
mark.attrs.title ? ` "${mark.attrs.title.replace(/"/g, '\\"')}"` : ''
|
||||
})${writeMdAttrs(mark.attrs.mdAttrs)}`,
|
||||
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. */
|
||||
function backticksFor(node, side) {
|
||||
const pattern = /`+/g
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
:nodes="pageStore.toc"
|
||||
:min-depth="pageStore.tocDepth.min"
|
||||
:max-depth="pageStore.tocDepth.max"
|
||||
:joined="tocJoinsArticleEdge"
|
||||
v-model:selected="state.tocSelected" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -689,6 +690,15 @@ const tocPanelIsOpen = computed(() => tocIsPanel.value && showSidebar.value && s
|
||||
*/
|
||||
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
|
||||
* article -- see `PageBlog.vue`. A blog POST is an ordinary page and is not this.
|
||||
@@ -1440,17 +1450,20 @@ function goBack() {
|
||||
|
||||
<style lang="scss">
|
||||
/*
|
||||
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.
|
||||
The Last Edited By link, set as a top-level entry in the contents above it (`.page-toc-item--d0`):
|
||||
its size and its ink. The ink is stated rather than inherited, since the column declares no text
|
||||
colour of its own and on the dark sidebar an inherited one came out black.
|
||||
*/
|
||||
.page-last-editor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
color: $grey-9;
|
||||
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 {
|
||||
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 {
|
||||
transition:
|
||||
opacity 0.2s var(--ease-standard),
|
||||
|
||||
@@ -15,6 +15,7 @@ import mdMdc from 'markdown-it-mdc'
|
||||
import mdUnderline from './modules/markdown-it-underline'
|
||||
import mdImsize from './modules/markdown-it-imsize'
|
||||
import mdGithubAlerts from './modules/github-alerts'
|
||||
import mdWikiLinks from './modules/markdown-it-wikilinks'
|
||||
import twemoji from '@twemoji/api'
|
||||
|
||||
// -> 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
|
||||
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
|
||||
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)) {
|
||||
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)
|
||||
}
|
||||
|
||||
// -> 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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user