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
@@ -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
- return url ? { url: `/${url}/`, title: doc.title } : null;
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, which reads the ids it adds.
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(remarkWikiLinks, resolveWikiLink)
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(markdown: string): Promise<RenderedMarkdown> {
162
- const file = await getProcessor().process(markdown);
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(file),
166
- headings: (file.data.headings as Heading[] | undefined) ?? [],
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 = /\[\[([^\]\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
 
@@ -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",