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.
Files changed (42) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/template/app/[...slug]/page.tsx +3 -1
  4. package/template/app/globals.css +8 -4
  5. package/template/app/layout.tsx +46 -24
  6. package/template/app/robots.ts +11 -3
  7. package/template/components/graph/GraphView.tsx +20 -7
  8. package/template/components/layout/LocalGraph.tsx +52 -0
  9. package/template/components/layout/MobileMenu.tsx +16 -1
  10. package/template/components/layout/PageLayout.tsx +11 -3
  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/components/search/SearchTrigger.tsx +11 -4
  15. package/template/lib/content/assets.ts +158 -0
  16. package/template/lib/content/excerpt.test.ts +68 -0
  17. package/template/lib/content/excerpt.ts +142 -0
  18. package/template/lib/graph/build.ts +55 -0
  19. package/template/lib/markdown/rehype-plugins.ts +24 -2
  20. package/template/lib/markdown/remark-wikilink.ts +201 -14
  21. package/template/lib/markdown/render.ts +130 -10
  22. package/template/lib/markdown/wikilink.test.ts +30 -0
  23. package/template/lib/markdown/wikilink.ts +12 -4
  24. package/template/lib/payload/schema.ts +1 -0
  25. package/template/lib/payload/types.ts +7 -0
  26. package/template/package-lock.json +3 -83
  27. package/template/package.json +2 -0
  28. package/template/public/fonts/Pretendard/pretendard-400-korean.woff2 +0 -0
  29. package/template/public/fonts/Pretendard/pretendard-400-latin.woff2 +0 -0
  30. package/template/public/fonts/Pretendard/pretendard-600-korean.woff2 +0 -0
  31. package/template/public/fonts/Pretendard/pretendard-600-latin.woff2 +0 -0
  32. package/template/public/fonts/Pretendard/pretendard-700-korean.woff2 +0 -0
  33. package/template/public/fonts/Pretendard/pretendard-700-latin.woff2 +0 -0
  34. package/template/styles/markdown.css +76 -2
  35. package/template/styles/theme.css +7 -0
  36. package/template/vercel.json +31 -0
  37. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  38. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  39. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  40. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  41. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  42. 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-400 transition-colors hover:border-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-600 ${className}`}
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 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">
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![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
+ });
@@ -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 lazy loading and consistent styling hooks to content images.
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
- node.properties.loading ??= 'lazy';
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;