create-eziwiki 0.1.0 → 0.2.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.
Files changed (42) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/template/app/[...slug]/page.tsx +3 -1
  4. package/template/app/globals.css +8 -4
  5. package/template/app/layout.tsx +46 -24
  6. package/template/app/robots.ts +11 -3
  7. package/template/components/graph/GraphView.tsx +20 -7
  8. package/template/components/layout/LocalGraph.tsx +52 -0
  9. package/template/components/layout/MobileMenu.tsx +16 -1
  10. package/template/components/layout/PageLayout.tsx +11 -3
  11. package/template/components/layout/Sidebar.tsx +30 -2
  12. package/template/components/markdown/LinkPreview.tsx +164 -0
  13. package/template/components/markdown/MarkdownContent.tsx +3 -1
  14. package/template/components/search/SearchTrigger.tsx +11 -4
  15. package/template/lib/content/assets.ts +158 -0
  16. package/template/lib/content/excerpt.test.ts +68 -0
  17. package/template/lib/content/excerpt.ts +142 -0
  18. package/template/lib/graph/build.ts +55 -0
  19. package/template/lib/markdown/rehype-plugins.ts +24 -2
  20. package/template/lib/markdown/remark-wikilink.ts +201 -14
  21. package/template/lib/markdown/render.ts +130 -10
  22. package/template/lib/markdown/wikilink.test.ts +30 -0
  23. package/template/lib/markdown/wikilink.ts +12 -4
  24. package/template/lib/payload/schema.ts +1 -0
  25. package/template/lib/payload/types.ts +7 -0
  26. package/template/package-lock.json +3 -83
  27. package/template/package.json +2 -0
  28. package/template/public/fonts/Pretendard/pretendard-400-korean.woff2 +0 -0
  29. package/template/public/fonts/Pretendard/pretendard-400-latin.woff2 +0 -0
  30. package/template/public/fonts/Pretendard/pretendard-600-korean.woff2 +0 -0
  31. package/template/public/fonts/Pretendard/pretendard-600-latin.woff2 +0 -0
  32. package/template/public/fonts/Pretendard/pretendard-700-korean.woff2 +0 -0
  33. package/template/public/fonts/Pretendard/pretendard-700-latin.woff2 +0 -0
  34. package/template/styles/markdown.css +76 -2
  35. package/template/styles/theme.css +7 -0
  36. package/template/vercel.json +31 -0
  37. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  38. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  39. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  40. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  41. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  42. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
@@ -1,5 +1,13 @@
1
1
  import { visit } from 'unist-util-visit';
2
- import type { Root, Text, PhrasingContent, Parent } from 'mdast';
2
+ import type {
3
+ Root,
4
+ Text,
5
+ PhrasingContent,
6
+ Parent,
7
+ Paragraph,
8
+ RootContent,
9
+ BlockContent,
10
+ } from 'mdast';
3
11
  import { WIKILINK_PATTERN, parseWikiLink, type WikiLink } from './wikilink';
4
12
 
5
13
  /**
@@ -18,6 +26,8 @@ export interface WikiLinkTarget {
18
26
  url: string;
19
27
  /** Default display text when the author gave no label */
20
28
  title: string;
29
+ /** One-line summary, carried on the link so a hover card needs no request */
30
+ excerpt?: string;
21
31
  }
22
32
 
23
33
  /**
@@ -25,6 +35,48 @@ export interface WikiLinkTarget {
25
35
  */
26
36
  export type WikiLinkResolver = (target: string) => WikiLinkTarget | null;
27
37
 
