create-eziwiki 0.1.1 → 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.
@@ -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
  /**
@@ -77,7 +83,88 @@ function resolveWikiLink(target: string): WikiLinkTarget | null {
77
83
  if (!doc) return null;
78
84
 
79
85
  const url = docPathToUrl(getUrlMap(), doc.path);
80
- 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 };
81
168
  }
82
169
 
83
170
  /**
@@ -99,7 +186,11 @@ function createProcessor(): Processor {
99
186
  .use(remarkParse)
100
187
  .use(remarkGfm)
101
188
  .use(remarkMath)
102
- .use(remarkWikiLinks, resolveWikiLink)
189
+ .use(remarkWikiLinks, {
190
+ link: resolveWikiLink,
191
+ embed: resolveWikiEmbed,
192
+ transclude: resolveWikiTransclusion,
193
+ })
103
194
  .use(remarkRehype, { allowDangerousHtml: true })
104
195
  .use(rehypeRaw)
105
196
  .use(rehypeSlug)
@@ -150,6 +241,9 @@ function getProcessor(): Processor {
150
241
  * Compiles a Markdown string to HTML and extracts its headings.
151
242
  *
152
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
153
247
  * @returns The rendered HTML and the headings found in it
154
248
  *
155
249
  * @example
@@ -158,12 +252,20 @@ function getProcessor(): Processor {
158
252
  * headings; // [{ id: 'setup', text: 'Setup', depth: 2 }]
159
253
  * ```
160
254
  */
161
- export async function renderMarkdown(markdown: string): Promise<RenderedMarkdown> {
162
- 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);
163
265
 
164
266
  return {
165
- html: String(file),
166
- headings: (file.data.headings as Heading[] | undefined) ?? [],
267
+ html: String(processed),
268
+ headings: (processed.data.headings as Heading[] | undefined) ?? [],
167
269
  };
168
270
  }
169
271
 
@@ -186,7 +288,7 @@ export async function renderDoc(docPath: string): Promise<RenderedMarkdown | nul
186
288
  const doc = getDoc(docPath);
187
289
  if (!doc) return null;
188
290
 
189
- const rendered = await renderMarkdown(doc.content);
291
+ const rendered = await renderMarkdown(doc.content, doc.path);
190
292
  cache.set(docPath, rendered);
191
293
 
192
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
  *
@@ -7,14 +7,17 @@
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
+ "github-slugger": "^2.0.0",
14
16
  "gray-matter": "^4.0.3",
15
17
  "hast-util-to-string": "^3.0.1",
16
18
  "katex": "^0.16.47",
17
19
  "lucide-react": "^0.554.0",
20
+ "mdast-util-to-string": "^4.0.0",
18
21
  "minisearch": "^7.2.0",
19
22
  "next": "^14.2.0",
20
23
  "react": "^18.3.0",
@@ -1047,15 +1050,6 @@
1047
1050
  "url": "https://opencollective.com/pkgr"
1048
1051
  }
1049
1052
  },
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
1053
  "node_modules/@rollup/rollup-android-arm-eabi": {
1060
1054
  "version": "4.53.2",
1061
1055
  "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
@@ -2222,30 +2216,6 @@
2222
2216
  "url": "https://opencollective.com/vitest"
2223
2217
  }
2224
2218
  },
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
2219
  "node_modules/@vitest/utils": {
2250
2220
  "version": "4.0.9",
2251
2221
  "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.9.tgz",
@@ -4288,15 +4258,6 @@
4288
4258
  "reusify": "^1.0.4"
4289
4259
  }
4290
4260
  },
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
4261
  "node_modules/file-entry-cache": {
4301
4262
  "version": "6.0.1",
4302
4263
  "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -6793,18 +6754,6 @@
6793
6754
  "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
6794
6755
  "license": "MIT"
6795
6756
  },
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
6757
  "node_modules/ms": {
6809
6758
  "version": "2.1.3",
6810
6759
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8349,23 +8298,6 @@
8349
8298
  "url": "https://github.com/sponsors/isaacs"
8350
8299
  }
