create-eziwiki 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/template/app/[...slug]/page.tsx +3 -1
- package/template/app/globals.css +8 -4
- package/template/app/layout.tsx +46 -24
- package/template/app/robots.ts +11 -3
- package/template/components/graph/GraphView.tsx +20 -7
- package/template/components/layout/LocalGraph.tsx +52 -0
- package/template/components/layout/MobileMenu.tsx +16 -1
- package/template/components/layout/PageLayout.tsx +11 -3
- package/template/components/layout/Sidebar.tsx +30 -2
- package/template/components/markdown/LinkPreview.tsx +164 -0
- package/template/components/markdown/MarkdownContent.tsx +3 -1
- package/template/components/search/SearchTrigger.tsx +11 -4
- package/template/lib/content/assets.ts +158 -0
- package/template/lib/content/excerpt.test.ts +68 -0
- package/template/lib/content/excerpt.ts +142 -0
- package/template/lib/graph/build.ts +55 -0
- package/template/lib/markdown/rehype-plugins.ts +24 -2
- package/template/lib/markdown/remark-wikilink.ts +201 -14
- package/template/lib/markdown/render.ts +130 -10
- package/template/lib/markdown/wikilink.test.ts +30 -0
- package/template/lib/markdown/wikilink.ts +12 -4
- package/template/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +7 -0
- package/template/package-lock.json +3 -83
- package/template/package.json +2 -0
- package/template/public/fonts/Pretendard/pretendard-400-korean.woff2 +0 -0
- package/template/public/fonts/Pretendard/pretendard-400-latin.woff2 +0 -0
- package/template/public/fonts/Pretendard/pretendard-600-korean.woff2 +0 -0
- package/template/public/fonts/Pretendard/pretendard-600-latin.woff2 +0 -0
- package/template/public/fonts/Pretendard/pretendard-700-korean.woff2 +0 -0
- package/template/public/fonts/Pretendard/pretendard-700-latin.woff2 +0 -0
- package/template/styles/markdown.css +76 -2
- package/template/styles/theme.css +7 -0
- package/template/vercel.json +31 -0
- package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
|
@@ -22,17 +22,24 @@ export function SearchTrigger({ className = '' }: { className?: string }) {
|
|
|
22
22
|
}, []);
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
|
+
// No aria-label: it read "Search documentation" while the control visibly
|
|
26
|
+
// says "Search…", so the spoken name did not contain the written one and
|
|
27
|
+
// voice control had nothing to match. The text names the button instead;
|
|
28
|
+
// the icon and the shortcut are hidden from the name, the latter because
|
|
29
|
+
// `aria-keyshortcuts` already announces it.
|
|
25
30
|
<button
|
|
26
31
|
type="button"
|
|
27
32
|
onClick={open}
|
|
28
|
-
aria-label="Search documentation"
|
|
29
33
|
aria-keyshortcuts="Meta+K Control+K"
|
|
30
|
-
className={`flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-
|
|
34
|
+
className={`flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-500 transition-colors hover:border-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-gray-600 ${className}`}
|
|
31
35
|
>
|
|
32
|
-
<Search className="h-4 w-4 flex-shrink-0" />
|
|
36
|
+
<Search className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
|
33
37
|
<span className="min-w-0 flex-1 truncate text-left">Search…</span>
|
|
34
38
|
{shortcut && (
|
|
35
|
-
<kbd
|
|
39
|
+
<kbd
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
className="hidden flex-shrink-0 rounded border border-gray-300 px-1 py-0.5 text-[10px] text-gray-500 sm:block dark:border-gray-600 dark:text-gray-400"
|
|
42
|
+
>
|
|
36
43
|
{shortcut}
|
|
37
44
|
</kbd>
|
|
38
45
|
)}
|
|
@@ -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\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
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { unified } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import { visit } from 'unist-util-visit';
|
|
5
|
+
import type { Root, RootContent } from 'mdast';
|
|
6
|
+
import { toString as mdastToString } from 'mdast-util-to-string';
|
|
7
|
+
import { getDoc } from './registry';
|
|
8
|
+
import { CACHE_DERIVED_CONTENT } from '../cache';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Short plain-text summaries of documents.
|
|
12
|
+
*
|
|
13
|
+
* Used for the card that appears when a reader hovers a wiki link. Deriving it
|
|
14
|
+
* from Markdown rather than from the rendered HTML keeps markup, syntax
|
|
15
|
+
* highlighting, and the `ezw-` wrappers out of what is meant to be one or two
|
|
16
|
+
* readable sentences.
|
|
17
|
+
*
|
|
18
|
+
* Server-only.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** How much of a document the card shows before trailing off. */
|
|
22
|
+
const MAX_LENGTH = 180;
|
|
23
|
+
|
|
24
|
+
/** Parser used only to reach the text; no rendering plugins. */
|
|
25
|
+
const parser = unified().use(remarkParse).use(remarkGfm);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Node types that carry no prose worth previewing.
|
|
29
|
+
*
|
|
30
|
+
* A page opening with a diagram or a command should preview as the sentence
|
|
31
|
+
* underneath it, not as an empty string or a line of shell.
|
|
32
|
+
*/
|
|
33
|
+
const SKIPPED = new Set(['code', 'thematicBreak', 'image', 'html', 'table', 'math']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a node is the document's own title heading.
|
|
37
|
+
*
|
|
38
|
+
* The card shows the title separately, so repeating it as the first line of the
|
|
39
|
+
* body wastes the little space there is.
|
|
40
|
+
*/
|
|
41
|
+
function isTitleHeading(node: RootContent): boolean {
|
|
42
|
+
return node.type === 'heading' && node.depth === 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Collapses a node's text into a single line.
|
|
47
|
+
*/
|
|
48
|
+
function flatten(node: RootContent): string {
|
|
49
|
+
// An image alt is not prose; strip it so a paragraph that is only a figure
|
|
50
|
+
// does not preview as its alt text.
|
|
51
|
+
const clone: Root = { type: 'root', children: [structuredClone(node)] };
|
|
52
|
+
visit(clone, 'image', (_image, index, parent) => {
|
|
53
|
+
if (parent && index !== undefined) parent.children.splice(index, 1);
|
|
54
|
+
return index;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return mdastToString(clone).replace(/\s+/g, ' ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds a one-or-two sentence summary of a Markdown document.
|
|
62
|
+
*
|
|
63
|
+
* Prefers the frontmatter description, which an author wrote deliberately.
|
|
64
|
+
* Otherwise takes prose from the top of the document, skipping the title and
|
|
65
|
+
* anything that is not text, and stops at a word boundary so the card never
|
|
66
|
+
* cuts mid-word.
|
|
67
|
+
*
|
|
68
|
+
* @param markdown - Document body, with frontmatter already stripped
|
|
69
|
+
* @param description - `description` from the frontmatter, when present
|
|
70
|
+
* @returns The summary, or an empty string when the document has no prose
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* buildExcerpt('# Title\n\nThe first sentence.'); // 'The first sentence.'
|
|
75
|
+
* buildExcerpt('# Title\n\nIgnored.', 'From the frontmatter'); // 'From the frontmatter'
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function buildExcerpt(markdown: string, description?: string): string {
|
|
79
|
+
if (description?.trim()) return truncate(description.trim());
|
|
80
|
+
|
|
81
|
+
const tree = parser.parse(markdown) as Root;
|
|
82
|
+
const parts: string[] = [];
|
|
83
|
+
|
|
84
|
+
for (const node of tree.children) {
|
|
85
|
+
if (isTitleHeading(node) || SKIPPED.has(node.type)) continue;
|
|
86
|
+
|
|
87
|
+
const text = flatten(node);
|
|
88
|
+
if (!text) continue;
|
|
89
|
+
|
|
90
|
+
parts.push(text);
|
|
91
|
+
if (parts.join(' ').length >= MAX_LENGTH) break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return truncate(parts.join(' '));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cache = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Returns a document's summary, memoised by path.
|
|
101
|
+
*
|
|
102
|
+
* A popular page is linked from dozens of others, and each of those links asks
|
|
103
|
+
* for the same summary while its page is rendered. Parsing the target once per
|
|
104
|
+
* build rather than once per link keeps that from showing up in build time.
|
|
105
|
+
*
|
|
106
|
+
* @param docPath - Content-relative path without extension
|
|
107
|
+
* @returns The summary, or an empty string when there is no such document
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```typescript
|
|
111
|
+
* getExcerpt('getting-started/quick-start'); // 'Get a wiki running in…'
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export function getExcerpt(docPath: string): string {
|
|
115
|
+
// Checked through the map rather than `cached()`, because an empty summary is
|
|
116
|
+
// a legitimate result and would otherwise be indistinguishable from a miss.
|
|
117
|
+
if (CACHE_DERIVED_CONTENT) {
|
|
118
|
+
const hit = cache.get(docPath);
|
|
119
|
+
if (hit !== undefined) return hit;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const doc = getDoc(docPath);
|
|
123
|
+
const excerpt = doc ? buildExcerpt(doc.content, doc.description) : '';
|
|
124
|
+
|
|
125
|
+
cache.set(docPath, excerpt);
|
|
126
|
+
return excerpt;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Shortens text to the preview length, breaking at a space.
|
|
131
|
+
*
|
|
132
|
+
* @param text - Text to shorten
|
|
133
|
+
* @returns The text, with an ellipsis when it was cut
|
|
134
|
+
*/
|
|
135
|
+
function truncate(text: string): string {
|
|
136
|
+
if (text.length <= MAX_LENGTH) return text;
|
|
137
|
+
|
|
138
|
+
const cut = text.slice(0, MAX_LENGTH);
|
|
139
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
140
|
+
|
|
141
|
+
return `${(lastSpace > MAX_LENGTH / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
142
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { Root, Link, Text } from 'mdast';
|
|
|
6
6
|
import { getContentRegistry, type ContentDoc } from '../content/registry';
|
|
7
7
|
import { resolveTarget } from '../content/resolver';
|
|
8
8
|
import { findWikiLinks } from '../markdown/wikilink';
|
|
9
|
+
import { resolveAsset } from '../content/assets';
|
|
9
10
|
import { getSite } from '../site';
|
|
10
11
|
import { cached } from '../cache';
|
|
11
12
|
|
|
@@ -117,6 +118,12 @@ function scanDoc(doc: ContentDoc): { targets: Set<string>; broken: BrokenLink[]
|
|
|
117
118
|
// An anchor-only link stays within the page and is not an edge.
|
|
118
119
|
if (!link.target) continue;
|
|
119
120
|
|
|
121
|
+
// `![[diagram.png]]` embeds a file. It is neither an edge between pages
|
|
122
|
+
// nor a broken reference, so it leaves the graph here. An embed that
|
|
123
|
+
// names no such file falls through, matching the renderer, which treats
|
|
124
|
+
// it as a link to a document.
|
|
125
|
+
if (link.embed && resolveAsset(link.target)) continue;
|
|
126
|
+
|
|
120
127
|
const resolution = resolveTarget(link.target);
|
|
121
128
|
|
|
122
129
|
if (resolution.doc) {
|
|
@@ -197,6 +204,54 @@ export function getLinkGraph(): LinkGraph {
|
|
|
197
204
|
return memo;
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
/** A page and everything one link away from it. */
|
|
208
|
+
export interface LocalGraph {
|
|
209
|
+
/** The page itself, plus its immediate neighbours */
|
|
210
|
+
nodes: GraphNode[];
|
|
211
|
+
/** Links among those nodes, including ones not touching the page */
|
|
212
|
+
edges: GraphEdge[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Returns the neighbourhood around a page.
|
|
217
|
+
*
|
|
218
|
+
* The whole-site graph answers "how is this wiki shaped"; past a few dozen
|
|
219
|
+
* pages it stops answering "what is near this one", which is the question a
|
|
220
|
+
* reader has while reading. This is that view: the page, everything it links
|
|
221
|
+
* to, everything linking to it, and the links among them — the last so the
|
|
222
|
+
* neighbours read as a cluster rather than a fan of unconnected dots.
|
|
223
|
+
*
|
|
224
|
+
* Direction is deliberately not distinguished. A reader looking for related
|
|
225
|
+
* pages cares that two are connected, not which one did the linking; the
|
|
226
|
+
* backlinks list already says that for the pages that point here.
|
|
227
|
+
*
|
|
228
|
+
* @param path - Content path of the page at the centre
|
|
229
|
+
* @returns The neighbourhood, or empty when the page has no links either way
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```typescript
|
|
233
|
+
* const { nodes, edges } = getLocalGraph('features/wiki-links');
|
|
234
|
+
* nodes.length; // the page plus its neighbours
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export function getLocalGraph(path: string): LocalGraph {
|
|
238
|
+
const graph = getLinkGraph();
|
|
239
|
+
|
|
240
|
+
const neighbours = new Set<string>([
|
|
241
|
+
...(graph.outbound.get(path) ?? []),
|
|
242
|
+
...(graph.backlinks.get(path) ?? []),
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
if (neighbours.size === 0) return { nodes: [], edges: [] };
|
|
246
|
+
|
|
247
|
+
const included = new Set<string>([path, ...neighbours]);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
nodes: graph.nodes.filter((node) => included.has(node.path)),
|
|
251
|
+
edges: graph.edges.filter((edge) => included.has(edge.from) && included.has(edge.to)),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
200
255
|
/**
|
|
201
256
|
* Returns the documents that link to a given page.
|
|
202
257
|
*
|
|
@@ -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
|
|
|
@@ -221,15 +226,32 @@ export function rehypeBasePath(basePath: string) {
|
|
|
221
226
|
}
|
|
222
227
|
|
|
223
228
|
/**
|
|
224
|
-
* Adds
|
|
229
|
+
* Adds loading hints and consistent styling hooks to content images.
|
|
230
|
+
*
|
|
231
|
+
* Every image was deferred, including the first one. On a page that opens with
|
|
232
|
+
* a figure that image is what the browser measures as the largest contentful
|
|
233
|
+
* paint, and `loading="lazy"` keeps it out of the preload scan — the request
|
|
234
|
+
* only starts once layout has reached it, so the headline metric waits on a
|
|
235
|
+
* round trip that need not have been late. The first image is therefore
|
|
236
|
+
* fetched eagerly and marked high priority; the rest, which are further down,
|
|
237
|
+
* keep deferring.
|
|
225
238
|
*/
|
|
226
239
|
export function rehypeImages() {
|
|
227
240
|
return (tree: Root) => {
|
|
241
|
+
let seen = 0;
|
|
242
|
+
|
|
228
243
|
visit(tree, 'element', (node: Element) => {
|
|
229
244
|
if (node.tagName !== 'img') return;
|
|
230
245
|
|
|
231
246
|
node.properties ??= {};
|
|
232
|
-
|
|
247
|
+
const isFirst = seen++ === 0;
|
|
248
|
+
|
|
249
|
+
if (isFirst) {
|
|
250
|
+
node.properties.loading ??= 'eager';
|
|
251
|
+
node.properties.fetchPriority ??= 'high';
|
|
252
|
+
} else {
|
|
253
|
+
node.properties.loading ??= 'lazy';
|
|
254
|
+
}
|
|
233
255
|
node.properties.decoding ??= 'async';
|
|
234
256
|
|
|
235
257
|
const className = node.properties.className;
|