create-eziwiki 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/template/app/[...slug]/page.tsx +59 -3
- package/template/app/layout.tsx +3 -1
- package/template/components/graph/GraphView.tsx +20 -7
- package/template/components/layout/LocalGraph.tsx +52 -0
- package/template/components/layout/MobileMenu.tsx +16 -1
- package/template/components/layout/MovedPage.tsx +45 -0
- package/template/components/layout/PageLayout.tsx +11 -3
- package/template/components/layout/PageNavigation.tsx +73 -0
- package/template/components/layout/Sidebar.tsx +30 -2
- package/template/components/markdown/LinkPreview.tsx +164 -0
- package/template/components/markdown/MarkdownContent.tsx +3 -1
- package/template/lib/content/aliases.test.ts +76 -0
- package/template/lib/content/aliases.ts +114 -0
- package/template/lib/content/assets.ts +158 -0
- package/template/lib/content/excerpt.test.ts +68 -0
- package/template/lib/content/excerpt.ts +142 -0
- package/template/lib/content/registry.ts +31 -0
- package/template/lib/graph/build.ts +55 -0
- package/template/lib/graph/health.test.ts +60 -0
- package/template/lib/graph/health.ts +58 -0
- package/template/lib/markdown/callout.test.ts +87 -0
- package/template/lib/markdown/mermaid.test.ts +72 -0
- package/template/lib/markdown/rehype-mermaid.ts +133 -0
- package/template/lib/markdown/rehype-plugins.ts +50 -0
- package/template/lib/markdown/remark-callout.ts +173 -0
- package/template/lib/markdown/remark-wikilink.ts +201 -14
- package/template/lib/markdown/render.ts +125 -9
- package/template/lib/markdown/wikilink.test.ts +30 -0
- package/template/lib/markdown/wikilink.ts +12 -4
- package/template/lib/navigation/sequence.test.ts +73 -0
- package/template/lib/navigation/sequence.ts +100 -0
- package/template/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +7 -0
- package/template/package-lock.json +32 -83
- package/template/package.json +3 -0
- package/template/scripts/check-links.ts +60 -18
- package/template/styles/markdown.css +231 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { unified, type Processor } from 'unified';
|
|
2
|
+
import { VFile } from 'vfile';
|
|
3
|
+
import type { Root, RootContent } from 'mdast';
|
|
2
4
|
import remarkParse from 'remark-parse';
|
|
3
5
|
import remarkGfm from 'remark-gfm';
|
|
4
6
|
import remarkMath from 'remark-math';
|
|
@@ -12,17 +14,24 @@ import {
|
|
|
12
14
|
rehypeBasePath,
|
|
13
15
|
rehypeCodeShell,
|
|
14
16
|
rehypeCollectHeadings,
|
|
17
|
+
rehypeHeadingAnchors,
|
|
15
18
|
rehypeImages,
|
|
16
19
|
rehypeInternalLinks,
|
|
17
20
|
type Heading,
|
|
18
21
|
} from './rehype-plugins';
|
|
19
|
-
import { remarkWikiLinks, type WikiLinkTarget } from './remark-wikilink';
|
|
22
|
+
import { remarkWikiLinks, type WikiLinkTarget, type TranscludeTarget } from './remark-wikilink';
|
|
23
|
+
import { remarkCallouts } from './remark-callout';
|
|
24
|
+
import { rehypeMermaid } from './rehype-mermaid';
|
|
20
25
|
import { getUsedLanguages } from './languages';
|
|
21
26
|
import { cached } from '../cache';
|
|
22
27
|
import { BASE_PATH } from '../basePath';
|
|
23
28
|
import { getUrlMap } from '../navigation/urlMap';
|
|
24
29
|
import { getDoc } from '../content/registry';
|
|
25
30
|
import { resolveTarget } from '../content/resolver';
|
|
31
|
+
import { resolveAsset } from '../content/assets';
|
|
32
|
+
import { getExcerpt } from '../content/excerpt';
|
|
33
|
+
import GithubSlugger from 'github-slugger';
|
|
34
|
+
import { toString as mdastToString } from 'mdast-util-to-string';
|
|
26
35
|
import { docPathToUrl } from '../navigation/url';
|
|
27
36
|
|
|
28
37
|
/**
|
|
@@ -77,18 +86,107 @@ function resolveWikiLink(target: string): WikiLinkTarget | null {
|
|
|
77
86
|
if (!doc) return null;
|
|
78
87
|
|
|
79
88
|
const url = docPathToUrl(getUrlMap(), doc.path);
|
|
80
|
-
|
|
89
|
+
if (!url) return null;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
url: `/${url}/`,
|
|
93
|
+
title: doc.title,
|
|
94
|
+
excerpt: getExcerpt(doc.path),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolves an embed target to a file under `public/`.
|
|
100
|
+
*
|
|
101
|
+
* @param target - Raw target text from inside the brackets
|
|
102
|
+
* @returns The file's URL, or null when nothing matches
|
|
103
|
+
*/
|
|
104
|
+
function resolveWikiEmbed(target: string): { url: string } | null {
|
|
105
|
+
const asset = resolveAsset(target);
|
|
106
|
+
return asset ? { url: asset.url } : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parser for included documents; no rendering plugins, so it stays cheap. */
|
|
110
|
+
const transclusionParser = unified().use(remarkParse).use(remarkGfm).use(remarkMath);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Narrows a document's nodes to the section a heading names.
|
|
114
|
+
*
|
|
115
|
+
* The section runs from the matching heading to the next one at the same level
|
|
116
|
+
* or above, which is what a reader means by "that part of the page". The
|
|
117
|
+
* heading itself is kept: dropping it would leave the included text with no
|
|
118
|
+
* indication of what it is.
|
|
119
|
+
*
|
|
120
|
+
* @param nodes - The whole document's nodes
|
|
121
|
+
* @param anchor - Heading slug from after the `#`
|
|
122
|
+
* @returns The section's nodes, or null when no heading matches
|
|
123
|
+
*/
|
|
124
|
+
function sliceSection(nodes: RootContent[], anchor: string): RootContent[] | null {
|
|
125
|
+
const slugger = new GithubSlugger();
|
|
126
|
+
const wanted = anchor.toLowerCase();
|
|
127
|
+
|
|
128
|
+
let start = -1;
|
|
129
|
+
let depth = 0;
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
132
|
+
const node = nodes[i];
|
|
133
|
+
if (node.type !== 'heading') continue;
|
|
134
|
+
|
|
135
|
+
// Slugs are assigned in document order, and repeats are numbered, so every
|
|
136
|
+
// heading has to pass through the slugger even before the match is found.
|
|
137
|
+
const slug = slugger.slug(mdastToString(node));
|
|
138
|
+
|
|
139
|
+
if (start === -1) {
|
|
140
|
+
if (slug !== wanted) continue;
|
|
141
|
+
start = i;
|
|
142
|
+
depth = node.depth;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (node.depth <= depth) return nodes.slice(start, i);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return start === -1 ? null : nodes.slice(start);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolves an embed target to the document content it names.
|
|
154
|
+
*
|
|
155
|
+
* @param target - Raw target text from inside the brackets
|
|
156
|
+
* @param anchor - Heading slug, when the embed named a section
|
|
157
|
+
* @returns The document's nodes and identity, or null when it does not resolve
|
|
158
|
+
*/
|
|
159
|
+
function resolveWikiTransclusion(target: string, anchor?: string): TranscludeTarget | null {
|
|
160
|
+
const { doc } = resolveTarget(target);
|
|
161
|
+
if (!doc) return null;
|
|
162
|
+
|
|
163
|
+
const url = docPathToUrl(getUrlMap(), doc.path);
|
|
164
|
+
if (!url) return null;
|
|
165
|
+
|
|
166
|
+
const tree = transclusionParser.parse(doc.content) as Root;
|
|
167
|
+
const nodes = anchor ? sliceSection(tree.children, anchor) : tree.children;
|
|
168
|
+
if (!nodes || nodes.length === 0) return null;
|
|
169
|
+
|
|
170
|
+
return { path: doc.path, url: `/${url}/`, title: doc.title, nodes };
|
|
81
171
|
}
|
|
82
172
|
|
|
83
173
|
/**
|
|
84
174
|
* Builds the shared unified processor.
|
|
85
175
|
*
|
|
86
176
|
* Plugin order is load-bearing:
|
|
177
|
+
* - `remarkCallouts` must run before the wiki-link pass, so that a link written
|
|
178
|
+
* inside a callout is resolved like any other rather than being left in the
|
|
179
|
+
* text the marker was read from.
|
|
87
180
|
* - `remarkWikiLinks` must run while the tree is still Markdown, so the links
|
|
88
181
|
* it produces are processed like any other link downstream.
|
|
89
182
|
* - `rehype-raw` must follow `remark-rehype` with `allowDangerousHtml`, so that
|
|
90
183
|
* inline HTML in Markdown is parsed rather than escaped.
|
|
91
|
-
* - `rehype-slug` must precede heading collection
|
|
184
|
+
* - `rehype-slug` must precede heading collection and the heading anchors,
|
|
185
|
+
* both of which read the ids it adds.
|
|
186
|
+
* - `rehypeHeadingAnchors` must follow heading collection, or the anchor's own
|
|
187
|
+
* text would be gathered into the contents rail.
|
|
188
|
+
* - `rehypeMermaid` must precede both, so a diagram fence never becomes a code
|
|
189
|
+
* block, and one it cannot draw still does.
|
|
92
190
|
* - `rehypeCodeShell` must precede the highlighter, since it reads the
|
|
93
191
|
* `language-*` class that highlighting replaces.
|
|
94
192
|
* - `rehypeBasePath` runs last among the link plugins, so it prefixes the
|
|
@@ -99,14 +197,21 @@ function createProcessor(): Processor {
|
|
|
99
197
|
.use(remarkParse)
|
|
100
198
|
.use(remarkGfm)
|
|
101
199
|
.use(remarkMath)
|
|
102
|
-
.use(
|
|
200
|
+
.use(remarkCallouts)
|
|
201
|
+
.use(remarkWikiLinks, {
|
|
202
|
+
link: resolveWikiLink,
|
|
203
|
+
embed: resolveWikiEmbed,
|
|
204
|
+
transclude: resolveWikiTransclusion,
|
|
205
|
+
})
|
|
103
206
|
.use(remarkRehype, { allowDangerousHtml: true })
|
|
104
207
|
.use(rehypeRaw)
|
|
105
208
|
.use(rehypeSlug)
|
|
106
209
|
.use(rehypeCollectHeadings)
|
|
210
|
+
.use(rehypeHeadingAnchors)
|
|
107
211
|
.use(rehypeKatex)
|
|
108
212
|
.use(rehypeInternalLinks, getUrlMap())
|
|
109
213
|
.use(rehypeImages)
|
|
214
|
+
.use(rehypeMermaid)
|
|
110
215
|
.use(rehypeCodeShell)
|
|
111
216
|
.use(rehypeShiki, {
|
|
112
217
|
themes: SHIKI_THEMES,
|
|
@@ -150,6 +255,9 @@ function getProcessor(): Processor {
|
|
|
150
255
|
* Compiles a Markdown string to HTML and extracts its headings.
|
|
151
256
|
*
|
|
152
257
|
* @param markdown - Markdown source, with frontmatter already stripped
|
|
258
|
+
* @param docPath - Path of the document being rendered, when it has one; a
|
|
259
|
+
* transclusion naming it is refused rather than nesting a copy of the page
|
|
260
|
+
* inside itself
|
|
153
261
|
* @returns The rendered HTML and the headings found in it
|
|
154
262
|
*
|
|
155
263
|
* @example
|
|
@@ -158,12 +266,20 @@ function getProcessor(): Processor {
|
|
|
158
266
|
* headings; // [{ id: 'setup', text: 'Setup', depth: 2 }]
|
|
159
267
|
* ```
|
|
160
268
|
*/
|
|
161
|
-
export async function renderMarkdown(
|
|
162
|
-
|
|
269
|
+
export async function renderMarkdown(
|
|
270
|
+
markdown: string,
|
|
271
|
+
docPath?: string,
|
|
272
|
+
): Promise<RenderedMarkdown> {
|
|
273
|
+
const file = new VFile(markdown);
|
|
274
|
+
// Seeds the transclusion stack, so a document that includes itself is caught
|
|
275
|
+
// at the first step rather than after rendering one copy of itself.
|
|
276
|
+
if (docPath) file.data.docPath = docPath;
|
|
277
|
+
|
|
278
|
+
const processed = await getProcessor().process(file);
|
|
163
279
|
|
|
164
280
|
return {
|
|
165
|
-
html: String(
|
|
166
|
-
headings: (
|
|
281
|
+
html: String(processed),
|
|
282
|
+
headings: (processed.data.headings as Heading[] | undefined) ?? [],
|
|
167
283
|
};
|
|
168
284
|
}
|
|
169
285
|
|
|
@@ -186,7 +302,7 @@ export async function renderDoc(docPath: string): Promise<RenderedMarkdown | nul
|
|
|
186
302
|
const doc = getDoc(docPath);
|
|
187
303
|
if (!doc) return null;
|
|
188
304
|
|
|
189
|
-
const rendered = await renderMarkdown(doc.content);
|
|
305
|
+
const rendered = await renderMarkdown(doc.content, doc.path);
|
|
190
306
|
cache.set(docPath, rendered);
|
|
191
307
|
|
|
192
308
|
return rendered;
|
|
@@ -89,3 +89,33 @@ describe('findWikiLinks', () => {
|
|
|
89
89
|
expect(findWikiLinks('x [[a|B]] y')[0].raw).toBe('[[a|B]]');
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
describe('embeds', () => {
|
|
94
|
+
it('marks a leading ! as an embed', () => {
|
|
95
|
+
const [link] = findWikiLinks('![[diagram.png]]');
|
|
96
|
+
|
|
97
|
+
expect(link.embed).toBe(true);
|
|
98
|
+
expect(link.target).toBe('diagram.png');
|
|
99
|
+
expect(link.raw).toBe('![[diagram.png]]');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('leaves a plain link unmarked', () => {
|
|
103
|
+
const [link] = findWikiLinks('[[diagram.png]]');
|
|
104
|
+
|
|
105
|
+
expect(link.embed).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// The `!` has to be part of the match. Matching only the brackets would
|
|
109
|
+
// leave it behind as literal text in front of the rendered node.
|
|
110
|
+
it('consumes the ! rather than leaving it in the text', () => {
|
|
111
|
+
const [link] = findWikiLinks('before ![[a]] after');
|
|
112
|
+
|
|
113
|
+
expect(link.raw.startsWith('!')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts a label on an embed', () => {
|
|
117
|
+
const [link] = findWikiLinks('![[diagram.png|Architecture]]');
|
|
118
|
+
|
|
119
|
+
expect(link).toMatchObject({ target: 'diagram.png', label: 'Architecture', embed: true });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* Matches a wiki link and captures its contents.
|
|
10
10
|
*
|
|
11
|
+
* The leading `!` is part of the match so that `![[diagram.png]]` is recognised
|
|
12
|
+
* as an embed rather than leaving a stray `!` in the text ahead of a link.
|
|
13
|
+
*
|
|
11
14
|
* Deliberately refuses `]` and newlines inside the brackets: an unterminated
|
|
12
15
|
* `[[` should stay literal text rather than swallowing the rest of a paragraph.
|
|
13
16
|
*/
|
|
14
|
-
export const WIKILINK_PATTERN =
|
|
17
|
+
export const WIKILINK_PATTERN = /(!?)\[\[([^\]\n]+)\]\]/g;
|
|
15
18
|
|
|
16
19
|
/** The parts of a wiki link. */
|
|
17
20
|
export interface WikiLink {
|
|
@@ -21,6 +24,8 @@ export interface WikiLink {
|
|
|
21
24
|
anchor?: string;
|
|
22
25
|
/** Display text, when the author supplied one after '|' */
|
|
23
26
|
label?: string;
|
|
27
|
+
/** Written as `![[…]]`, asking for the target to be shown rather than linked */
|
|
28
|
+
embed: boolean;
|
|
24
29
|
/** The full matched source, e.g. '[[guide#setup|Setup]]' */
|
|
25
30
|
raw: string;
|
|
26
31
|
}
|
|
@@ -33,21 +38,23 @@ export interface WikiLink {
|
|
|
33
38
|
* - `[[target|label]]`
|
|
34
39
|
* - `[[target#anchor]]`
|
|
35
40
|
* - `[[target#anchor|label]]`
|
|
41
|
+
* - any of the above prefixed with `!`, to embed rather than link
|
|
36
42
|
*
|
|
37
43
|
* The label is split off first, so a `|` inside it is preserved and a `#` in
|
|
38
44
|
* the label is not mistaken for an anchor.
|
|
39
45
|
*
|
|
40
46
|
* @param inner - Text between the brackets
|
|
41
47
|
* @param raw - The full matched source, stored on the result
|
|
48
|
+
* @param embed - Whether the source carried a leading `!`
|
|
42
49
|
* @returns The parsed link, or null when the target is empty
|
|
43
50
|
*
|
|
44
51
|
* @example
|
|
45
52
|
* ```typescript
|
|
46
53
|
* parseWikiLink('guides/setup#step-1|Step one', '[[guides/setup#step-1|Step one]]');
|
|
47
|
-
* // { target: 'guides/setup', anchor: 'step-1', label: 'Step one', raw: '...' }
|
|
54
|
+
* // { target: 'guides/setup', anchor: 'step-1', label: 'Step one', embed: false, raw: '...' }
|
|
48
55
|
* ```
|
|
49
56
|
*/
|
|
50
|
-
export function parseWikiLink(inner: string, raw: string): WikiLink | null {
|
|
57
|
+
export function parseWikiLink(inner: string, raw: string, embed = false): WikiLink | null {
|
|
51
58
|
const pipe = inner.indexOf('|');
|
|
52
59
|
const label = pipe === -1 ? undefined : inner.slice(pipe + 1).trim();
|
|
53
60
|
const locator = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
|
|
@@ -63,6 +70,7 @@ export function parseWikiLink(inner: string, raw: string): WikiLink | null {
|
|
|
63
70
|
target,
|
|
64
71
|
anchor,
|
|
65
72
|
label: label || undefined,
|
|
73
|
+
embed,
|
|
66
74
|
raw,
|
|
67
75
|
};
|
|
68
76
|
}
|
|
@@ -77,7 +85,7 @@ export function findWikiLinks(text: string): WikiLink[] {
|
|
|
77
85
|
const links: WikiLink[] = [];
|
|
78
86
|
|
|
79
87
|
for (const match of text.matchAll(WIKILINK_PATTERN)) {
|
|
80
|
-
const parsed = parseWikiLink(match[
|
|
88
|
+
const parsed = parseWikiLink(match[2], match[0], match[1] === '!');
|
|
81
89
|
if (parsed) links.push(parsed);
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getAdjacentPages } from './sequence';
|
|
3
|
+
import { getSite } from '../site';
|
|
4
|
+
import { extractAllPaths, filterHiddenItems } from './builder';
|
|
5
|
+
|
|
6
|
+
/** The reading order, as the sidebar presents it. */
|
|
7
|
+
function order(): string[] {
|
|
8
|
+
return extractAllPaths(filterHiddenItems(getSite().navigation));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('getAdjacentPages', () => {
|
|
12
|
+
// Written against however many pages there are: a scaffolded project starts
|
|
13
|
+
// with two, and indexing past them would fail there rather than here.
|
|
14
|
+
it('follows the order the sidebar shows', () => {
|
|
15
|
+
const sequence = order();
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < sequence.length; i++) {
|
|
18
|
+
const { previous, next } = getAdjacentPages(sequence[i]);
|
|
19
|
+
|
|
20
|
+
if (i > 0) expect(previous?.url).toContain(sequence[i - 1]);
|
|
21
|
+
if (i < sequence.length - 1) expect(next?.url).toContain(sequence[i + 1]);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('gives the first page no predecessor', () => {
|
|
26
|
+
expect(getAdjacentPages(order()[0]).previous).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('gives the last page no successor', () => {
|
|
30
|
+
const sequence = order();
|
|
31
|
+
|
|
32
|
+
expect(getAdjacentPages(sequence[sequence.length - 1]).next).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('titles each neighbour', () => {
|
|
36
|
+
const sequence = order();
|
|
37
|
+
// Skipped rather than failed on a one-page wiki, which has no neighbours
|
|
38
|
+
// to title.
|
|
39
|
+
if (sequence.length < 2) return;
|
|
40
|
+
|
|
41
|
+
const { next } = getAdjacentPages(sequence[0]);
|
|
42
|
+
|
|
43
|
+
expect(next?.title).toBeTruthy();
|
|
44
|
+
expect(next?.title).not.toBe(next?.url);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Stepping through a guide should not land on a page deliberately kept out
|
|
48
|
+
// of the sidebar.
|
|
49
|
+
it('leaves hidden pages out of the sequence', () => {
|
|
50
|
+
const { hiddenPaths } = getSite();
|
|
51
|
+
const sequence = order();
|
|
52
|
+
|
|
53
|
+
for (const hidden of hiddenPaths) {
|
|
54
|
+
expect(sequence).not.toContain(hidden);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('offers nothing for a page outside the sequence', () => {
|
|
59
|
+
expect(getAdjacentPages('no/such/page')).toEqual({ previous: null, next: null });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Every URL it produces is the trailing-slash form the export serves, or the
|
|
63
|
+
// links here would redirect like the ones fixed elsewhere.
|
|
64
|
+
it('emits URLs in the form the export serves', () => {
|
|
65
|
+
for (const path of order()) {
|
|
66
|
+
const { previous, next } = getAdjacentPages(path);
|
|
67
|
+
|
|
68
|
+
for (const page of [previous, next]) {
|
|
69
|
+
if (page) expect(page.url).toMatch(/^\/.*\/$/);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getSite } from '../site';
|
|
2
|
+
import { extractAllPaths, filterHiddenItems } from './builder';
|
|
3
|
+
import { docPathToUrl } from './url';
|
|
4
|
+
import { getDoc } from '../content/registry';
|
|
5
|
+
import { cached } from '../cache';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The order pages are meant to be read in.
|
|
9
|
+
*
|
|
10
|
+
* The sidebar already answers this: sections come from the content tree, order
|
|
11
|
+
* from `_meta.json` and frontmatter, and reading it top to bottom is how a
|
|
12
|
+
* reader works through a guide. Flattening that same tree gives the sequence,
|
|
13
|
+
* so the two can never disagree — a separate ordering would be one more thing
|
|
14
|
+
* to keep in step.
|
|
15
|
+
*
|
|
16
|
+
* Server-only.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A page next to another in reading order. */
|
|
20
|
+
export interface AdjacentPage {
|
|
21
|
+
/** Display title */
|
|
22
|
+
title: string;
|
|
23
|
+
/** Href, in the site's configured URL form */
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What comes before and after a page. */
|
|
28
|
+
export interface Adjacent {
|
|
29
|
+
previous: AdjacentPage | null;
|
|
30
|
+
next: AdjacentPage | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let memo: string[] | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every page in reading order, hidden ones excluded.
|
|
37
|
+
*
|
|
38
|
+
* Hidden pages are reachable by direct link but are not part of the sequence:
|
|
39
|
+
* stepping through a guide should not land on something deliberately unlisted.
|
|
40
|
+
*
|
|
41
|
+
* @returns Content paths, in the order the sidebar shows them
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* getReadingOrder()[0]; // the page a reader starts at
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function getReadingOrder(): string[] {
|
|
49
|
+
const hit = cached(memo);
|
|
50
|
+
if (hit) return hit;
|
|
51
|
+
|
|
52
|
+
const { navigation } = getSite();
|
|
53
|
+
|
|
54
|
+
// Sections that only group pages carry no path of their own and drop out
|
|
55
|
+
// here, leaving just the readable pages.
|
|
56
|
+
memo = extractAllPaths(filterHiddenItems(navigation));
|
|
57
|
+
return memo;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Finds the pages either side of one in reading order.
|
|
62
|
+
*
|
|
63
|
+
* @param path - Content path of the current page
|
|
64
|
+
* @returns The neighbours, each null at the ends of the sequence
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const { previous, next } = getAdjacentPages('getting-started/installation');
|
|
69
|
+
* next?.title; // 'Your First Wiki'
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function getAdjacentPages(path: string): Adjacent {
|
|
73
|
+
const sequence = getReadingOrder();
|
|
74
|
+
const index = sequence.indexOf(path);
|
|
75
|
+
|
|
76
|
+
// A hidden page, or one reached by a URL that navigation does not cover, has
|
|
77
|
+
// no place in the sequence and so no neighbours to offer.
|
|
78
|
+
if (index === -1) return { previous: null, next: null };
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
previous: toAdjacent(sequence[index - 1]),
|
|
82
|
+
next: toAdjacent(sequence[index + 1]),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolves a content path to the title and href a link needs.
|
|
88
|
+
*
|
|
89
|
+
* @param path - Content path, or undefined at either end of the sequence
|
|
90
|
+
* @returns The page, or null when there is none
|
|
91
|
+
*/
|
|
92
|
+
function toAdjacent(path: string | undefined): AdjacentPage | null {
|
|
93
|
+
if (!path) return null;
|
|
94
|
+
|
|
95
|
+
const { urlMap } = getSite();
|
|
96
|
+
const url = docPathToUrl(urlMap, path);
|
|
97
|
+
if (!url) return null;
|
|
98
|
+
|
|
99
|
+
return { title: getDoc(path)?.title ?? path, url: `/${url}/` };
|
|
100
|
+
}
|
|
@@ -14,6 +14,7 @@ export const payloadSchema = {
|
|
|
14
14
|
description: { type: 'string', minLength: 1 },
|
|
15
15
|
favicon: { type: 'string' },
|
|
16
16
|
baseUrl: { type: 'string', format: 'uri' },
|
|
17
|
+
repoUrl: { type: 'string', format: 'uri' },
|
|
17
18
|
urlStrategy: { type: 'string', enum: ['path', 'hash'] },
|
|
18
19
|
autoNavigation: { type: 'boolean' },
|
|
19
20
|
seo: {
|
|
@@ -30,6 +30,13 @@ export interface GlobalConfig {
|
|
|
30
30
|
favicon?: string;
|
|
31
31
|
/** Base URL for the site */
|
|
32
32
|
baseUrl?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Source repository for this site.
|
|
35
|
+
*
|
|
36
|
+
* Shown as a link in the sidebar when set, and omitted entirely when not, so
|
|
37
|
+
* a private or unpublished wiki does not advertise one.
|
|
38
|
+
*/
|
|
39
|
+
repoUrl?: string;
|
|
33
40
|
/**
|
|
34
41
|
* How content paths are expressed in URLs.
|
|
35
42
|
*
|
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
"": {
|
|
8
8
|
"name": "my-wiki",
|
|
9
9
|
"version": "0.1.0",
|
|
10
|
+
"license": "MIT",
|
|
10
11
|
"dependencies": {
|
|
11
12
|
"@shikijs/rehype": "^4.3.1",
|
|
12
13
|
"ajv": "^8.12.0",
|
|
13
14
|
"ajv-formats": "^3.0.1",
|
|
15
|
+
"beautiful-mermaid": "^1.1.3",
|
|
16
|
+
"github-slugger": "^2.0.0",
|
|
14
17
|
"gray-matter": "^4.0.3",
|
|
15
18
|
"hast-util-to-string": "^3.0.1",
|
|
16
19
|
"katex": "^0.16.47",
|
|
17
20
|
"lucide-react": "^0.554.0",
|
|
21
|
+
"mdast-util-to-string": "^4.0.0",
|
|
18
22
|
"minisearch": "^7.2.0",
|
|
19
23
|
"next": "^14.2.0",
|
|
20
24
|
"react": "^18.3.0",
|
|
@@ -1047,15 +1051,6 @@
|
|
|
1047
1051
|
"url": "https://opencollective.com/pkgr"
|
|
1048
1052
|
}
|
|
1049
1053
|
},
|
|
1050
|
-
"node_modules/@polka/url": {
|
|
1051
|
-
"version": "1.0.0-next.29",
|
|
1052
|
-
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
|
1053
|
-
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
|
1054
|
-
"dev": true,
|
|
1055
|
-
"license": "MIT",
|
|
1056
|
-
"optional": true,
|
|
1057
|
-
"peer": true
|
|
1058
|
-
},
|
|
1059
1054
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
|
1060
1055
|
"version": "4.53.2",
|
|
1061
1056
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
|
|
@@ -2222,30 +2217,6 @@
|
|
|
2222
2217
|
"url": "https://opencollective.com/vitest"
|
|
2223
2218
|
}
|
|
2224
2219
|
},
|
|
2225
|
-
"node_modules/@vitest/ui": {
|
|
2226
|
-
"version": "4.0.9",
|
|
2227
|
-
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.9.tgz",
|
|
2228
|
-
"integrity": "sha512-6HV2HHl9aRJ09TlYj/WAQxaa797Ezb5u0LpgabthlASAUAWKgw/W1DSPX7t848mMZmIUvzZgnUHGIylAoYHP0w==",
|
|
2229
|
-
"dev": true,
|
|
2230
|
-
"license": "MIT",
|
|
2231
|
-
"optional": true,
|
|
2232
|
-
"peer": true,
|
|
2233
|
-
"dependencies": {
|
|
2234
|
-
"@vitest/utils": "4.0.9",
|
|
2235
|
-
"fflate": "^0.8.2",
|
|
2236
|
-
"flatted": "^3.3.3",
|
|
2237
|
-
"pathe": "^2.0.3",
|
|
2238
|
-
"sirv": "^3.0.2",
|
|
2239
|
-
"tinyglobby": "^0.2.15",
|
|
2240
|
-
"tinyrainbow": "^3.0.3"
|
|
2241
|
-
},
|
|
2242
|
-
"funding": {
|
|
2243
|
-
"url": "https://opencollective.com/vitest"
|
|
2244
|
-
},
|
|
2245
|
-
"peerDependencies": {
|
|
2246
|
-
"vitest": "4.0.9"
|
|
2247
|
-
}
|
|
2248
|
-
},
|
|
2249
2220
|
"node_modules/@vitest/utils": {
|
|
2250
2221
|
"version": "4.0.9",
|
|
2251
2222
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.9.tgz",
|
|
@@ -2685,6 +2656,28 @@
|
|
|
2685
2656
|
"baseline-browser-mapping": "dist/cli.js"
|
|
2686
2657
|
}
|
|
2687
2658
|
},
|
|
2659
|
+
"node_modules/beautiful-mermaid": {
|
|
2660
|
+
"version": "1.1.3",
|
|
2661
|
+
"resolved": "https://registry.npmjs.org/beautiful-mermaid/-/beautiful-mermaid-1.1.3.tgz",
|
|
2662
|
+
"integrity": "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg==",
|
|
2663
|
+
"license": "MIT",
|
|
2664
|
+
"dependencies": {
|
|
2665
|
+
"elkjs": "^0.11.0",
|
|
2666
|
+
"entities": "^7.0.1"
|
|
2667
|
+
}
|
|
2668
|
+
},
|
|
2669
|
+
"node_modules/beautiful-mermaid/node_modules/entities": {
|
|
2670
|
+
"version": "7.0.1",
|
|
2671
|
+
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
|
2672
|
+
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
|
2673
|
+
"license": "BSD-2-Clause",
|
|
2674
|
+
"engines": {
|
|
2675
|
+
"node": ">=0.12"
|
|
2676
|
+
},
|
|
2677
|
+
"funding": {
|
|
2678
|
+
"url": "https://github.com/fb55/entities?sponsor=1"
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2688
2681
|
"node_modules/binary-extensions": {
|
|
2689
2682
|
"version": "2.3.0",
|
|
2690
2683
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
|
@@ -3274,6 +3267,12 @@
|
|
|
3274
3267
|
"dev": true,
|
|
3275
3268
|
"license": "ISC"
|
|
3276
3269
|
},
|
|
3270
|
+
"node_modules/elkjs": {
|
|
3271
|
+
"version": "0.11.1",
|
|
3272
|
+
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
|
3273
|
+
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
|
|
3274
|
+
"license": "EPL-2.0"
|
|
3275
|
+
},
|
|
3277
3276
|
"node_modules/emoji-regex": {
|
|
3278
3277
|
"version": "9.2.2",
|
|
3279
3278
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
|
@@ -4288,15 +4287,6 @@
|
|
|
4288
4287
|
"reusify": "^1.0.4"
|
|
4289
4288
|
}
|
|
4290
4289
|
},
|
|
4291
|
-
"node_modules/fflate": {
|
|
4292
|
-
"version": "0.8.2",
|
|
4293
|
-
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
|
4294
|
-
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
|
4295
|
-
"dev": true,
|
|
4296
|
-
"license": "MIT",
|
|
4297
|
-
"optional": true,
|
|
4298
|
-
"peer": true
|
|
4299
|
-
},
|
|
4300
4290
|
"node_modules/file-entry-cache": {
|
|
4301
4291
|
"version": "6.0.1",
|
|
4302
4292
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
|
@@ -6793,18 +6783,6 @@
|
|
|
6793
6783
|
"integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
|
|
6794
6784
|
"license": "MIT"
|
|
6795
6785
|
},
|
|
6796
|
-
"node_modules/mrmime": {
|
|
6797
|
-
"version": "2.0.1",
|
|
6798
|
-
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
|
6799
|
-
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
|
|
6800
|
-
"dev": true,
|
|
6801
|
-
"license": "MIT",
|
|
6802
|
-
"optional": true,
|
|
6803
|
-
"peer": true,
|
|
6804
|
-
"engines": {
|
|
6805
|
-
"node": ">=10"
|
|
6806
|
-
}
|
|
6807
|
-
},
|
|
6808
6786
|
"node_modules/ms": {
|
|
6809
6787
|
"version": "2.1.3",
|
|
6810
6788
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
@@ -8349,23 +8327,6 @@
|
|
|
8349
8327
|
"url": "https://github.com/sponsors/isaacs"
|
|
8350
8328
|
}
|
|
8351
8329
|
},
|
|
8352
|
-
"node_modules/sirv": {
|
|
8353
|
-
"version": "3.0.2",
|
|
8354
|
-
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
|
8355
|
-
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
|
8356
|
-
"dev": true,
|
|
8357
|
-
"license": "MIT",
|
|
8358
|
-
"optional": true,
|
|
8359
|
-
"peer": true,
|
|
8360
|
-
"dependencies": {
|
|
8361
|
-
"@polka/url": "^1.0.0-next.24",
|
|
8362
|
-
"mrmime": "^2.0.0",
|
|
8363
|
-
"totalist": "^3.0.0"
|
|
8364
|
-
},
|
|
8365
|
-
"engines": {
|
|
8366
|
-
"node": ">=18"
|
|
8367
|
-
}
|
|
8368
|
-
},
|
|
8369
8330
|
"node_modules/slash": {
|
|
8370
8331
|
"version": "3.0.0",
|
|
8371
8332
|
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
|
@@ -8955,18 +8916,6 @@
|
|
|
8955
8916
|
"node": ">=8.0"
|
|
8956
8917
|
}
|
|
8957
8918
|
},
|
|
8958
|
-
"node_modules/totalist": {
|
|
8959
|
-
"version": "3.0.1",
|
|
8960
|
-
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
|
8961
|
-
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
|
8962
|
-
"dev": true,
|
|
8963
|
-
"license": "MIT",
|
|
8964
|
-
"optional": true,
|
|
8965
|
-
"peer": true,
|
|
8966
|
-
"engines": {
|
|
8967
|
-
"node": ">=6"
|
|
8968
|
-
}
|
|
8969
|
-
},
|
|
8970
8919
|
"node_modules/trim-lines": {
|
|
8971
8920
|
"version": "3.0.1",
|
|
8972
8921
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|