8351
8300
  },
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
8301
  "node_modules/slash": {
8370
8302
  "version": "3.0.0",
8371
8303
  "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -8955,18 +8887,6 @@
8955
8887
  "node": ">=8.0"
8956
8888
  }
8957
8889
  },
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
8890
  "node_modules/trim-lines": {
8971
8891
  "version": "3.0.1",
8972
8892
  "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -24,10 +24,12 @@
24
24
  "@shikijs/rehype": "^4.3.1",
25
25
  "ajv": "^8.12.0",
26
26
  "ajv-formats": "^3.0.1",
27
+ "github-slugger": "^2.0.0",
27
28
  "gray-matter": "^4.0.3",
28
29
  "hast-util-to-string": "^3.0.1",
29
30
  "katex": "^0.16.47",
30
31
  "lucide-react": "^0.554.0",
32
+ "mdast-util-to-string": "^4.0.0",
31
33
  "minisearch": "^7.2.0",
32
34
  "next": "^14.2.0",
33
35
  "react": "^18.3.0",
@@ -165,3 +165,77 @@ html.dark .ezw-broken-link {
165
165
  white-space: normal;
166
166
  }
167
167
  }
168
+
169
+ /* Content pulled in from another page with `![[wiki link]]`.
170
+ Set apart so a reader can tell borrowed text from the page's own, and
171
+ attributed so they can follow it back to where it is maintained. */
172
+ .ezw-transclusion {
173
+ margin: 1.5rem 0;
174
+ padding: 1rem 1.25rem;
175
+ border: 1px solid var(--color-border);
176
+ border-left: 3px solid var(--color-primary, #2563eb);
177
+ border-radius: 0.375rem;
178
+ background: var(--color-sidebar-bg);
179
+ }
180
+
181
+ .ezw-transclusion > :first-child {
182
+ margin-top: 0;
183
+ }
184
+
185
+ .ezw-transclusion__source {
186
+ margin: 0.75rem 0 0;
187
+ padding-top: 0.5rem;
188
+ border-top: 1px solid var(--color-border);
189
+ font-size: 0.8125rem;
190
+ color: var(--color-text-muted);
191
+ }
192
+
193
+ .ezw-transclusion__source::before {
194
+ content: 'From ';
195
+ }
196
+
197
+ /* Card shown when a reader hovers or focuses a wiki link. Its contents are
198
+ already on the anchor from the build, so it never waits on a request. */
199
+ .ezw-link-preview {
200
+ position: fixed;
201
+ z-index: 60;
202
+ padding: 0.75rem 0.875rem;
203
+ border: 1px solid var(--color-border);
204
+ border-radius: 0.5rem;
205
+ background: var(--color-background);
206
+ box-shadow:
207
+ 0 4px 6px -1px rgb(0 0 0 / 0.1),
208
+ 0 2px 4px -2px rgb(0 0 0 / 0.1);
209
+ /* Purely informational, so it never intercepts the pointer heading for the
210
+ link underneath it. */
211
+ pointer-events: none;
212
+ }
213
+
214
+ .ezw-link-preview__title {
215
+ margin: 0;
216
+ font-size: 0.875rem;
217
+ font-weight: 600;
218
+ color: var(--color-text);
219
+ }
220
+
221
+ .ezw-link-preview__excerpt {
222
+ margin: 0.375rem 0 0;
223
+ font-size: 0.8125rem;
224
+ line-height: 1.5;
225
+ color: var(--color-text-muted);
226
+ }
227
+
228
+ @media (prefers-reduced-motion: no-preference) {
229
+ .ezw-link-preview {
230
+ animation: ezw-link-preview-in 120ms ease-out;
231
+ }
232
+
233
+ @keyframes ezw-link-preview-in {
234
+ from {
235
+ opacity: 0;
236
+ }
237
+ to {
238
+ opacity: 1;
239
+ }
240
+ }
241
+ }