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
@@ -0,0 +1,164 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+
6
+ /**
7
+ * Shows where a wiki link goes before a reader commits to following it.
8
+ *
9
+ * The title and summary are already on the anchor as `data-preview-*`,
10
+ * written there during the build, so the card costs no request and appears
11
+ * immediately. A single delegated listener covers every link on the page,
12
+ * including any that arrive with a transcluded block.
13
+ *
14
+ * Rendered into `document.body` rather than in place. The card is positioned
15
+ * against the viewport, and `position: fixed` resolves against the nearest
16
+ * transformed ancestor instead — which the page-transition wrapper is, so a
17
+ * card rendered here landed roughly a screenful off the top of the window.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <article dangerouslySetInnerHTML={{ __html: html }} />
22
+ * <LinkPreview />
23
+ * ```
24
+ */
25
+
26
+ /** Milliseconds a pointer must rest on a link before the card appears. */
27
+ const OPEN_DELAY_MS = 350;
28
+
29
+ /** Milliseconds before a card closes, so a pointer may cross a gap. */
30
+ const CLOSE_DELAY_MS = 120;
31
+
32
+ /** Distance from the link to the card. */
33
+ const OFFSET_PX = 8;
34
+
35
+ /** Card width, needed here to keep it on screen. */
36
+ const WIDTH_PX = 320;
37
+
38
+ interface PreviewState {
39
+ title: string;
40
+ excerpt: string;
41
+ top: number;
42
+ left: number;
43
+ /** Whether the card sits below its link rather than above */
44
+ below: boolean;
45
+ }
46
+
47
+ export function LinkPreview() {
48
+ const [preview, setPreview] = useState<PreviewState | null>(null);
49
+ const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
50
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
51
+
52
+ useEffect(() => {
53
+ function clearTimers() {
54
+ if (openTimer.current) clearTimeout(openTimer.current);
55
+ if (closeTimer.current) clearTimeout(closeTimer.current);
56
+ }
57
+
58
+ function place(anchor: HTMLElement): PreviewState | null {
59
+ const title = anchor.getAttribute('data-preview-title');
60
+ if (!title) return null;
61
+
62
+ const rect = anchor.getBoundingClientRect();
63
+
64
+ // Above the link by default, below it when there is no room above —
65
+ // a card that opens off the top of the viewport shows nothing.
66
+ const below = rect.top < 160;
67
+
68
+ return {
69
+ title,
70
+ excerpt: anchor.getAttribute('data-preview') ?? '',
71
+ top: below ? rect.bottom + OFFSET_PX : rect.top - OFFSET_PX,
72
+ left: Math.max(8, Math.min(rect.left, document.documentElement.clientWidth - WIDTH_PX - 8)),
73
+ below,
74
+ };
75
+ }
76
+
77
+ function open(anchor: HTMLElement, delay: number) {
78
+ clearTimers();
79
+ openTimer.current = setTimeout(() => {
80
+ const next = place(anchor);
81
+ if (next) setPreview(next);
82
+ }, delay);
83
+ }
84
+
85
+ function close(delay = CLOSE_DELAY_MS) {
86
+ clearTimers();
87
+ closeTimer.current = setTimeout(() => setPreview(null), delay);
88
+ }
89
+
90
+ function anchorFrom(target: EventTarget | null): HTMLElement | null {
91
+ return (target as HTMLElement | null)?.closest?.<HTMLElement>('a.ezw-wikilink') ?? null;
92
+ }
93
+
94
+ function handleOver(event: MouseEvent) {
95
+ const anchor = anchorFrom(event.target);
96
+ if (anchor) open(anchor, OPEN_DELAY_MS);
97
+ }
98
+
99
+ function handleOut(event: MouseEvent) {
100
+ if (anchorFrom(event.target)) close();
101
+ }
102
+
103
+ // Keyboard users reach the link by tabbing, and get the same card without
104
+ // the delay a pointer needs to signal intent.
105
+ function handleFocus(event: FocusEvent) {
106
+ const anchor = anchorFrom(event.target);
107
+ if (anchor) open(anchor, 0);
108
+ }
109
+
110
+ function handleBlur(event: FocusEvent) {
111
+ if (anchorFrom(event.target)) close(0);
112
+ }
113
+
114
+ // Dismissible without moving the pointer, which WCAG asks of anything that
115
+ // appears on hover.
116
+ function handleKey(event: KeyboardEvent) {
117
+ if (event.key === 'Escape') close(0);
118
+ }
119
+
120
+ document.addEventListener('mouseover', handleOver);
121
+ document.addEventListener('mouseout', handleOut);
122
+ document.addEventListener('focusin', handleFocus);
123
+ document.addEventListener('focusout', handleBlur);
124
+ // Any movement of the page leaves the card pointing at nothing. Named so
125
+ // that the cleanup below can actually remove it.
126
+ function handleScroll() {
127
+ close(0);
128
+ }
129
+
130
+ document.addEventListener('keydown', handleKey);
131
+ window.addEventListener('scroll', handleScroll, { passive: true });
132
+
133
+ return () => {
134
+ clearTimers();
135
+ document.removeEventListener('mouseover', handleOver);
136
+ document.removeEventListener('mouseout', handleOut);
137
+ document.removeEventListener('focusin', handleFocus);
138
+ document.removeEventListener('focusout', handleBlur);
139
+ document.removeEventListener('keydown', handleKey);
140
+ window.removeEventListener('scroll', handleScroll);
141
+ };
142
+ }, []);
143
+
144
+ if (!preview || typeof document === 'undefined') return null;
145
+
146
+ return createPortal(
147
+ <div
148
+ // Presentational: the link it describes is already in the accessible
149
+ // tree, and announcing the summary twice would be noise.
150
+ aria-hidden="true"
151
+ className="ezw-link-preview"
152
+ style={{
153
+ top: preview.top,
154
+ left: preview.left,
155
+ width: WIDTH_PX,
156
+ transform: preview.below ? undefined : 'translateY(-100%)',
157
+ }}
158
+ >
159
+ <p className="ezw-link-preview__title">{preview.title}</p>
160
+ {preview.excerpt && <p className="ezw-link-preview__excerpt">{preview.excerpt}</p>}
161
+ </div>,
162
+ document.body,
163
+ );
164
+ }
@@ -1,4 +1,5 @@
1
1
  import { CodeCopy } from './CodeCopy';
