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 +1 -1
- package/template/app/[...slug]/page.tsx +59 -2
- package/template/app/layout.tsx +4 -1
- package/template/app/sitemap.ts +21 -1
- package/template/app/tags/[[...tag]]/page.tsx +139 -0
- package/template/components/layout/MovedPage.tsx +45 -0
- package/template/components/layout/PageNavigation.tsx +73 -0
- package/template/components/layout/PageTags.tsx +32 -0
- package/template/components/layout/TabBar.tsx +1 -1
- package/template/lib/content/aliases.test.ts +76 -0
- package/template/lib/content/aliases.ts +114 -0
- package/template/lib/content/registry.ts +74 -0
- package/template/lib/content/tags.test.ts +93 -0
- package/template/lib/content/tags.ts +139 -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/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +8 -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 +160 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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>
|
package/template/app/layout.tsx
CHANGED
|
@@ -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=
|
|
141
|
+
<html lang={site.global.lang ?? DEFAULT_LANG}>
|
|
139
142
|
<head>
|
|
140
143
|
{FONT_FACES.filter((font) => font.preload).map((font) => (
|
|
141
144
|
<link
|
package/template/app/sitemap.ts
CHANGED
|
@@ -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
|
-
|
|
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’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
|
+
}
|