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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-eziwiki",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a new eziwiki documentation site",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- return docPaths.flatMap((path) => {
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) notFound();
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
+ });