Skip to content

Commit f64d643

Browse files
committed
feat: wikilinks syntax + more compact ToC rail
1 parent 7ca0c30 commit f64d643

14 files changed

Lines changed: 432 additions & 28 deletions

File tree

‎backend/base.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ editors:
224224
lineBreaks: true
225225
typographer: false
226226
underline: false
227+
wikiLinks: true
227228
tabWidth: 2
228229
latexEngine: katex
229230
kroki: true

‎backend/locales/en.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,8 @@
437437
"admin.editors.markdown.typographerHint": "Enable some language-neutral replacement + quotes beautification.",
438438
"admin.editors.markdown.underline": "Underline Emphasis",
439439
"admin.editors.markdown.underlineHint": "Enable text underlining by using _underline_ syntax.",
440+
"admin.editors.markdown.wikiLinks": "Wikilinks",
441+
"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.",
440442
"admin.editors.markdownDescription": "Use the Markdown syntax to write content. Includes real-time preview and code completion features.",
441443
"admin.editors.markdownName": "Markdown Editor",
442444
"admin.editors.redirectDescription": "Create redirections to other pages / external links.",

‎backend/models/sites.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ class Sites {
231231
quotes: 'english',
232232
tabWidth: 2,
233233
typographer: false,
234-
underline: true
234+
underline: true,
235+
wikiLinks: true
235236
}
236237
},
237238
/*
@@ -526,7 +527,8 @@ class Sites {
526527
quotes: 'english',
527528
tabWidth: 2,
528529
typographer: false,
529-
underline: true
530+
underline: true,
531+
wikiLinks: true
530532
}
531533
},
532534
/*

‎frontend/scripts/check-visual-roundtrip.mjs‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,20 @@ const VERBOSE = process.argv.includes('--verbose')
7979
* an editor that normalised it to `*x*` would be changing the page rather than reformatting it.
8080
*/
8181
const CONFIGS = {
82-
default: { allowHTML: true, linkify: true, lineBreaks: true, multimdTable: true },
82+
default: {
83+
allowHTML: true,
84+
linkify: true,
85+
lineBreaks: true,
86+
multimdTable: true,
87+
wikiLinks: true
88+
},
8389
underline: {
8490
allowHTML: true,
8591
linkify: true,
8692
lineBreaks: true,
8793
multimdTable: true,
88-
underline: true
94+
underline: true,
95+
wikiLinks: true
8996
},
9097
plain: {
9198
allowHTML: true,
@@ -111,6 +118,10 @@ const CONSTRUCTS = {
111118
*/
112119
'link opening a new tab': 'A [new tab](https://example.com){target="_blank"} here.',
113120
'link with id and class': 'A [classed](https://example.com){#x .cls} here.',
121+
// -> A citation written as a link, which crashed the renderer before MDC's span stopped claiming
122+
// the brackets inside it
123+
'link whose text is bracketed': 'See [[1]](https://example.com) here.',
124+
'link with a span in its text': 'A [text with [a span]{.x} in it](https://example.com) here.',
114125
'link with a title and a target': 'A [both](https://example.com "Tip"){target="_blank"} here.',
115126
'image with size': '![alt](pic.png =100x200)',
116127
// -> 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 = {
123134
'ordered list with parens': '1) one\n2) two',
124135
'loose list': '- one\n\n- two',
125136
'task list': '- [x] done\n- [ ] todo',
137+
'task starting with a link': '- [ ] [a link](/somewhere) to do',
126138
blockquote: '> quoted\n>\n> second',
127139
alert: '> [!WARNING] Mind the gap\n> Body of the alert.',
128140
'alert without a title': '> [!NOTE]\n> Body of the note.',
@@ -154,6 +166,18 @@ const CONSTRUCTS = {
154166
'code containing backticks': '``a ` b``'
155167
}
156168

169+
/**
170+
* Only under a config that turns `wikiLinks` on. With it off, `[[x]]` is two of MDC's inline spans one
171+
* inside the other, which is a different construct and not what these are here to check.
172+
*/
173+
const WIKILINK_CONSTRUCTS = {
174+
wikilink: 'See [[Getting Started]] and [[Guides/Setup Guide|the setup guide]].',
175+
'wikilink to a section': 'See [[Page Name#Some Heading]] and [[#Local Section]].',
176+
'wikilink with formatted text': 'See [[Some Page|**bold** and *italic*]] here.',
177+
'wikilink with escapes': 'See [[Star\\* Page]] here.',
178+
'wikilink opening a new tab': 'See [[Some Page]]{target="_blank"} here.'
179+
}
180+
157181
/**
158182
* A render reduced to what it says.
159183
*
@@ -182,6 +206,27 @@ function check(name, source, config) {
182206
} catch (err) {
183207
return { ok: false, why: `threw while round-tripping: ${err.message}` }
184208
}
209+
/*
210+
And a second save must change nothing at all. The render comparison above cannot see a source that
211+
only drifts in its whitespace -- it collapses whitespace on purpose -- and that is exactly the
212+
failure that grows: a task item gained one more space after its checkbox on every save.
213+
*/
214+
let again
215+
try {
216+
again = serialize(parser.parse(rewritten, 'demo/page'))
217+
} catch (err) {
218+
return { ok: false, why: `threw on a second round trip: ${err.message}`, rewritten }
219+
}
220+
if (again !== rewritten) {
221+
return {
222+
ok: false,
223+
why: 'changes again on a second round trip',
224+
rewritten,
225+
before: rewritten,
226+
after: again
227+
}
228+
}
229+
185230
const before = meaningOf(render(source))
186231
const after = meaningOf(render(rewritten))
187232
if (before === after) {
@@ -206,6 +251,9 @@ let checked = 0
206251

207252
for (const [configName, config] of Object.entries(CONFIGS)) {
208253
const cases = Object.entries(CONSTRUCTS)
254+
if (config.wikiLinks) {
255+
cases.push(...Object.entries(WIKILINK_CONSTRUCTS))
256+
}
209257
for (const page of SAMPLE_PAGES) {
210258
if (page.content?.trim()) {
211259
cases.push([`sample page: ${page.title ?? page.path}`, page.content])

‎frontend/src/components/EditorMarkdownConfigOverlay.vue‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,22 @@
186186
:aria-label="t(`admin.editors.markdown.underline`)" />
187187
</w-item-section>
188188
</w-item>
189+
<w-separator class="my-2" inset />
190+
<w-item tag="label">
191+
<blueprint-icon icon="tree-structure" />
192+
<w-item-section>
193+
<w-item-label>{{t(`admin.editors.markdown.wikiLinks`)}}</w-item-label>
194+
<w-item-label caption>{{t(`admin.editors.markdown.wikiLinksHint`)}}</w-item-label>
195+
</w-item-section>
196+
<w-item-section avatar>
197+
<w-toggle
198+
v-model="state.config.wikiLinks"
199+
color="primary"
200+
checked-icon="la:check"
201+
unchecked-icon="la:times"
202+
:aria-label="t(`admin.editors.markdown.wikiLinks`)" />
203+
</w-item-section>
204+
</w-item>
189205
</w-card>
190206
<w-inner-loading :showing="state.loading > 0">
191207
<w-spinner color="accent" size="lg" />
@@ -233,7 +249,8 @@ function defaultConfig() {
233249
quotes: 'english',
234250
underline: true,
235251
tabWidth: 2,
236-
multimdTable: true
252+
multimdTable: true,
253+
wikiLinks: true
237254
}
238255
}
239256

‎frontend/src/components/PageToc.vue‎

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<nav class="page-toc" aria-label="Table of contents">
2+
<nav class="page-toc" :class="{ 'page-toc--joined': joined }" aria-label="Table of contents">
33
<ul
44
class="page-toc-list"
55
:class="{ 'page-toc-list--animated': markerAnimated }"
@@ -69,6 +69,15 @@ const props = defineProps({
6969
selected: {
7070
type: String,
7171
default: null
72+
},
73+
/**
74+
* Whether the rail turns out to the left at each end to meet a border drawn down the column's left
75+
* edge, rather than stopping level with the first and last label. Only the caller knows whether
76+
* there is such a border to meet, and how far off it is: see `--page-toc-reach` in the stylesheet.
77+
*/
78+
joined: {
79+
type: Boolean,
80+
default: false
7281
}
7382
})
7483
@@ -333,6 +342,23 @@ onBeforeUnmount(() => {
333342
--page-toc-ink-soft: #{$grey-6};
334343
--page-toc-ink-hover: #{$grey-10};
335344
--page-toc-hover-surface: rgba(0, 0, 0, 0.04);
345+
/*
346+
The space between the rail and a top-level label. The list is pulled left by the same amount, so
347+
the labels start on this component's own left edge -- level with whatever else the caller lines up
348+
there -- and the rail hangs out into the caller's padding.
349+
*/
350+
--page-toc-gutter: 9px;
351+
/* How far left of the rail the border a joined rail meets is: what is left of the caller's `px-4` */
352+
--page-toc-reach: calc(1rem - var(--page-toc-gutter));
353+
/* The radius of a joined rail's two turns, a little inside the reach so each keeps a short straight */
354+
--page-toc-turn: 6px;
355+
/* The active marker's thickness, measured left from the rail's right-hand edge */
356+
--page-toc-marker-w: 4px;
357+
/*
358+
What a joined rail encloses between itself and the border: half the article's own white, so the
359+
strip reads as the edge of the page reaching into the column rather than as a gap in it.
360+
*/
361+
--page-toc-reach-fill: rgba(255, 255, 255, 0.5);
336362
337363
line-height: 1.4;
338364
@@ -343,11 +369,13 @@ onBeforeUnmount(() => {
343369
--page-toc-ink-soft: rgba(255, 255, 255, 0.45);
344370
--page-toc-ink-hover: #fff;
345371
--page-toc-hover-surface: rgba(255, 255, 255, 0.06);
372+
/* -> Half the article's dark ground, `$dark-6`, for the same reason: white would glare here */
373+
--page-toc-reach-fill: #{rgba($dark-6, 0.5)};
346374
}
347375
348376
&-list {
349377
position: relative;
350-
margin: 0;
378+
margin: 0 0 0 calc(-1 * var(--page-toc-gutter));
351379
padding: 0;
352380
list-style: none;
353381
@@ -363,15 +391,49 @@ onBeforeUnmount(() => {
363391
}
364392
}
365393
394+
/*
395+
The joined rail: in from the column's left border above the list, a quarter turn down, the rail,
396+
and a quarter turn back out to the border below it -- the same line round the contents that the
397+
history timeline draws round its entries.
398+
399+
As there, all three stretches are ONE border of ONE box -- the top, right and bottom edges of an
400+
invisible rectangle whose left edge is the column's -- so that the straights and the turns cannot
401+
come out at different thicknesses under fractional display scaling.
402+
403+
The turns sit outside the list, above and below it, so the whole height of the list is the
404+
straight stretch and the marker never rides onto a curve. The caller's padding is what they sit
405+
in. And it is drawn in the border's own colour rather than the rail's, since a line that changed
406+
shade where it met the border would read as two lines touching.
407+
*/
408+
&--joined &-list::before {
409+
/*
410+
How far above the list the line comes in is the caller's to say, as `--page-toc-lead`: it is
411+
wherever the line it continues is drawn, which nothing here can know. At least the turn, or the
412+
curve would run into the first label.
413+
*/
414+
top: calc(-1 * max(var(--page-toc-lead, 0px), var(--page-toc-turn)));
415+
bottom: calc(-1 * var(--page-toc-turn));
416+
left: calc(-1 * var(--page-toc-reach));
417+
box-sizing: border-box;
418+
/* -> Its right-hand border lands where the plain rail is, on the list's own left edge */
419+
width: calc(var(--page-toc-reach) + 1px);
420+
border: 1px solid var(--page-chrome-rule, var(--page-toc-rail));
421+
border-left: 0;
422+
border-radius: 0 var(--page-toc-turn) var(--page-toc-turn) 0;
423+
background-color: var(--page-toc-reach-fill);
424+
}
425+
366426
&-item {
367427
position: relative;
368428
/* Depth is carried as a custom property by the template, so one rule indents every level */
369429
padding-left: calc(var(--page-toc-depth) * var(--page-toc-indent));
370430
}
371431
372432
/*
373-
The active marker, drawn ON the rail rather than beside it: `left: 0` is the list's own border
374-
box, which is where the rail is, so every depth marks the same line whatever its indentation.
433+
The active marker, drawn ON the rail rather than beside it: the rail is the list's first pixel,
434+
so every depth marks the same line whatever its indentation. Its right edge is the rail's right
435+
edge, and the extra width grows out to the left -- toward the border a joined rail meets, and away
436+
from the labels, which then sit the same distance from it as from the rail.
375437
376438
One element for the whole list rather than a pseudo on the active row, so that moving to the next
377439
heading is a slide down the rail instead of the marker being switched off one row and on another.
@@ -382,10 +444,12 @@ onBeforeUnmount(() => {
382444
content: '';
383445
position: absolute;
384446
top: 0;
385-
left: 0;
386-
width: 2px;
447+
/* -> The rail is 1px wide from `left: 0` */
448+
left: calc(1px - var(--page-toc-marker-w));
449+
width: var(--page-toc-marker-w);
387450
height: var(--page-toc-marker-h, 0);
388-
border-radius: 1px;
451+
/* -> Square on the rail's side, so it sits flush along the line it marks */
452+
border-radius: calc(var(--page-toc-marker-w) / 2) 0 0 calc(var(--page-toc-marker-w) / 2);
389453
background-color: var(--color-primary);
390454
opacity: var(--page-toc-marker-opacity, 0);
391455
transform: translateY(var(--page-toc-marker-y, 0));
@@ -401,8 +465,8 @@ onBeforeUnmount(() => {
401465
402466
&-link {
403467
display: block;
404-
/* 9px of gutter, not a caret column: the rail is the only thing to the left of a label */
405-
padding: 3px 8px 3px 9px;
468+
/* A gutter, not a caret column: the rail is the only thing to the left of a label */
469+
padding: 3px 8px 3px var(--page-toc-gutter);
406470
border-radius: 4px;
407471
color: inherit;
408472
font-size: inherit;

‎frontend/src/css/_page-chrome.scss‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,16 @@ $toc-overlay-max: 749.98px;
390390
.page-sidebar {
391391
flex: 0 0 300px;
392392

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

‎frontend/src/editor/visual/links.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function findLink(state) {
7676
text: state.doc.textBetween(from, to, '', ''),
7777
href: mark.attrs.href,
7878
title: mark.attrs.title ?? '',
79+
wikilink: mark.attrs.wikilink,
7980
// -> Whatever `{…}` the link carries, so editing it does not drop a `target="_blank"`
8081
mdAttrs: mark.attrs.mdAttrs,
8182
// -> 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 }) {
105106
const mark = type.create({
106107
href,
107108
title: title || null,
109+
// -> Still a wikilink while it still goes where its target says; a new address makes it an
110+
// ordinary link, since the target is what the href would be derived from
111+
wikilink: href === range.href ? (range.wikilink ?? null) : null,
108112
mdAttrs: Object.keys(mdAttrs).length > 0 ? mdAttrs : null
109113
})
110114
const tr = view.state.tr

‎frontend/src/editor/visual/parse.js‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,23 @@ function normalizeInline(children, config) {
202202
its text, and the item itself carries whether it is ticked, so there is nothing here for it to
203203
contribute.
204204
205-
The plugin slices the `[x] ` off the text itself, marker and space together, so nothing is left
206-
to trim after it — see the guard in `renderers/markdown.js` that keeps MDC's inline span off
207-
that marker, without which it silently sliced nothing and left the marker in the page.
205+
The plugin slices three characters off the text after it -- the `[x]` and NOT the space that
206+
follows, which it leaves in so the page draws a gap between the box and the words. That space
207+
is the serialiser's to write, since it goes out with the marker, so it is taken off the text
208+
here: left on, every save wrote `- [ ] ` and then the text's own leading space after it, and
209+
the item gained another space each time the page was saved. See also the guard in
210+
`renderers/markdown.js` that keeps MDC's inline span off that marker, without which it silently
211+
sliced nothing and left the marker in the page.
208212
*/
209213
if (tok.type === 'html_inline' && tok.content.includes('task-list-item-checkbox')) {
214+
const next = children[i + 1]
215+
if (next?.type === 'text') {
216+
next.content = next.content.trimStart()
217+
// -> An item that starts with a link or an emoji has nothing else in that token
218+
if (!next.content) {
219+
i++
220+
}
221+
}
210222
continue
211223
}
212224

@@ -521,6 +533,7 @@ const tokenSpecs = {
521533
getAttrs: (tok) => ({
522534
href: tok.attrGet('href'),
523535
title: tok.attrGet('title') || null,
536+
wikilink: tok.meta?.wikilink ?? null,
524537
mdAttrs: readMdAttrs({ attrs: tok.meta?.props })
525538
})
526539
},

‎frontend/src/editor/visual/schema.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,12 @@ export const schema = new Schema({
543543
* `target="_blank"` is the one that matters and the reason this carries attributes at all: it is
544544
* how both editors write "open in a new tab", and a link mark that could not hold it would drop it
545545
* from every page that had one, the first time the page was opened here and saved.
546+
*
547+
* `wikilink` is the target of a `[[Page Name]]` link as it was written, and null for every other
548+
* kind. It is what the serialiser writes the link back out as; the href is derived from it.
546549
*/
547550
link: {
548-
attrs: { href: {}, title: { default: null }, ...mdAttrs },
551+
attrs: { href: {}, title: { default: null }, wikilink: { default: null }, ...mdAttrs },
549552
inclusive: false,
550553
parseDOM: [
551554
{

0 commit comments

Comments
 (0)