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.
Files changed (39) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/template/app/[...slug]/page.tsx +59 -3
  4. package/template/app/layout.tsx +3 -1
  5. package/template/components/graph/GraphView.tsx +20 -7
  6. package/template/components/layout/LocalGraph.tsx +52 -0
  7. package/template/components/layout/MobileMenu.tsx +16 -1
  8. package/template/components/layout/MovedPage.tsx +45 -0
  9. package/template/components/layout/PageLayout.tsx +11 -3
  10. package/template/components/layout/PageNavigation.tsx +73 -0
  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/lib/content/aliases.test.ts +76 -0
  15. package/template/lib/content/aliases.ts +114 -0
  16. package/template/lib/content/assets.ts +158 -0
  17. package/template/lib/content/excerpt.test.ts +68 -0
  18. package/template/lib/content/excerpt.ts +142 -0
  19. package/template/lib/content/registry.ts +31 -0
  20. package/template/lib/graph/build.ts +55 -0
  21. package/template/lib/graph/health.test.ts +60 -0
  22. package/template/lib/graph/health.ts +58 -0
  23. package/template/lib/markdown/callout.test.ts +87 -0
  24. package/template/lib/markdown/mermaid.test.ts +72 -0
  25. package/template/lib/markdown/rehype-mermaid.ts +133 -0
  26. package/template/lib/markdown/rehype-plugins.ts +50 -0
  27. package/template/lib/markdown/remark-callout.ts +173 -0
  28. package/template/lib/markdown/remark-wikilink.ts +201 -14
  29. package/template/lib/markdown/render.ts +125 -9
  30. package/template/lib/markdown/wikilink.test.ts +30 -0
  31. package/template/lib/markdown/wikilink.ts +12 -4
  32. package/template/lib/navigation/sequence.test.ts +73 -0
  33. package/template/lib/navigation/sequence.ts +100 -0
  34. package/template/lib/payload/schema.ts +1 -0
  35. package/template/lib/payload/types.ts +7 -0
  36. package/template/package-lock.json +32 -83
  37. package/template/package.json +3 -0
  38. package/template/scripts/check-links.ts +60 -18
  39. package/template/styles/markdown.css +231 -0