2
+ import { LinkPreview } from './LinkPreview';
2
3
 
3
4
  /**
4
5
  * Props for the MarkdownContent component
@@ -13,7 +14,7 @@ interface MarkdownContentProps {
13
14
  *
14
15
  * The markup arrives already parsed, highlighted, and link-resolved from
15
16
  * `renderDoc()`, so this is a server component that emits static HTML. The only
16
- * client-side code is the small copy-button listener.
17
+ * client-side code is the copy-button and link-preview listeners.
17
18
  *
18
19
  * Passing build-time output to `dangerouslySetInnerHTML` is safe here in the
19
20
  * sense that matters: the input is the repository's own content files, not user
@@ -33,6 +34,7 @@ export function MarkdownContent({ html }: MarkdownContentProps) {
33
34
  <>
34
35
  <div className="ezw-prose" dangerouslySetInnerHTML={{ __html: html }} />
35
36
  <CodeCopy />
37
+ <LinkPreview />
36
38
  </>
37
39
  );
38
40
  }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getAliasMap, resolveAlias, aliasUrl, resolveAliasUrl } from './aliases';
3
+ import { getContentRegistry } from './registry';
4
+
5
+ describe('alias parsing', () => {
6
+ // An author moving one page writes one path; requiring a list would be a
7
+ // rule to remember for no gain.
8
+ it('accepts a single alias or a list', () => {
9
+ const { docs } = getContentRegistry();
10
+
11
+ for (const doc of docs) {
12
+ expect(Array.isArray(doc.aliases)).toBe(true);
13
+ }
14
+ });
15
+
16
+ it('leaves documents without aliases empty', () => {
17
+ const { byPath } = getContentRegistry();
18
+
19
+ expect(byPath.get('intro')?.aliases).toEqual([]);
20
+ });
21
+ });
22
+
23
+ describe('getAliasMap', () => {
24
+ it('maps a former path to the document that superseded it', () => {
25
+ const map = getAliasMap();
26
+
27
+ for (const [alias, target] of map) {
28
+ expect(alias).not.toBe(target);
29
+ expect(getContentRegistry().byPath.has(target)).toBe(true);
30
+ }
31
+ });
32
+
33
+ // An alias shadowing a live page would make that page unreachable, so it is
34
+ // a build error rather than something resolved by precedence.
35
+ it('never claims a path a page occupies', () => {
36
+ const { byPath } = getContentRegistry();
37
+
38
+ for (const alias of getAliasMap().keys()) {
39
+ expect(byPath.has(alias)).toBe(false);
40
+ }
41
+ });
42
+ });
43
+
44
+ describe('aliasUrl', () => {
45
+ it('is the path itself under the path strategy', () => {
46
+ expect(aliasUrl('guides/setup', 'path')).toBe('guides/setup');
47
+ });
48
+
49
+ // The old URL under `hash` was the digest of the old path, so reproducing it
50
+ // is what makes the address a reader still has keep working.
51
+ it('is the digest of the path under the hash strategy', () => {
52
+ const url = aliasUrl('guides/setup', 'hash');
53
+
54
+ expect(url).toMatch(/^[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{8}$/);
55
+ expect(aliasUrl('guides/setup', 'hash')).toBe(url);
56
+ expect(aliasUrl('guides/other', 'hash')).not.toBe(url);
57
+ });
58
+
59
+ it('tolerates surrounding slashes', () => {
60
+ expect(aliasUrl('/guides/setup/', 'path')).toBe('guides/setup');
61
+ });
62
+ });
63
+
64
+ describe('resolveAliasUrl', () => {
65
+ it('resolves a former URL back to the current document under both strategies', () => {
66
+ for (const [alias, target] of getAliasMap()) {
67
+ expect(resolveAliasUrl(aliasUrl(alias, 'path'), 'path')).toBe(target);
68
+ expect(resolveAliasUrl(aliasUrl(alias, 'hash'), 'hash')).toBe(target);
69
+ }
70
+ });
71
+
72
+ it('resolves nothing for an address no page ever had', () => {
73
+ expect(resolveAliasUrl('never/existed', 'path')).toBeNull();
74
+ expect(resolveAlias('never/existed')).toBeNull();
75
+ });
76
+ });
@@ -0,0 +1,114 @@
1
+ import { getContentRegistry } from './registry';
2
+ import { cached } from '../cache';
3
+ import { normalizeSlug, type UrlStrategy } from '../navigation/url';
4
+ import { generatePathHash } from '../navigation/hash';
5
+
6
+ /**
7
+ * Former locations of documents, and where they now live.
8
+ *
9
+ * URLs are derived from content paths, so moving `guides/setup.md` to
10
+ * `getting-started/setup.md` changes the published URL and every link, bookmark
11
+ * and search result pointing at the old one stops working. Wiki links survive
12
+ * the move — they resolve by name — but nothing arriving from outside does.
13
+ * Declaring the old path in frontmatter keeps it answering.
14
+ *
15
+ * Server-only: reads the content registry.
16
+ */
17
+
18
+ /** Alias content path mapped to the document that superseded it. */
19
+ export type AliasMap = Map<string, string>;
20
+
21
+ let memo: AliasMap | null = null;
22
+
23
+ /**
24
+ * Builds the alias index.
25
+ *
26
+ * Two kinds of clash are refused rather than resolved. An alias naming a real
27
+ * page would shadow it, making a live document unreachable; an alias claimed by
28
+ * two documents has no answer, and picking either would send readers somewhere
29
+ * arbitrary. Both are mistakes in the content, and both are cheaper to find at
30
+ * build time than as a wrong page in production.
31
+ *
32
+ * @returns Old path to current path
33
+ * @throws Error when an alias shadows a page or is claimed twice
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * getAliasMap().get('guides/setup'); // 'getting-started/setup'
38
+ * ```
39
+ */
40
+ export function getAliasMap(): AliasMap {
41
+ const hit = cached(memo);
42
+ if (hit) return hit;
43
+
44
+ const { docs, byPath } = getContentRegistry();
45
+ const map: AliasMap = new Map();
46
+
47
+ for (const doc of docs) {
48
+ for (const alias of doc.aliases) {
49
+ if (byPath.has(alias)) {
50
+ throw new Error(
51
+ `Alias collision: '${alias}' in content/${doc.path}.md is also a page ` +
52
+ `(content/${alias}.md). An alias may only name a path no page occupies.`,
53
+ );
54
+ }
55
+
56
+ const claimed = map.get(alias);
57
+ if (claimed && claimed !== doc.path) {
58
+ throw new Error(
59
+ `Alias collision: '${alias}' is claimed by both content/${claimed}.md ` +
60
+ `and content/${doc.path}.md. Remove it from one of them.`,
61
+ );
62
+ }
63
+
64
+ map.set(alias, doc.path);
65
+ }
66
+ }
67
+
68
+ memo = map;
69
+ return memo;
70
+ }
71
+
72
+ /**
73
+ * Finds the document an old path now points to.
74
+ *
75
+ * @param path - Content path as it used to be written
76
+ * @returns The current content path, or null when nothing claims it
77
+ */
78
+ export function resolveAlias(path: string): string | null {
79
+ return getAliasMap().get(path) ?? null;
80
+ }
81
+
82
+ /**
83
+ * The URL segment an alias answers on.
84
+ *
85
+ * Built with the same rule the live map uses, so the address a reader has
86
+ * really is the one that is served: under `path` the old content path, under
87
+ * `hash` the digest of it, which is exactly what the old URL was before the
88
+ * page moved.
89
+ *
90
+ * @param alias - Alias content path
91
+ * @param strategy - URL strategy in force
92
+ * @returns The segment, without leading or trailing slashes
93
+ */
94
+ export function aliasUrl(alias: string, strategy: UrlStrategy): string {
95
+ const normalized = normalizeSlug(alias);
96
+ return strategy === 'hash' ? generatePathHash(normalized) : normalized;
97
+ }
98
+
99
+ /**
100
+ * Resolves a URL segment to the document that superseded it.
101
+ *
102
+ * @param url - URL segment as requested
103
+ * @param strategy - URL strategy in force
104
+ * @returns The current content path, or null when the segment is not an alias
105
+ */
106
+ export function resolveAliasUrl(url: string, strategy: UrlStrategy): string | null {
107
+ const wanted = normalizeSlug(url);
108
+
109
+ for (const [alias, target] of getAliasMap()) {
110
+ if (aliasUrl(alias, strategy) === wanted) return target;
111
+ }
112
+
113
+ return null;
114
+ }
@@ -0,0 +1,158 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { cached } from '../cache';
4
+
5
+ /**
6
+ * Index of the static files a page can embed.
7
+ *
8
+ * `![[diagram.png]]` names a file the way a vault does — by its name, with no
9
+ * path — because that is what an author remembers and what Obsidian accepts.
10
+ * Turning that into a URL means knowing every file under `public/`, so they are
11
+ * scanned once and indexed by both their full path and their bare filename.
12
+ *
13
+ * Server-only: reads the filesystem.
14
+ */
15
+
16
+ /** Directory whose contents are served from the site root. */
17
+ export const PUBLIC_DIR = path.join(process.cwd(), 'public');
18
+
19
+ /**
20
+ * Directories under `public/` that hold generated or structural files rather
21
+ * than embeddable assets. Indexing them would let `![[search-index.json]]`
22
+ * resolve, which is never what an author meant.
23
+ */
24
+ const SKIP_DIRS = new Set(['fonts']);
25
+
26
+ /** Extensions an embed may point at. */
27
+ const EMBEDDABLE = new Set([
28
+ '.png',
29
+ '.jpg',
30
+ '.jpeg',
31
+ '.gif',
32
+ '.webp',
33
+ '.avif',
34
+ '.svg',
35
+ '.bmp',
36
+ '.ico',
37
+ ]);
38
+
39
+ /** A file under `public/` that a page may embed. */
40
+ export interface Asset {
41
+ /** Path relative to `public/`, e.g. 'images/docs/sample.jpg' */
42
+ path: string;
43
+ /** Root-relative URL, before the deployment base path is applied */
44
+ url: string;
45
+ }
46
+
47
+ /** Assets indexed for lookup. */
48
+ export interface AssetRegistry {
49
+ /** Every embeddable asset found */
50
+ assets: Asset[];
51
+ /** By path relative to `public/`, lowercased */
52
+ byPath: Map<string, Asset>;
53
+ /** By bare filename, lowercased; several files may share one */
54
+ byName: Map<string, Asset[]>;
55
+ }
56
+
57
+ let memo: AssetRegistry | null = null;
58
+
59
+ /**
60
+ * Collects embeddable files beneath a directory.
61
+ *
62
+ * @param dir - Directory to scan
63
+ * @param root - Directory that paths are made relative to
64
+ * @returns Paths relative to `root`, using forward slashes
65
+ */
66
+ function walkAssets(dir: string, root: string): string[] {
67
+ if (!fs.existsSync(dir)) return [];
68
+
69
+ const found: string[] = [];
70
+
71
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
72
+ if (entry.name.startsWith('.')) continue;
73
+
74
+ const full = path.join(dir, entry.name);
75
+
76
+ if (entry.isDirectory()) {
77
+ if (dir === root && SKIP_DIRS.has(entry.name)) continue;
78
+ found.push(...walkAssets(full, root));
79
+ continue;
80
+ }
81
+
82
+ if (!EMBEDDABLE.has(path.extname(entry.name).toLowerCase())) continue;
83
+
84
+ found.push(path.relative(root, full).split(path.sep).join('/'));
85
+ }
86
+
87
+ return found;
88
+ }
89
+
90
+ /**
91
+ * Scans `public/` and indexes what an embed may point at.
92
+ *
93
+ * Memoised for the process, like the content registry, and for the same
94
+ * reason: the files cannot change during a build.
95
+ *
96
+ * @returns The populated registry
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * const { byName } = getAssetRegistry();
101
+ * byName.get('sample.jpg')?.[0].url; // '/images/docs/sample.jpg'
102
+ * ```
103
+ */
104
+ export function getAssetRegistry(): AssetRegistry {
105
+ const hit = cached(memo);
106
+ if (hit) return hit;
107
+
108
+ const assets: Asset[] = walkAssets(PUBLIC_DIR, PUBLIC_DIR).map((relative) => ({
109
+ path: relative,
110
+ url: `/${relative}`,
111
+ }));
112
+
113
+ const byPath = new Map(assets.map((asset) => [asset.path.toLowerCase(), asset]));
114
+ const byName = new Map<string, Asset[]>();
115
+
116
+ for (const asset of assets) {
117
+ const name = path.basename(asset.path).toLowerCase();
118
+ const existing = byName.get(name);
119
+ if (existing) existing.push(asset);
120
+ else byName.set(name, [asset]);
121
+ }
122
+
123
+ memo = { assets, byPath, byName };
124
+ return memo;
125
+ }
126
+
127
+ /**
128
+ * Resolves an embed target to a file under `public/`.
129
+ *
130
+ * A full path wins over a bare filename, so `![[icons/logo.svg]]` is
131
+ * unambiguous even when another `logo.svg` exists elsewhere. A bare name that
132
+ * matches more than one file resolves to nothing rather than guessing: picking
133
+ * whichever was scanned first would silently embed the wrong image, and the
134
+ * author would have no indication of it.
135
+ *
136
+ * @param target - Text between the brackets, e.g. 'sample.jpg'
137
+ * @returns The asset, or null when nothing or too much matches
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * resolveAsset('sample.jpg')?.url; // '/images/docs/sample.jpg'
142
+ * resolveAsset('/images/docs/sample.jpg')?.url; // '/images/docs/sample.jpg'
143
+ * ```
144
+ */
145
+ export function resolveAsset(target: string): Asset | null {
146
+ const key = target.trim().replace(/^\/+/, '').toLowerCase();
147
+ if (!key) return null;
148
+
149
+ const { byPath, byName } = getAssetRegistry();
150
+
151
+ const exact = byPath.get(key);
152
+ if (exact) return exact;
153
+
154
+ const matches = byName.get(key);
155
+ if (matches?.length === 1) return matches[0];
156
+
157
+ return null;
158
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildExcerpt, getExcerpt } from './excerpt';
3
+
4
+ describe('buildExcerpt', () => {
5
+ it('prefers the description an author wrote', () => {
6
+ expect(buildExcerpt('# Title\n\nThe body.', 'A deliberate summary')).toBe(
7
+ 'A deliberate summary',
8
+ );
9
+ });
10
+
11
+ it('falls back to the opening prose', () => {
12
+ expect(buildExcerpt('# Title\n\nThe first sentence.')).toBe('The first sentence.');
13
+ });
14
+
15
+ // The card shows the title on its own line, so repeating it as the summary
16
+ // would waste the space there is.
17
+ it('skips the title heading', () => {
18
+ expect(buildExcerpt('# Page Title\n\nBody text.')).not.toContain('Page Title');
19
+ });
20
+
21
+ it('skips code, rules and tables to reach the prose', () => {
22
+ const markdown = '# T\n\n```bash\nnpm install\n```\n\n---\n\nThe actual sentence.';
23
+
24
+ expect(buildExcerpt(markdown)).toBe('The actual sentence.');
25
+ });
26
+
27
+ // A page opening with a figure should preview as the sentence under it, not
28
+ // as the image's alt text.
29
+ it('leaves image alt text out', () => {
30
+ expect(buildExcerpt('# T\n\n![a diagram of the pipeline](/x.png)\n\nReal prose.')).toBe(
31
+ 'Real prose.',
32
+ );
33
+ });
34
+
35
+ it('flattens inline markup and whitespace', () => {
36
+ expect(buildExcerpt('# T\n\nSome **bold**\nand `code` here.')).toBe('Some bold and code here.');
37
+ });
38
+
39
+ it('cuts at a word boundary rather than mid-word', () => {
40
+ const long = `# T\n\n${'alpha '.repeat(80)}`;
41
+ const excerpt = buildExcerpt(long);
42
+
43
+ expect(excerpt.endsWith('…')).toBe(true);
44
+ expect(excerpt).not.toMatch(/alph…$/);
45
+ expect(excerpt.length).toBeLessThanOrEqual(181);
46
+ });
47
+
48
+ it('returns nothing for a document with no prose', () => {
49
+ expect(buildExcerpt('# Only a title\n')).toBe('');
50
+ });
51
+ });
52
+
53
+ describe('getExcerpt', () => {
54
+ // `intro` is the one document both this repository and a freshly scaffolded
55
+ // project have, so this test travels with the engine rather than having to be
56
+ // held back from it.
57
+ it('summarises a document from the registry', () => {
58
+ expect(getExcerpt('intro').length).toBeGreaterThan(0);
59
+ });
60
+
61
+ it('returns the same summary on a repeat call', () => {
62
+ expect(getExcerpt('intro')).toBe(getExcerpt('intro'));
63
+ });
64
+
65
+ it('returns an empty summary for an unknown path', () => {
66
+ expect(getExcerpt('no/such/document')).toBe('');
67
+ });
68
+ });