create-eziwiki 0.2.0 → 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/package.json +1 -1
- package/template/app/[...slug]/page.tsx +56 -2
- package/template/components/layout/MovedPage.tsx +45 -0
- package/template/components/layout/PageNavigation.tsx +73 -0
- package/template/lib/content/aliases.test.ts +76 -0
- package/template/lib/content/aliases.ts +114 -0
- package/template/lib/content/registry.ts +31 -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 +45 -0
- package/template/lib/markdown/remark-callout.ts +173 -0
- package/template/lib/markdown/render.ts +15 -1
- package/template/lib/navigation/sequence.test.ts +73 -0
- package/template/lib/navigation/sequence.ts +100 -0
- package/template/package-lock.json +29 -0
- package/template/package.json +1 -0
- package/template/scripts/check-links.ts +60 -18
- package/template/styles/markdown.css +157 -0
package/package.json
CHANGED
|
@@ -3,7 +3,11 @@ import { PageTransition } from '@/components/markdown/PageTransition';
|
|
|
3
3
|
import { TableOfContents } from '@/components/layout/TableOfContents';
|
|
4
4
|
import { Backlinks } from '@/components/layout/Backlinks';
|
|
5
5
|
import { LocalGraph } from '@/components/layout/LocalGraph';
|
|
6
|
+
import { PageNavigation } from '@/components/layout/PageNavigation';
|
|
7
|
+
import { MovedPage } from '@/components/layout/MovedPage';
|
|
6
8
|
import { getBacklinks, getLocalGraph } from '@/lib/graph/build';
|
|
9
|
+
import { getAdjacentPages } from '@/lib/navigation/sequence';
|
|
10
|
+
import { getAliasMap, aliasUrl, resolveAliasUrl } from '@/lib/content/aliases';
|
|
7
11
|
import { renderDoc } from '@/lib/markdown/render';
|
|
8
12
|
import { getDoc, type ContentDoc } from '@/lib/content/registry';
|
|
9
13
|
import { docPathToUrl, urlToDocPath } from '@/lib/navigation/url';
|
|
@@ -36,6 +40,25 @@ function resolveSlug(slug: string[]): { path: string; url: string } | null {
|
|
|
36
40
|
return path ? { path, url } : null;
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolves a slug that names a page's former address.
|
|
45
|
+
*
|
|
46
|
+
* Checked only after the live map misses, so a real page always wins over an
|
|
47
|
+
* alias — an alias shadowing a page is refused when the index is built, but
|
|
48
|
+
* order here makes the intent explicit.
|
|
49
|
+
*
|
|
50
|
+
* @param slug - Route segments captured by the catch-all route
|
|
51
|
+
* @returns The document that superseded the address, and its URL, or null
|
|
52
|
+
*/
|
|
53
|
+
function resolveMoved(slug: string[]): { path: string; url: string } | null {
|
|
54
|
+
const { urlMap } = getSite();
|
|
55
|
+
const path = resolveAliasUrl(slug.join('/'), urlMap.strategy);
|
|
56
|
+
if (!path) return null;
|
|
57
|
+
|
|
58
|
+
const url = docPathToUrl(urlMap, path);
|
|
59
|
+
return url ? { path, url } : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
/**
|
|
40
63
|
* Generates per-page metadata from the document's frontmatter.
|
|
41
64
|
*/
|
|
@@ -45,6 +68,21 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
45
68
|
const doc = resolved ? getDoc(resolved.path) : undefined;
|
|
46
69
|
|
|
47
70
|
if (!resolved || !doc) {
|
|
71
|
+
const moved = resolveMoved(params.slug);
|
|
72
|
+
const target = moved ? getDoc(moved.path) : undefined;
|
|
73
|
+
|
|
74
|
+
// A former address should not compete with the page it forwards to: it is
|
|
75
|
+
// kept out of the index, and points its canonical at the destination so any
|
|
76
|
+
// ranking the old URL earned transfers rather than being split.
|
|
77
|
+
if (moved && target) {
|
|
78
|
+
return {
|
|
79
|
+
title: target.title,
|
|
80
|
+
description: target.description || global.description,
|
|
81
|
+
alternates: { canonical: pageUrl(moved.url, global.baseUrl) },
|
|
82
|
+
robots: { index: false, follow: true },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
return { title: global.title, description: global.description };
|
|
49
87
|
}
|
|
50
88
|
|
|
@@ -90,10 +128,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
90
128
|
export async function generateStaticParams() {
|
|
91
129
|
const { urlMap, docPaths } = getSite();
|
|
92
130
|
|
|
93
|
-
|
|
131
|
+
const pages = docPaths.flatMap((path) => {
|
|
94
132
|
const url = docPathToUrl(urlMap, path);
|
|
95
133
|
return url ? [{ slug: url.split('/') }] : [];
|
|
96
134
|
});
|
|
135
|
+
|
|
136
|
+
// Former addresses are built too, each as a page that forwards. Without this
|
|
137
|
+
// there is nothing at the old URL for a static host to serve.
|
|
138
|
+
const moved = [...getAliasMap().keys()].map((alias) => ({
|
|
139
|
+
slug: aliasUrl(alias, urlMap.strategy).split('/'),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
return [...pages, ...moved];
|
|
97
143
|
}
|
|
98
144
|
|
|
99
145
|
/**
|
|
@@ -136,7 +182,14 @@ function ArticleSchema({ doc, url }: { doc: ContentDoc; url: string }) {
|
|
|
136
182
|
export default async function ContentPage({ params }: PageProps) {
|
|
137
183
|
const resolved = resolveSlug(params.slug);
|
|
138
184
|
|
|
139
|
-
if (!resolved)
|
|
185
|
+
if (!resolved) {
|
|
186
|
+
const moved = resolveMoved(params.slug);
|
|
187
|
+
const target = moved ? getDoc(moved.path) : undefined;
|
|
188
|
+
|
|
189
|
+
if (moved && target) return <MovedPage url={`/${moved.url}/`} title={target.title} />;
|
|
190
|
+
|
|
191
|
+
notFound();
|
|
192
|
+
}
|
|
140
193
|
|
|
141
194
|
const doc = getDoc(resolved.path);
|
|
142
195
|
const rendered = await renderDoc(resolved.path);
|
|
@@ -149,6 +202,7 @@ export default async function ContentPage({ params }: PageProps) {
|
|
|
149
202
|
<article className="prose prose-slate min-w-0 max-w-none flex-1 dark:prose-invert">
|
|
150
203
|
<ArticleSchema doc={doc} url={resolved.url} />
|
|
151
204
|
<MarkdownContent html={rendered.html} />
|
|
205
|
+
<PageNavigation adjacent={getAdjacentPages(resolved.path)} />
|
|
152
206
|
<Backlinks links={getBacklinks(resolved.path)} />
|
|
153
207
|
<LocalGraph graph={getLocalGraph(resolved.path)} path={resolved.path} />
|
|
154
208
|
</article>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { asset } from '@/lib/basePath';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stands in for a page that has moved, and sends the reader on.
|
|
6
|
+
*
|
|
7
|
+
* A static export has no server to answer with a 301, so the redirect is a
|
|
8
|
+
* `meta refresh` in the document itself. Search engines treat that as a
|
|
9
|
+
* permanent move when the delay is zero, and the canonical link says the same
|
|
10
|
+
* thing again for anything that reads markup rather than following it.
|
|
11
|
+
*
|
|
12
|
+
* The visible text is not decoration. A reader whose browser blocks the refresh
|
|
13
|
+
* — or who arrives with scripting and meta refresh disabled — still needs a way
|
|
14
|
+
* through, so the link is real and focusable rather than a spinner.
|
|
15
|
+
*
|
|
16
|
+
* @param props - Component props
|
|
17
|
+
* @param props.url - Href of the page that superseded this address
|
|
18
|
+
* @param props.title - Title of that page
|
|
19
|
+
*/
|
|
20
|
+
export function MovedPage({ url, title }: { url: string; title: string }) {
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<meta httpEquiv="refresh" content={`0; url=${asset(url)}`} />
|
|
24
|
+
|
|
25
|
+
<div className="mx-auto max-w-lg px-6 py-24 text-center">
|
|
26
|
+
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
27
|
+
This page moved
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<h1 className="mt-3 text-2xl font-semibold text-gray-900 dark:text-gray-100">{title}</h1>
|
|
31
|
+
|
|
32
|
+
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
|
33
|
+
You are being taken there now. If nothing happens, follow the link.
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<Link
|
|
37
|
+
href={url}
|
|
38
|
+
className="mt-6 inline-block rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-900 no-underline transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
|
39
|
+
>
|
|
40
|
+
Continue to {title}
|
|
41
|
+
</Link>
|
|
42
|
+
</div>
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
3
|
+
import type { Adjacent } from '@/lib/navigation/sequence';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Links to the pages either side of this one in reading order.
|
|
7
|
+
*
|
|
8
|
+
* A guide is written to be read through, and until now the only way onwards was
|
|
9
|
+
* back to the sidebar to find where you had got to. The order is the sidebar's
|
|
10
|
+
* own, flattened, so the two cannot drift apart.
|
|
11
|
+
*
|
|
12
|
+
* `rel="prev"` and `rel="next"` say the same thing to a crawler, which is how a
|
|
13
|
+
* sequence of pages is declared to be one.
|
|
14
|
+
*
|
|
15
|
+
* @param props - Component props
|
|
16
|
+
* @param props.adjacent - Neighbours from `getAdjacentPages()`
|
|
17
|
+
*/
|
|
18
|
+
export function PageNavigation({ adjacent }: { adjacent: Adjacent }) {
|
|
19
|
+
const { previous, next } = adjacent;
|
|
20
|
+
|
|
21
|
+
// The first and last pages have one neighbour; a page outside the sequence
|
|
22
|
+
// has none, and gets nothing rather than an empty bar.
|
|
23
|
+
if (!previous && !next) return null;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<nav
|
|
27
|
+
aria-label="Page navigation"
|
|
28
|
+
className="mt-12 flex items-stretch gap-4 border-t border-gray-200 pt-6 dark:border-gray-800"
|
|
29
|
+
>
|
|
30
|
+
{previous ? (
|
|
31
|
+
<Link
|
|
32
|
+
href={previous.url}
|
|
33
|
+
rel="prev"
|
|
34
|
+
className="group flex flex-1 items-center gap-3 rounded-lg border border-gray-200 p-4 no-underline transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:hover:border-gray-700 dark:hover:bg-gray-800/50"
|
|
35
|
+
>
|
|
36
|
+
<ChevronLeft className="h-4 w-4 flex-shrink-0 text-gray-400 transition-transform group-hover:-translate-x-0.5" />
|
|
37
|
+
<span className="min-w-0">
|
|
38
|
+
<span className="block text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
39
|
+
Previous
|
|
40
|
+
</span>
|
|
41
|
+
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
42
|
+
{previous.title}
|
|
43
|
+
</span>
|
|
44
|
+
</span>
|
|
45
|
+
</Link>
|
|
46
|
+
) : (
|
|
47
|
+
// Holds the column so a lone "next" stays on the right, where it is
|
|
48
|
+
// when there is a pair.
|
|
49
|
+
<div className="flex-1" />
|
|
50
|
+
)}
|
|
51
|
+
|
|
52
|
+
{next ? (
|
|
53
|
+
<Link
|
|
54
|
+
href={next.url}
|
|
55
|
+
rel="next"
|
|
56
|
+
className="group flex flex-1 items-center justify-end gap-3 rounded-lg border border-gray-200 p-4 text-right no-underline transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:hover:border-gray-700 dark:hover:bg-gray-800/50"
|
|
57
|
+
>
|
|
58
|
+
<span className="min-w-0">
|
|
59
|
+
<span className="block text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
60
|
+
Next
|
|
61
|
+
</span>
|
|
62
|
+
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
63
|
+
{next.title}
|
|
64
|
+
</span>
|
|
65
|
+
</span>
|
|
66
|
+
<ChevronRight className="h-4 w-4 flex-shrink-0 text-gray-400 transition-transform group-hover:translate-x-0.5" />
|
|
67
|
+
</Link>
|
|
68
|
+
) : (
|
|
69
|
+
<div className="flex-1" />
|
|
70
|
+
)}
|
|
71
|
+
</nav>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -25,6 +25,13 @@ export interface ContentDoc {
|
|
|
25
25
|
order: number;
|
|
26
26
|
/** Excluded from navigation, but still reachable by direct URL */
|
|
27
27
|
hidden: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Paths this document used to live at.
|
|
30
|
+
*
|
|
31
|
+
* A wiki moves pages; without these, every published link to the old
|
|
32
|
+
* location breaks silently the moment a file is renamed.
|
|
33
|
+
*/
|
|
34
|
+
aliases: string[];
|
|
28
35
|
/** Full parsed frontmatter, for consumers that need custom fields */
|
|
29
36
|
frontmatter: Record<string, unknown>;
|
|
30
37
|
/** Markdown body with frontmatter stripped */
|
|
@@ -107,6 +114,29 @@ function readBoolean(value: unknown): boolean {
|
|
|
107
114
|
return false;
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Reads the `aliases` frontmatter into a list of content paths.
|
|
119
|
+
*
|
|
120
|
+
* Accepts a single string or a list, since an author moving one page writes
|
|
121
|
+
* one path and should not have to remember which form is required. Leading and
|
|
122
|
+
* trailing slashes and a `.md` suffix are tolerated: the value looks like a
|
|
123
|
+
* path, and being strict about its punctuation would only produce silent
|
|
124
|
+
* misses.
|
|
125
|
+
*
|
|
126
|
+
* @param value - The raw frontmatter value
|
|
127
|
+
* @returns Normalised content paths, without duplicates
|
|
128
|
+
*/
|
|
129
|
+
function readAliases(value: unknown): string[] {
|
|
130
|
+
const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [];
|
|
131
|
+
|
|
132
|
+
const paths = raw
|
|
133
|
+
.filter((entry): entry is string => typeof entry === 'string')
|
|
134
|
+
.map((entry) => entry.trim().replace(/^\/+/, '').replace(/\.md$/i, '').replace(/\/+$/, ''))
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
|
|
137
|
+
return [...new Set(paths)];
|
|
138
|
+
}
|
|
139
|
+
|
|
110
140
|
/**
|
|
111
141
|
* Reads the optional `_meta.json` for a content subdirectory.
|
|
112
142
|
*
|
|
@@ -198,6 +228,7 @@ function readDoc(filePath: string): ContentDoc | null {
|
|
|
198
228
|
description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
|
|
199
229
|
order: readOrder(frontmatter.order),
|
|
200
230
|
hidden: readBoolean(frontmatter.hidden) || frontmatter.nav === false,
|
|
231
|
+
aliases: readAliases(frontmatter.aliases),
|
|
201
232
|
frontmatter,
|
|
202
233
|
content,
|
|
203
234
|
filePath,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getWikiHealth } from './health';
|
|
3
|
+
import { getLinkGraph } from './build';
|
|
4
|
+
import { getReadingOrder } from '../navigation/sequence';
|
|
5
|
+
|
|
6
|
+
describe('getWikiHealth', () => {
|
|
7
|
+
it('finds pages nothing links to', () => {
|
|
8
|
+
const graph = getLinkGraph();
|
|
9
|
+
|
|
10
|
+
for (const page of getWikiHealth().orphans) {
|
|
11
|
+
expect(graph.backlinks.get(page.path) ?? []).toHaveLength(0);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('finds pages with no links out', () => {
|
|
16
|
+
const graph = getLinkGraph();
|
|
17
|
+
|
|
18
|
+
for (const page of getWikiHealth().deadEnds) {
|
|
19
|
+
expect(graph.outbound.get(page.path) ?? []).toHaveLength(0);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Where a reader starts needs nothing pointing at it. Reporting it on every
|
|
24
|
+
// build would teach everyone to ignore the report.
|
|
25
|
+
it('never calls the entry page an orphan', () => {
|
|
26
|
+
const [entry] = getReadingOrder();
|
|
27
|
+
|
|
28
|
+
expect(getWikiHealth().orphans.map((page) => page.path)).not.toContain(entry);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports only pages that are in the graph', () => {
|
|
32
|
+
const paths = new Set(getLinkGraph().nodes.map((node) => node.path));
|
|
33
|
+
const { orphans, deadEnds } = getWikiHealth();
|
|
34
|
+
|
|
35
|
+
for (const page of [...orphans, ...deadEnds]) {
|
|
36
|
+
expect(paths.has(page.path)).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Hidden pages are absent from the graph entirely, so an unlisted page is
|
|
41
|
+
// not reported as disconnected — it is unlisted on purpose.
|
|
42
|
+
it('says nothing about hidden pages', () => {
|
|
43
|
+
const { orphans, deadEnds } = getWikiHealth();
|
|
44
|
+
const reported = [...orphans, ...deadEnds].map((page) => page.path);
|
|
45
|
+
const visible = new Set(getLinkGraph().nodes.map((node) => node.path));
|
|
46
|
+
|
|
47
|
+
for (const path of reported) {
|
|
48
|
+
expect(visible.has(path)).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('gives every reported page a title and a link', () => {
|
|
53
|
+
const { orphans, deadEnds } = getWikiHealth();
|
|
54
|
+
|
|
55
|
+
for (const page of [...orphans, ...deadEnds]) {
|
|
56
|
+
expect(page.title).toBeTruthy();
|
|
57
|
+
expect(page.url).toBeTruthy();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getLinkGraph, type GraphNode } from './build';
|
|
2
|
+
import { getReadingOrder } from '../navigation/sequence';
|
|
3
|
+
import { cached } from '../cache';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the link graph says about the state of the wiki.
|
|
7
|
+
*
|
|
8
|
+
* A broken link is an error and already reported. These are not errors — a
|
|
9
|
+
* wiki can be perfectly correct and still have them — but they are the shapes
|
|
10
|
+
* a collection of documents falls into when it stops being a wiki: pages
|
|
11
|
+
* nothing leads to, and pages nothing leads on from. Neither is visible from
|
|
12
|
+
* inside a single document, and neither shows up in a link check, which only
|
|
13
|
+
* asks whether the links that exist resolve.
|
|
14
|
+
*
|
|
15
|
+
* Server-only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Pages the graph flags as worth a second look. */
|
|
19
|
+
export interface WikiHealth {
|
|
20
|
+
/** Pages nothing links to, so a reader can only arrive through the sidebar */
|
|
21
|
+
orphans: GraphNode[];
|
|
22
|
+
/** Pages with no links out, where a reader arrives and has nowhere to go */
|
|
23
|
+
deadEnds: GraphNode[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let memo: WikiHealth | null = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Finds pages that are disconnected from the rest of the wiki.
|
|
30
|
+
*
|
|
31
|
+
* The first page in reading order is never an orphan. It is where a reader
|
|
32
|
+
* starts, so nothing needs to point at it, and reporting it every build would
|
|
33
|
+
* teach everyone to ignore the report.
|
|
34
|
+
*
|
|
35
|
+
* @returns The pages worth looking at
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const { orphans, deadEnds } = getWikiHealth();
|
|
40
|
+
* orphans.map((page) => page.path); // ['examples/api-docs', …]
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function getWikiHealth(): WikiHealth {
|
|
44
|
+
const hit = cached(memo);
|
|
45
|
+
if (hit) return hit;
|
|
46
|
+
|
|
47
|
+
const graph = getLinkGraph();
|
|
48
|
+
const [entry] = getReadingOrder();
|
|
49
|
+
|
|
50
|
+
const orphans = graph.nodes.filter(
|
|
51
|
+
(node) => node.path !== entry && (graph.backlinks.get(node.path) ?? []).length === 0,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const deadEnds = graph.nodes.filter((node) => (graph.outbound.get(node.path) ?? []).length === 0);
|
|
55
|
+
|
|
56
|
+
memo = { orphans, deadEnds };
|
|
57
|
+
return memo;
|
|
58
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderMarkdown } from './render';
|
|
3
|
+
|
|
4
|
+
/** Renders a callout and reports what it became. */
|
|
5
|
+
async function render(markdown: string) {
|
|
6
|
+
const { html } = await renderMarkdown(`${markdown}\n`);
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
html,
|
|
10
|
+
kind: (html.match(/ezw-callout--(\w+)/) ?? [])[1] ?? null,
|
|
11
|
+
tag: (html.match(/<(details|div)[^>]*ezw-callout/) ?? [])[1] ?? null,
|
|
12
|
+
title: (html.match(/ezw-callout__title">([^<]*)/) ?? [])[1] ?? null,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('callouts', () => {
|
|
17
|
+
it('turns a marked blockquote into a callout', async () => {
|
|
18
|
+
const { kind, tag, title, html } = await render('> [!NOTE]\n> Useful information.');
|
|
19
|
+
|
|
20
|
+
expect(kind).toBe('note');
|
|
21
|
+
expect(tag).toBe('div');
|
|
22
|
+
expect(title).toBe('Note');
|
|
23
|
+
expect(html).toContain('Useful information.');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('uses a title given on the marker line', async () => {
|
|
27
|
+
expect((await render('> [!WARNING] Mind the gap\n> Careful.')).title).toBe('Mind the gap');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('recognises the kinds case-insensitively', async () => {
|
|
31
|
+
expect((await render('> [!note]\n> x')).kind).toBe('note');
|
|
32
|
+
expect((await render('> [!NoTe]\n> x')).kind).toBe('note');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Obsidian defines more kinds than GitHub and vaults use them, so they map
|
|
36
|
+
// onto the nearest one instead of losing their formatting.
|
|
37
|
+
it('maps the extra Obsidian kinds onto the nearest one', async () => {
|
|
38
|
+
expect((await render('> [!danger]\n> x')).kind).toBe('caution');
|
|
39
|
+
expect((await render('> [!success]\n> x')).kind).toBe('tip');
|
|
40
|
+
expect((await render('> [!question]\n> x')).kind).toBe('important');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('leaves an unknown kind as an ordinary quote', async () => {
|
|
44
|
+
const { kind, html } = await render('> [!nonsense]\n> x');
|
|
45
|
+
|
|
46
|
+
expect(kind).toBeNull();
|
|
47
|
+
expect(html).toContain('<blockquote>');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('leaves a plain quote alone', async () => {
|
|
51
|
+
const { kind, html } = await render('> Just a quote.');
|
|
52
|
+
|
|
53
|
+
expect(kind).toBeNull();
|
|
54
|
+
expect(html).toContain('<blockquote>');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// `<details>` opens and closes without script, so a disclosure keeps working
|
|
58
|
+
// with JavaScript disabled.
|
|
59
|
+
it('folds with a trailing - or +', async () => {
|
|
60
|
+
const closed = await render('> [!TIP]- Optional\n> Hidden.');
|
|
61
|
+
const open = await render('> [!TIP]+ Shown\n> Visible.');
|
|
62
|
+
|
|
63
|
+
expect(closed.tag).toBe('details');
|
|
64
|
+
expect(closed.html).not.toMatch(/<details[^>]*\sopen/);
|
|
65
|
+
expect(open.tag).toBe('details');
|
|
66
|
+
expect(open.html).toMatch(/<details[^>]*\sopen/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The body passes through the rest of the pipeline, so nothing inside a
|
|
70
|
+
// callout behaves differently from the same text outside one.
|
|
71
|
+
it('renders links, wiki links and code inside the body', async () => {
|
|
72
|
+
// `intro` is the one page both this repository and a scaffolded project
|
|
73
|
+
// have, so the test travels with the engine.
|
|
74
|
+
const wiki = await render('> [!NOTE]\n> See [[intro]].');
|
|
75
|
+
const code = await render('> [!TIP]\n> Run `npm i`.');
|
|
76
|
+
|
|
77
|
+
expect(wiki.html).toContain('ezw-wikilink');
|
|
78
|
+
expect(code.html).toContain('<code');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps a multi-line body together', async () => {
|
|
82
|
+
const { html } = await render('> [!NOTE]\n> First line.\n> Second line.');
|
|
83
|
+
|
|
84
|
+
expect(html).toContain('First line.');
|
|
85
|
+
expect(html).toContain('Second line.');
|
|
86
|
+
});
|
|
87
|
+
});
|