@@ -49,6 +49,11 @@ export function rehypeCollectHeadings() {
49
49
  visit(tree, 'element', (node: Element) => {
50
50
  if (!TOC_LEVELS.has(node.tagName)) return;
51
51
 
52
+ // A transcluded heading belongs to the document it came from. Listing it
53
+ // would offer a reader sections that are not this page's own, and two
54
+ // entries with the same name whenever a page includes part of another.
55
+ if (node.properties?.dataTranscluded) return;
56
+
52
57
  const id = node.properties?.id;
53
58
  if (typeof id !== 'string' || !id) return;
54
59
 
@@ -63,6 +68,51 @@ export function rehypeCollectHeadings() {
63
68
  };
64
69
  }
65
70
 
71
+ /** Heading levels that get a link to themselves. */
72
+ const ANCHORED_LEVELS = new Set(['h2', 'h3', 'h4', 'h5', 'h6']);
73
+
74
+ /**
75
+ * Appends a link to each heading pointing at itself.
76
+ *
77
+ * A reader who wants to send someone to one section of a long page otherwise
78
+ * has to read the id out of the address bar, or link the whole page and say
79
+ * "scroll down". Every documentation site solves this the same way, and the
80
+ * anchor is a real link — focusable, copyable, and working without script.
81
+ *
82
+ * `h1` is skipped: it names the page, which the page URL already addresses.
83
+ * Transcluded headings are skipped too, since their `id` belongs to the
84
+ * document they came from and linking here would send a reader to a copy.
85
+ */
86
+ export function rehypeHeadingAnchors() {
87
+ return (tree: Root) => {
88
+ visit(tree, 'element', (node: Element) => {
89
+ if (!ANCHORED_LEVELS.has(node.tagName)) return;
90
+ if (node.properties?.dataTranscluded) return;
91
+
92
+ const id = node.properties?.id;
93
+ if (typeof id !== 'string' || !id) return;
94
+
95
+ node.properties.className = [
96
+ ...(Array.isArray(node.properties.className) ? node.properties.className.map(String) : []),
97
+ 'ezw-heading',
98
+ ];
99
+
100
+ node.children.push({
101
+ type: 'element',
102
+ tagName: 'a',
103
+ properties: {
104
+ href: `#${id}`,
105
+ className: ['ezw-heading__anchor'],
106
+ // The heading's own text already names the destination; without this
107
+ // a screen reader hears every heading followed by a stray "#".
108
+ 'aria-label': `Link to this section: ${toString(node)}`,
109
+ },
110
+ children: [{ type: 'text', value: '#' }],
111
+ });
112
+ });
113
+ };
114
+ }
115
+
66
116
  /**
67
117
  * Determines whether an href points outside the site.
68
118
  */
@@ -0,0 +1,173 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import type { Root, Blockquote, PhrasingContent, BlockContent } from 'mdast';
3
+
4
+ /**
5
+ * Turns `> [!NOTE]` blockquotes into callouts.
6
+ *
7
+ * The syntax is GitHub's and Obsidian's alike, which is the reason for
8
+ * choosing it: a document written for either renders here, and one written
9
+ * here still reads as an ordinary blockquote anywhere that does not know the
10
+ * convention. Nothing is invented.
11
+ *
12
+ * Runs on the Markdown AST so the body passes through the rest of the pipeline
13
+ * unchanged — links, code and wiki links inside a callout behave exactly as
14
+ * they do outside one.
15
+ */
16
+
17
+ /**
18
+ * Marker opening a callout: the kind, an optional fold hint, and an optional
19
+ * title on the same line.
20
+ */
21
+ const MARKER = /^\[!([A-Za-z]+)\]([-+])?\s*(.*)$/;
22
+
23
+ /**
24
+ * Callout kinds, and the ones that are the same thing under another name.
25
+ *
26
+ * GitHub defines five; Obsidian defines more and its vaults use them, so the
27
+ * extra names map onto the nearest kind rather than falling back to a plain
28
+ * quote. A vault should not lose its formatting on the way in.
29
+ */
30
+ const KINDS: Record<string, string> = {
31
+ note: 'note',
32
+ info: 'note',
33
+ abstract: 'note',
34
+ summary: 'note',
35
+ tip: 'tip',
36
+ hint: 'tip',
37
+ success: 'tip',
38
+ check: 'tip',
39
+ done: 'tip',
40
+ important: 'important',
41
+ example: 'important',
42
+ question: 'important',
43
+ help: 'important',
44
+ faq: 'important',
45
+ warning: 'warning',
46
+ attention: 'warning',
47
+ todo: 'warning',
48
+ caution: 'caution',
49
+ danger: 'caution',
50
+ error: 'caution',
51
+ failure: 'caution',
52
+ fail: 'caution',
53
+ bug: 'caution',
54
+ };
55
+
56
+ /** What a callout marker said. */
57
+ interface Marker {
58
+ /** Normalised kind, one of the values in {@link KINDS} */
59
+ kind: string;
60
+ /** Heading text, defaulting to the kind when the author gave none */
61
+ title: PhrasingContent[];
62
+ /** Whether the body folds away, and whether it starts open */
63
+ fold: 'none' | 'open' | 'closed';
64
+ }
65
+
66
+ /**
67
+ * Reads the marker from the first line of a blockquote.
68
+ *
69
+ * @param node - The blockquote to inspect
70
+ * @returns The marker, or null when this is an ordinary quote
71
+ */
72
+ function readMarker(node: Blockquote): Marker | null {
73
+ const [first] = node.children;
74
+ if (!first || first.type !== 'paragraph') return null;
75
+
76
+ const [lead] = first.children;
77
+ if (!lead || lead.type !== 'text') return null;
78
+
79
+ // Only the first line carries the marker; the rest of the paragraph is body.
80
+ const newline = lead.value.indexOf('\n');
81
+ const head = newline === -1 ? lead.value : lead.value.slice(0, newline);
82
+
83
+ const match = MARKER.exec(head.trim());
84
+ if (!match) return null;
85
+
86
+ const kind = KINDS[match[1].toLowerCase()];
87
+ if (!kind) return null;
88
+
89
+ const rest = newline === -1 ? '' : lead.value.slice(newline + 1);
90
+ const heading = match[3].trim();
91
+
92
+ // Everything after the marker line stays in the body, including any inline
93
+ // nodes that followed the opening text.
94
+ const body: PhrasingContent[] = [
95
+ ...(rest ? [{ type: 'text' as const, value: rest }] : []),
96
+ ...first.children.slice(1),
97
+ ];
98
+
99
+ node.children = [
100
+ ...(body.length ? [{ type: 'paragraph' as const, children: body }] : []),
101
+ ...node.children.slice(1),
102
+ ];
103
+
104
+ return {
105
+ kind,
106
+ title: [{ type: 'text', value: heading || titleFor(kind) }],
107
+ fold: match[2] === '-' ? 'closed' : match[2] === '+' ? 'open' : 'none',
108
+ };
109
+ }
110
+
111
+ /** Default heading for a kind, when the author supplied none. */
112
+ function titleFor(kind: string): string {
113
+ return kind.charAt(0).toUpperCase() + kind.slice(1);
114
+ }
115
+
116
+ /**
117
+ * Builds the callout node.
118
+ *
119
+ * A foldable callout becomes `<details>`, which opens and closes without any
120
+ * script — the browser already knows how to do this, and a disclosure that
121
+ * depends on JavaScript is one that fails with it disabled.
122
+ */
123
+ function toCallout(node: Blockquote, marker: Marker): Blockquote {
124
+ const foldable = marker.fold !== 'none';
125
+
126
+ const heading: BlockContent = {
127
+ type: 'paragraph',
128
+ data: {
129
+ hName: foldable ? 'summary' : 'p',
130
+ hProperties: { className: ['ezw-callout__title'] },
131
+ },
132
+ children: marker.title,
133
+ };
134
+
135
+ return {
136
+ type: 'blockquote',
137
+ data: {
138
+ hName: foldable ? 'details' : 'div',
139
+ hProperties: {
140
+ className: ['ezw-callout', `ezw-callout--${marker.kind}`],
141
+ ...(marker.fold === 'open' ? { open: true } : {}),
142
+ },
143
+ },
144
+ children: [heading, ...node.children],
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Remark plugin factory.
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * unified().use(remarkParse).use(remarkCallouts);
154
+ * // > [!WARNING] Mind the gap
155
+ * // > Body text.
156
+ * ```
157
+ */
158
+ export function remarkCallouts() {
159
+ return (tree: Root) => {
160
+ visit(tree, 'blockquote', (node: Blockquote, index, parent) => {
161
+ if (!parent || index === undefined) return;
162
+
163
+ const marker = readMarker(node);
164
+ if (!marker) return;
165
+
166
+ parent.children[index] = toCallout(node, marker);
167
+
168
+ // Skip the node just written: its children have already been read, and
169
+ // revisiting would look at a blockquote that is now a callout.
170
+ return index + 1;
171
+ });
172
+ };
173
+ }
@@ -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);