38
+ /**
39
+ * Resolves an embed target to a static file, or null when there is none.
40
+ */
41
+ export type EmbedResolver = (target: string) => { url: string } | null;
42
+
43
+ /** A document whose content is to be shown inside another. */
44
+ export interface TranscludeTarget {
45
+ /** Content path, used to detect a page including itself */
46
+ path: string;
47
+ /** Href of the source document */
48
+ url: string;
49
+ /** Title of the source document */
50
+ title: string;
51
+ /** The Markdown nodes to splice in */
52
+ nodes: RootContent[];
53
+ }
54
+
55
+ /**
56
+ * Resolves an embed target to a document's content, or null when the target is
57
+ * not a document or the named section does not exist.
58
+ */
59
+ export type TranscludeResolver = (target: string, anchor?: string) => TranscludeTarget | null;
60
+
61
+ /** How a wiki link should be turned into a node. */
62
+ export interface WikiLinkResolvers {
63
+ /** Resolves a document target */
64
+ link: WikiLinkResolver;
65
+ /** Resolves an embeddable file; absent when embeds are not supported */
66
+ embed?: EmbedResolver;
67
+ /** Resolves a document to include inline; absent disables transclusion */
68
+ transclude?: TranscludeResolver;
69
+ }
70
+
71
+ /**
72
+ * How deeply a transclusion may nest.
73
+ *
74
+ * A page including a page that includes a page is already hard to read; past
75
+ * that it is more likely a mistake than an intent, and each level multiplies
76
+ * the work the build does.
77
+ */
78
+ const MAX_TRANSCLUSION_DEPTH = 3;
79
+
28
80
  /**
29
81
  * Builds the replacement node for one wiki link.
30
82
  *
@@ -32,7 +84,24 @@ export type WikiLinkResolver = (target: string) => WikiLinkTarget | null;
32
84
  * that goes nowhere is worse than visibly broken text, because it looks
33
85
  * clickable and silently is not.
34
86
  */
