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.
- package/README.md +3 -2
- package/package.json +1 -1
- package/template/app/[...slug]/page.tsx +59 -3
- package/template/app/layout.tsx +3 -1
- 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/MovedPage.tsx +45 -0
- package/template/components/layout/PageLayout.tsx +11 -3
- package/template/components/layout/PageNavigation.tsx +73 -0
- 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/lib/content/aliases.test.ts +76 -0
- package/template/lib/content/aliases.ts +114 -0
- 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/content/registry.ts +31 -0
- package/template/lib/graph/build.ts +55 -0
- package/template/lib/graph/health.test.ts +60 -0
- package/template/lib/graph/health.ts +58 -0
- package/template/lib/markdown/callout.test.ts +87 -0
- package/template/lib/markdown/mermaid.test.ts +72 -0
- package/template/lib/markdown/rehype-mermaid.ts +133 -0
- package/template/lib/markdown/rehype-plugins.ts +50 -0
- package/template/lib/markdown/remark-callout.ts +173 -0
- package/template/lib/markdown/remark-wikilink.ts +201 -14
- package/template/lib/markdown/render.ts +125 -9
- package/template/lib/markdown/wikilink.test.ts +30 -0
- package/template/lib/markdown/wikilink.ts +12 -4
- package/template/lib/navigation/sequence.test.ts +73 -0
- package/template/lib/navigation/sequence.ts +100 -0
- package/template/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +7 -0
- package/template/package-lock.json +32 -83
- package/template/package.json +3 -0
- package/template/scripts/check-links.ts +60 -18
- 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
|
|
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\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
|
+
});
|