create-eziwiki 0.2.0 → 0.4.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.4.0",
4
4
  "description": "Scaffold a new eziwiki documentation site",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,13 @@ 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 { PageTags } from '@/components/layout/PageTags';
8
+ import { MovedPage } from '@/components/layout/MovedPage';
6
9
  import { getBacklinks, getLocalGraph } from '@/lib/graph/build';
10
+ import { getAdjacentPages } from '@/lib/navigation/sequence';
11
+ import { getAliasMap, aliasUrl, resolveAliasUrl } from '@/lib/content/aliases';
12
+ import { getTagsFor } from '@/lib/content/tags';
7
13
  import { renderDoc } from '@/lib/markdown/render';
8
14
  import { getDoc, type ContentDoc } from '@/lib/content/registry';
9
15
  import { docPathToUrl, urlToDocPath } from '@/lib/navigation/url';
@@ -36,6 +42,25 @@ function resolveSlug(slug: string[]): { path: string; url: string } | null {
36
42
  return path ? { path, url } : null;
37
43
  }
38
44
 
45
+ /**
46
+ * Resolves a slug that names a page's former address.
47
+ *
48
+ * Checked only after the live map misses, so a real page always wins over an
49
+ * alias — an alias shadowing a page is refused when the index is built, but
50
+ * order here makes the intent explicit.
51
+ *
52
+ * @param slug - Route segments captured by the catch-all route
53
+ * @returns The document that superseded the address, and its URL, or null
54
+ */
55
+ function resolveMoved(slug: string[]): { path: string; url: string } | null {
56
+ const { urlMap } = getSite();
57
+ const path = resolveAliasUrl(slug.join('/'), urlMap.strategy);
58
+ if (!path) return null;
59
+
60
+ const url = docPathToUrl(urlMap, path);
61
+ return url ? { path, url } : null;
62
+ }
63
+
39
64
  /**
40
65
  * Generates per-page metadata from the document's frontmatter.
41
66
  */
@@ -45,6 +70,21 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
45
70
  const doc = resolved ? getDoc(resolved.path) : undefined;
46
71
 
47
72
  if (!resolved || !doc) {
73
+ const moved = resolveMoved(params.slug);
74
+ const target = moved ? getDoc(moved.path) : undefined;
75
+
76
+ // A former address should not compete with the page it forwards to: it is
77
+ // kept out of the index, and points its canonical at the destination so any
78
+ // ranking the old URL earned transfers rather than being split.
79
+ if (moved && target) {
80
+ return {
81
+ title: target.title,
82
+ description: target.description || global.description,
83
+ alternates: { canonical: pageUrl(moved.url, global.baseUrl) },
84
+ robots: { index: false, follow: true },
85
+ };
86
+ }
87
+
48
88
  return { title: global.title, description: global.description };
49
89
  }
50
90
 