35
- function toNode(link: WikiLink, resolve: WikiLinkResolver): PhrasingContent {
87
+ function toNode(link: WikiLink, resolvers: WikiLinkResolvers): PhrasingContent {
88
+ const resolve = resolvers.link;
89
+
90
+ // `![[file.png]]` shows the file rather than linking to it. Only static
91
+ // assets are embedded for now; `![[some-note]]` falls through to a link, so
92
+ // an author who writes it gets a working reference instead of nothing.
93
+ if (link.embed && resolvers.embed) {
94
+ const asset = resolvers.embed(link.target);
95
+
96
+ if (asset) {
97
+ return {
98
+ type: 'image',
99
+ url: asset.url,
100
+ alt: link.label ?? link.target,
101
+ };
102
+ }
103
+ }
104
+
36
105
  // An anchor-only link points within the current page, so there is nothing to
37
106
  // resolve.
38
107
  if (!link.target && link.anchor) {
@@ -65,7 +134,14 @@ function toNode(link: WikiLink, resolve: WikiLinkResolver): PhrasingContent {
65
134
  type: 'link',
66
135
  url: link.anchor ? `${resolved.url}#${link.anchor}` : resolved.url,
67
136
  data: {
68
- hProperties: { className: ['ezw-wikilink'] },
137
+ hProperties: {
138
+ className: ['ezw-wikilink'],
139
+ // The card's contents travel with the link rather than being fetched.
140
+ // The build already knows them, and a reader who hovers should not wait
141
+ // on a request to find out where a link goes.
142
+ 'data-preview-title': resolved.title,
143
+ ...(resolved.excerpt ? { 'data-preview': resolved.excerpt } : {}),
144
+ },
69
145
  },
70
146
  children: [{ type: 'text', value: link.label ?? resolved.title }],
71
147
  };
@@ -75,10 +151,10 @@ function toNode(link: WikiLink, resolve: WikiLinkResolver): PhrasingContent {
75
151
  * Splits a text node into text and link nodes.
76
152
  *
77
153
  * @param node - The text node to split
78
- * @param resolve - Target resolver
154
+ * @param resolvers - Target resolvers for links and embeds
79
155
  * @returns Replacement nodes, or null when the text contains no wiki links
80
156
  */
81
- function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | null {
157
+ function splitText(node: Text, resolvers: WikiLinkResolvers): PhrasingContent[] | null {
82
158
  const { value } = node;
83
159
  if (!value.includes('[[')) return null;
84
160
 
@@ -89,7 +165,7 @@ function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | n
89
165
  // matchAll on a global pattern is safe here because the regex literal is
90
166
  // re-evaluated per call; lastIndex never leaks between documents.
91
167
  for (const match of value.matchAll(WIKILINK_PATTERN)) {
92
- const parsed = parseWikiLink(match[1], match[0]);
168
+ const parsed = parseWikiLink(match[2], match[0], match[1] === '!');
93
169
  if (!parsed) continue;
94
170
 
95
171
  const start = match.index ?? 0;
@@ -98,7 +174,7 @@ function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | n
98
174
  replacement.push({ type: 'text', value: value.slice(cursor, start) });
99
175
  }
100
176
 
101
- replacement.push(toNode(parsed, resolve));
177
+ replacement.push(toNode(parsed, resolvers));
102
178
  cursor = start + match[0].length;
103
179
  matched = true;
104
180
  }
@@ -112,24 +188,135 @@ function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | n
112
188
  return replacement;
113
189
  }
114
190
 
191
+ /**
192
+ * Reads a paragraph that consists of nothing but one embed.
193
+ *
194
+ * Transclusion replaces a paragraph with whole blocks — headings, lists, code —
195
+ * which cannot sit inside one. So it applies only when the embed is alone in
196
+ * its paragraph, which is also how an author writes it. An embed with prose
197
+ * beside it stays inline and becomes a link.
198
+ *
199
+ * @param node - Paragraph to inspect
200
+ * @returns The embed, or null when the paragraph holds anything else
201
+ */
202
+ function soleEmbed(node: Paragraph): WikiLink | null {
203
+ if (node.children.length !== 1) return null;
204
+
205
+ const [child] = node.children;
206
+ if (child.type !== 'text') return null;
207
+
208
+ const text = child.value.trim();
209
+ const match = /^(!?)\[\[([^\]\n]+)\]\]$/.exec(text);
210
+ if (!match || match[1] !== '!') return null;
211
+
212
+ return parseWikiLink(match[2], match[0], true);
213
+ }
214
+
215
+ /**
216
+ * Wraps included content so a reader can see where it came from.
217
+ *
218
+ * `blockquote` carries the block children — it is a known type that accepts
219
+ * them — while `hName` renders it as a plain division.
220
+ */
221
+ function wrapTranscluded(target: TranscludeTarget, nodes: RootContent[]): RootContent {
222
+ return {
223
+ type: 'blockquote',
224
+ data: {
225
+ hName: 'div',
226
+ hProperties: { className: ['ezw-transclusion'] },
227
+ },
228
+ children: [
229
+ ...(nodes as BlockContent[]),
230
+ {
231
+ type: 'paragraph',
232
+ data: { hProperties: { className: ['ezw-transclusion__source'] } },
233
+ children: [
234
+ {
235
+ type: 'link',
236
+ url: target.url,
237
+ children: [{ type: 'text', value: target.title }],
238
+ },
239
+ ],
240
+ },
241
+ ],
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Marks headings that arrived by transclusion.
247
+ *
248
+ * The table of contents describes the page a reader is on. Headings pulled in
249
+ * from elsewhere would list sections that belong to another document, so they
250
+ * are flagged here and skipped when the contents are collected.
251
+ */
252
+ function markTranscludedHeadings(tree: Root): void {
253
+ visit(tree, 'heading', (heading) => {
254
+ heading.data ??= {};
255
+ const properties = (heading.data.hProperties ??= {}) as Record<string, unknown>;
256
+ properties['data-transcluded'] = 'true';
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Replaces sole-embed paragraphs with the content they name.
262
+ *
263
+ * @param tree - Tree to transform in place
264
+ * @param resolvers - Target resolvers
265
+ * @param stack - Paths already being included, innermost last
266
+ */
267
+ function transclude(tree: Root, resolvers: WikiLinkResolvers, stack: string[]): void {
268
+ const resolve = resolvers.transclude;
269
+ if (!resolve) return;
270
+
271
+ visit(tree, 'paragraph', (node: Paragraph, index, parent) => {
272
+ if (!parent || index === undefined) return;
273
+
274
+ const embed = soleEmbed(node);
275
+ if (!embed || !embed.target) return;
276
+
277
+ const target = resolve(embed.target, embed.anchor);
278
+ if (!target) return;
279
+
280
+ // A page including itself, directly or through a chain, would never finish.
281
+ // Leaving the link is the honest outcome: the reference is real, it just
282
+ // cannot be shown here.
283
+ if (stack.includes(target.path) || stack.length >= MAX_TRANSCLUSION_DEPTH) return;
284
+
285
+ const inner: Root = { type: 'root', children: target.nodes };
286
+ transclude(inner, resolvers, [...stack, target.path]);
287
+ markTranscludedHeadings(inner);
288
+
289
+ parent.children.splice(index, 1, wrapTranscluded(target, inner.children));
290
+
291
+ return index + 1;
292
+ });
293
+ }
294
+
115
295
  /**
116
296
  * Remark plugin factory.
117
297
  *
118
- * @param resolve - Resolves a target to its destination
298
+ * @param resolvers - Resolves a target to a document, and optionally to a file
119
299
  *
120
300
  * @example
121
301
  * ```typescript
122
- * unified().use(remarkParse).use(remarkWikiLinks, (target) =>
123
- * target === 'intro' ? { url: '/intro', title: 'Introduction' } : null,
124
- * );
302
+ * unified().use(remarkParse).use(remarkWikiLinks, {
303
+ * link: (target) =>
304
+ * target === 'intro' ? { url: '/intro/', title: 'Introduction' } : null,
305
+ * embed: (target) => (target === 'logo.svg' ? { url: '/images/logo.svg' } : null),
306
+ * });
125
307
  * ```
126
308
  */
127
- export function remarkWikiLinks(resolve: WikiLinkResolver) {
128
- return (tree: Root) => {
309
+ export function remarkWikiLinks(resolvers: WikiLinkResolvers) {
310
+ return (tree: Root, file: { data: Record<string, unknown> }) => {
311
+ // Transclusion first: it works on whole paragraphs, and the inline pass
312
+ // below would otherwise have already turned the embed into a link.
313
+ const docPath = file?.data?.docPath;
314
+ transclude(tree, resolvers, typeof docPath === 'string' ? [docPath] : []);
315
+
129
316
  visit(tree, 'text', (node: Text, index, parent) => {
130
317
  if (!parent || index === undefined) return;
131
318
 
132
- const replacement = splitText(node, resolve);
319
+ const replacement = splitText(node, resolvers);
133
320
  if (!replacement) return;
134
321
 
135
322
  (parent as Parent).children.splice(index, 1, ...replacement);
@@ -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';
@@ -16,13 +18,17 @@ import {
16
18
  rehypeInternalLinks,
17
19
  type Heading,
18
20
  } from './rehype-plugins';
19
- import { remarkWikiLinks, type WikiLinkTarget } from './remark-wikilink';
21
+ import { remarkWikiLinks, type WikiLinkTarget, type TranscludeTarget } from './remark-wikilink';
20
22
  import { getUsedLanguages } from './languages';
21
23
  import { cached } from '../cache';
22
24
  import { BASE_PATH } from '../basePath';
23
25
  import { getUrlMap } from '../navigation/urlMap';
24
26
  import { getDoc } from '../content/registry';
25
27
  import { resolveTarget } from '../content/resolver';
28
+ import { resolveAsset } from '../content/assets';
29
+ import { getExcerpt } from '../content/excerpt';
30
+ import GithubSlugger from 'github-slugger';
31
+ import { toString as mdastToString } from 'mdast-util-to-string';
26
32
  import { docPathToUrl } from '../navigation/url';
27
33
 
28
34
  /**
@@ -43,14 +49,32 @@ export interface RenderedMarkdown {
43
49
  headings: Heading[];
44
50
  }
45
51
 
46
- /** Syntax highlighting themes, applied as CSS variables for light and dark. */
47
- const SHIKI_THEMES = { light: 'github-light', dark: 'github-dark' } as const;
52
+ /**
53
+ * Syntax highlighting themes, applied as CSS variables for light and dark.
54
+ *
55
+ * The high-contrast variants, because the plain ones are tuned for GitHub's
56
+ * near-white code background: against the slightly darker grey used here their
57
+ * strings, keywords and comments land between 4.15:1 and 4.37:1, under the
58
+ * 4.5:1 that body-sized text needs. Lightening the background would fix the
59
+ * ratios too, but a code block that matches the page around it stops reading
60
+ * as a code block.
61
+ */
62
+ const SHIKI_THEMES = {
63
+ light: 'github-light-high-contrast',
64
+ dark: 'github-dark-high-contrast',
65
+ } as const;
48
66
 
49
67
  let processor: Processor | null = null;
50
68
 
51
69
  /**
52
70
  * Resolves a wiki-link target to a destination in this site.
53
71
  *
72
+ * The trailing slash is applied here rather than downstream. Under the `path`
73
+ * strategy `rehypeInternalLinks` sees a content path it can resolve and adds
74
+ * one, but under `hash` the URL is already a digest, which does not map back
75
+ * to a document — so the link was left without it and every wiki link cost a
76
+ * redirect.
77
+ *
54
78
  * @param target - Raw target text from inside the brackets
55
79
  * @returns The destination, or null when the target does not resolve
56
80
  */
@@ -59,7 +83,88 @@ function resolveWikiLink(target: string): WikiLinkTarget | null {
59
83
  if (!doc) return null;
60
84
 
61
85
  const url = docPathToUrl(getUrlMap(), doc.path);
62
- return url ? { url: `/${url}`, title: doc.title } : null;
86
+ if (!url) return null;
87
+
88
+ return {
89
+ url: `/${url}/`,
90
+ title: doc.title,
91
+ excerpt: getExcerpt(doc.path),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Resolves an embed target to a file under `public/`.
97
+ *
98
+ * @param target - Raw target text from inside the brackets
99
+ * @returns The file's URL, or null when nothing matches
100
+ */
101
+ function resolveWikiEmbed(target: string): { url: string } | null {
102
+ const asset = resolveAsset(target);
103
+ return asset ? { url: asset.url } : null;
104
+ }
105
+
106
+ /** Parser for included documents; no rendering plugins, so it stays cheap. */
107
+ const transclusionParser = unified().use(remarkParse).use(remarkGfm).use(remarkMath);
108
+
109
+ /**
110
+ * Narrows a document's nodes to the section a heading names.
111
+ *
112
+ * The section runs from the matching heading to the next one at the same level
113
+ * or above, which is what a reader means by "that part of the page". The
114
+ * heading itself is kept: dropping it would leave the included text with no
115
+ * indication of what it is.
116
+ *
117
+ * @param nodes - The whole document's nodes
118
+ * @param anchor - Heading slug from after the `#`
119
+ * @returns The section's nodes, or null when no heading matches
120
+ */
121
+ function sliceSection(nodes: RootContent[], anchor: string): RootContent[] | null {
122
+ const slugger = new GithubSlugger();
123
+ const wanted = anchor.toLowerCase();
124
+
125
+ let start = -1;
126
+ let depth = 0;
127
+
128
+ for (let i = 0; i < nodes.length; i++) {
129
+ const node = nodes[i];
130
+ if (node.type !== 'heading') continue;
131
+
132
+ // Slugs are assigned in document order, and repeats are numbered, so every
133
+ // heading has to pass through the slugger even before the match is found.
134
+ const slug = slugger.slug(mdastToString(node));
135
+
136
+ if (start === -1) {
137
+ if (slug !== wanted) continue;
138
+ start = i;
139
+ depth = node.depth;
140
+ continue;
141
+ }
142
+
143
+ if (node.depth <= depth) return nodes.slice(start, i);
144
+ }
145
+
146
+ return start === -1 ? null : nodes.slice(start);
147
+ }
148
+
149
+ /**
150
+ * Resolves an embed target to the document content it names.
151
+ *
152
+ * @param target - Raw target text from inside the brackets
153
+ * @param anchor - Heading slug, when the embed named a section
154
+ * @returns The document's nodes and identity, or null when it does not resolve
155
+ */
156
+ function resolveWikiTransclusion(target: string, anchor?: string): TranscludeTarget | null {
157
+ const { doc } = resolveTarget(target);
158
+ if (!doc) return null;
159
+
160
+ const url = docPathToUrl(getUrlMap(), doc.path);
161
+ if (!url) return null;
162
+
163
+ const tree = transclusionParser.parse(doc.content) as Root;
164
+ const nodes = anchor ? sliceSection(tree.children, anchor) : tree.children;
165
+ if (!nodes || nodes.length === 0) return null;
166
+
167
+ return { path: doc.path, url: `/${url}/`, title: doc.title, nodes };
63
168
  }
64
169
 
65
170
  /**
@@ -81,7 +186,11 @@ function createProcessor(): Processor {
81
186
  .use(remarkParse)
82
187
  .use(remarkGfm)
83
188
  .use(remarkMath)
84
- .use(remarkWikiLinks, resolveWikiLink)
189
+ .use(remarkWikiLinks, {
190
+ link: resolveWikiLink,
191
+ embed: resolveWikiEmbed,
192
+ transclude: resolveWikiTransclusion,
193
+ })
85
194
  .use(remarkRehype, { allowDangerousHtml: true })
86
195
  .use(rehypeRaw)
87
196
  .use(rehypeSlug)
@@ -132,6 +241,9 @@ function getProcessor(): Processor {
132
241
  * Compiles a Markdown string to HTML and extracts its headings.
133
242
  *
134
243
  * @param markdown - Markdown source, with frontmatter already stripped
244
+ * @param docPath - Path of the document being rendered, when it has one; a
245
+ * transclusion naming it is refused rather than nesting a copy of the page
246
+ * inside itself
135
247
  * @returns The rendered HTML and the headings found in it
136
248
  *
137
249
  * @example
@@ -140,12 +252,20 @@ function getProcessor(): Processor {
140
252
  * headings; // [{ id: 'setup', text: 'Setup', depth: 2 }]
141
253
  * ```
142
254
  */
143
- export async function renderMarkdown(markdown: string): Promise<RenderedMarkdown> {
144
- const file = await getProcessor().process(markdown);
255
+ export async function renderMarkdown(
256
+ markdown: string,
257
+ docPath?: string,
258
+ ): Promise<RenderedMarkdown> {
259
+ const file = new VFile(markdown);
260
+ // Seeds the transclusion stack, so a document that includes itself is caught
261
+ // at the first step rather than after rendering one copy of itself.
262
+ if (docPath) file.data.docPath = docPath;
263
+
264
+ const processed = await getProcessor().process(file);
145
265
 
146
266
  return {
147
- html: String(file),
148
- headings: (file.data.headings as Heading[] | undefined) ?? [],
267
+ html: String(processed),
268
+ headings: (processed.data.headings as Heading[] | undefined) ?? [],
149
269
  };
150
270
  }
151
271
 
@@ -168,7 +288,7 @@ export async function renderDoc(docPath: string): Promise<RenderedMarkdown | nul
168
288
  const doc = getDoc(docPath);
169
289
  if (!doc) return null;
170
290
 
171
- const rendered = await renderMarkdown(doc.content);
291
+ const rendered = await renderMarkdown(doc.content, doc.path);
172
292
  cache.set(docPath, rendered);
173
293
 
174
294
  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 = /\[\[([^\]\n]+)\]\]/g;
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[1], match[0]);
88
+ const parsed = parseWikiLink(match[2], match[0], match[1] === '!');
81
89
  if (parsed) links.push(parsed);
82
90
  }
83
91
 
@@ -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
  *