@@ -90,10 +130,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
90
130
  export async function generateStaticParams() {
91
131
  const { urlMap, docPaths } = getSite();
92
132
 
93
- return docPaths.flatMap((path) => {
133
+ const pages = docPaths.flatMap((path) => {
94
134
  const url = docPathToUrl(urlMap, path);
95
135
  return url ? [{ slug: url.split('/') }] : [];
96
136
  });
137
+
138
+ // Former addresses are built too, each as a page that forwards. Without this
139
+ // there is nothing at the old URL for a static host to serve.
140
+ const moved = [...getAliasMap().keys()].map((alias) => ({
141
+ slug: aliasUrl(alias, urlMap.strategy).split('/'),
142
+ }));
143
+
144
+ return [...pages, ...moved];
97
145
  }
98
146
 
99
147
  /**
@@ -136,7 +184,14 @@ function ArticleSchema({ doc, url }: { doc: ContentDoc; url: string }) {
136
184
  export default async function ContentPage({ params }: PageProps) {
137
185
  const resolved = resolveSlug(params.slug);
138
186
 
139
- if (!resolved) notFound();
187
+ if (!resolved) {
188
+ const moved = resolveMoved(params.slug);
189
+ const target = moved ? getDoc(moved.path) : undefined;
190
+
191
+ if (moved && target) return <MovedPage url={`/${moved.url}/`} title={target.title} />;
192
+
193
+ notFound();
194
+ }
140
195
 
141
196
  const doc = getDoc(resolved.path);
142
197
  const rendered = await renderDoc(resolved.path);
@@ -148,7 +203,9 @@ export default async function ContentPage({ params }: PageProps) {
148
203
  <div className="flex gap-8">
149
204
  <article className="prose prose-slate min-w-0 max-w-none flex-1 dark:prose-invert">
150
205
  <ArticleSchema doc={doc} url={resolved.url} />
206
+ <PageTags tags={getTagsFor(resolved.path)} />
151
207
  <MarkdownContent html={rendered.html} />
208
+ <PageNavigation adjacent={getAdjacentPages(resolved.path)} />
152
209
  <Backlinks links={getBacklinks(resolved.path)} />
153
210
  <LocalGraph graph={getLocalGraph(resolved.path)} path={resolved.path} />
154
211
  </article>
@@ -130,12 +130,15 @@ const fontFaceCss = FONT_FACES.map(
130
130
  `unicode-range:${RANGES[font.subset]}}`,
131
131
  ).join('');
132
132
 
133
+ /** Language announced when the payload names none. */
134
+ const DEFAULT_LANG = 'en';
135
+
133
136
  export default function RootLayout({ children }: { children: React.ReactNode }) {
134
137
  const site = getSite();
135
138
  const homeUrl = pageUrl('', site.global.baseUrl);
136
139
 
137
140
  return (
138
- <html lang="en">
141
+ <html lang={site.global.lang ?? DEFAULT_LANG}>
139
142
  <head>
140
143
  {FONT_FACES.filter((font) => font.preload).map((font) => (
141
144
  <link
@@ -2,6 +2,7 @@ import { MetadataRoute } from 'next';
2
2
  import { getSite } from '@/lib/site';
3
3
  import { docPathToUrl } from '@/lib/navigation/url';
4
4
  import { pageUrl } from '@/lib/basePath';
5
+ import { getTags } from '@/lib/content/tags';
5
6
 
6
7
  /**
7
8
  * Generates the sitemap for every published page.
@@ -44,5 +45,24 @@ export default function sitemap(): MetadataRoute.Sitemap {
44
45
  ];
45
46
  });
46
47
 
47
- return [homeEntry, ...contentEntries];
48
+ // Tag pages are indexable and canonical, so leaving them out of the sitemap
49
+ // said one thing to a crawler following links and another to one reading
50
+ // this. The index is listed even when empty; a tag page only exists when
51
+ // something carries it.
52
+ const tagEntries: MetadataRoute.Sitemap = [
53
+ {
54
+ url: pageUrl('tags', global.baseUrl),
55
+ lastModified,
56
+ changeFrequency: 'weekly',
57
+ priority: 0.4,
58
+ },
59
+ ...getTags().map((tag) => ({
60
+ url: pageUrl(`tags/${tag.slug}`, global.baseUrl),
61
+ lastModified,
62
+ changeFrequency: 'weekly' as const,
63
+ priority: 0.4,
64
+ })),
65
+ ];
66
+
67
+ return [homeEntry, ...contentEntries, ...tagEntries];
48
68
  }
@@ -0,0 +1,139 @@
1
+ import Link from 'next/link';
2
+ import { notFound } from 'next/navigation';
3
+ import type { Metadata } from 'next';
4
+ import { getTag, getTags, type Tag } from '@/lib/content/tags';
5
+ import { getSite } from '@/lib/site';
6
+ import { pageUrl } from '@/lib/basePath';
7
+
8
+ interface PageProps {
9
+ params: { tag?: string[] };
10
+ }
11
+
12
+ /**
13
+ * The tag index and every tag, served by one route.
14
+ *
15
+ * An optional catch-all rather than two routes because a static export refuses
16
+ * a dynamic segment whose `generateStaticParams` comes back empty — which is
17
+ * exactly what a new wiki has, before anyone has written a tag. Folded together,
18
+ * the index is always one of the params and the build has something to make.
19
+ */
20
+ export async function generateStaticParams() {
21
+ return [{ tag: [] as string[] }, ...getTags().map((tag) => ({ tag: [tag.slug] }))];
22
+ }
23
+
24
+ /** The tag a route names, or null when the route is the index. */
25
+ function resolveTag(params: PageProps['params']): Tag | null {
26
+ const [slug] = params.tag ?? [];
27
+ return slug ? getTag(slug) : null;
28
+ }
29
+
30
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
31
+ const { global } = getSite();
32
+ const [slug] = params.tag ?? [];
33
+
34
+ if (!slug) {
35
+ return {
36
+ title: 'Tags',
37
+ description: 'Subjects across the wiki',
38
+ alternates: { canonical: pageUrl('tags', global.baseUrl) },
39
+ };
40
+ }
41
+
42
+ const tag = getTag(slug);
43
+ if (!tag) return { title: 'Tag', description: global.description };
44
+
45
+ return {
46
+ title: tag.name,
47
+ description: `Pages about ${tag.name}`,
48
+ alternates: { canonical: pageUrl(`tags/${tag.slug}`, global.baseUrl) },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Lists every subject, or the pages about one.
54
+ *
55
+ * The sidebar shows one arrangement — the folder tree — and this shows the
56
+ * other. A page belongs to one section and to as many subjects as it touches,
57
+ * and only this view can say so.
58
+ */
59
+ export default function TagsPage({ params }: PageProps) {
60
+ const [slug] = params.tag ?? [];
61
+
62
+ if (slug) {
63
+ const tag = resolveTag(params);
64
+ if (!tag) notFound();
65
+
66
+ return (
67
+ <div className="mx-auto max-w-3xl px-6 py-10">
68
+ <Link
69
+ href="/tags/"
70
+ className="text-xs uppercase tracking-wide text-gray-500 no-underline hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
71
+ >
72
+ Tags
73
+ </Link>
74
+
75
+ <h1 className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{tag.name}</h1>
76
+
77
+ <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
78
+ {tag.pages.length} {tag.pages.length === 1 ? 'page' : 'pages'}
79
+ </p>
80
+
81
+ <ul className="mt-6 space-y-3">
82
+ {tag.pages.map((page) => (
83
+ <li key={page.path}>
84
+ <Link
85
+ href={page.url}
86
+ className="block 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"
87
+ >
88
+ <span className="block text-sm font-medium text-gray-900 dark:text-gray-100">
89
+ {page.title}
90
+ </span>
91
+ {page.description && (
92
+ <span className="mt-1 block text-sm text-gray-600 dark:text-gray-400">
93
+ {page.description}
94
+ </span>
95
+ )}
96
+ </Link>
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ const tags = getTags();
105
+
106
+ return (
107
+ <div className="mx-auto max-w-3xl px-6 py-10">
108
+ <h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">Tags</h1>
109
+
110
+ {tags.length === 0 ? (
111
+ <p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
112
+ No tags yet. Add <code>tags</code> to a page&rsquo;s frontmatter and it will appear here.
113
+ </p>
114
+ ) : (
115
+ <>
116
+ <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
117
+ {tags.length} {tags.length === 1 ? 'subject' : 'subjects'} across the wiki.
118
+ </p>
119
+
120
+ <ul className="mt-6 flex flex-wrap gap-2">
121
+ {tags.map((tag) => (
122
+ <li key={tag.slug}>
123
+ <Link
124
+ href={`/tags/${encodeURIComponent(tag.slug)}/`}
125
+ className="inline-flex items-baseline gap-1.5 rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-700 no-underline transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:border-gray-700 dark:hover:bg-gray-800/50"
126
+ >
127
+ {tag.name}
128
+ <span className="text-xs text-gray-500 dark:text-gray-400">
129
+ {tag.pages.length}
130
+ </span>
131
+ </Link>
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ </>
136
+ )}
137
+ </div>
138
+ );
139
+ }
@@ -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,32 @@
1
+ import Link from 'next/link';
2
+ import { Tag as TagIcon } from 'lucide-react';
3
+ import type { Tag } from '@/lib/content/tags';
4
+
5
+ /**
6
+ * Shows the subjects a page belongs to.
7
+ *
8
+ * Placed at the top of the article rather than the foot: knowing what a page is
9
+ * about is useful before reading it, and it is the one piece of navigation the
10
+ * sidebar cannot express, since a file sits in exactly one folder.
11
+ *
12
+ * @param props - Component props
13
+ * @param props.tags - Tags on the page, from `getTagsFor()`
14
+ */
15
+ export function PageTags({ tags }: { tags: Tag[] }) {
16
+ if (tags.length === 0) return null;
17
+
18
+ return (
19
+ <nav aria-label="Tags" className="mb-6 flex flex-wrap items-center gap-2">
20
+ <TagIcon className="h-3.5 w-3.5 flex-shrink-0 text-gray-400" aria-hidden="true" />
21
+ {tags.map((tag) => (
22
+ <Link
23
+ key={tag.slug}
24
+ href={`/tags/${encodeURIComponent(tag.slug)}/`}
25
+ className="rounded-md bg-gray-100 px-2 py-0.5 text-xs text-gray-700 no-underline transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
26
+ >
27
+ {tag.name}
28
+ </Link>
29
+ ))}
30
+ </nav>
31
+ );
32
+ }
@@ -210,7 +210,7 @@ export function TabBar() {
210
210
  <button
211
211
  onClick={(e) => handleTabClose(e, tab.id)}
212
212
  className={`
213
- flex-shrink-0 w-5 h-5 min-w-[20px] min-h-[20px] flex items-center justify-center rounded hover:bg-gray-200 dark:hover:bg-gray-600
213
+ flex-shrink-0 w-5 h-5 min-w-[20px] min-h-[20px] max-md:w-6 max-md:h-6 max-md:min-w-[24px] max-md:min-h-[24px] flex items-center justify-center rounded hover:bg-gray-200 dark:hover:bg-gray-600
214
214
  transition-opacity
215
215
  ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}
216
216
  `}
@@ -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
+ }