create-eziwiki 0.3.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 +3 -0
- 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/PageTags.tsx +32 -0
- package/template/components/layout/TabBar.tsx +1 -1
- package/template/lib/content/registry.ts +43 -0
- package/template/lib/content/tags.test.ts +93 -0
- package/template/lib/content/tags.ts +139 -0
- package/template/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +8 -0
- package/template/styles/markdown.css +4 -1
package/package.json
CHANGED
|
@@ -4,10 +4,12 @@ import { TableOfContents } from '@/components/layout/TableOfContents';
|
|
|
4
4
|
import { Backlinks } from '@/components/layout/Backlinks';
|
|
5
5
|
import { LocalGraph } from '@/components/layout/LocalGraph';
|
|
6
6
|
import { PageNavigation } from '@/components/layout/PageNavigation';
|
|
7
|
+
import { PageTags } from '@/components/layout/PageTags';
|
|
7
8
|
import { MovedPage } from '@/components/layout/MovedPage';
|
|
8
9
|
import { getBacklinks, getLocalGraph } from '@/lib/graph/build';
|
|
9
10
|
import { getAdjacentPages } from '@/lib/navigation/sequence';
|
|
10
11
|
import { getAliasMap, aliasUrl, resolveAliasUrl } from '@/lib/content/aliases';
|
|
12
|
+
import { getTagsFor } from '@/lib/content/tags';
|
|
11
13
|
import { renderDoc } from '@/lib/markdown/render';
|
|
12
14
|
import { getDoc, type ContentDoc } from '@/lib/content/registry';
|
|
13
15
|
import { docPathToUrl, urlToDocPath } from '@/lib/navigation/url';
|
|
@@ -201,6 +203,7 @@ export default async function ContentPage({ params }: PageProps) {
|
|
|
201
203
|
<div className="flex gap-8">
|
|
202
204
|
<article className="prose prose-slate min-w-0 max-w-none flex-1 dark:prose-invert">
|
|
203
205
|
<ArticleSchema doc={doc} url={resolved.url} />
|
|
206
|
+
<PageTags tags={getTagsFor(resolved.path)} />
|
|
204
207
|
<MarkdownContent html={rendered.html} />
|
|
205
208
|
<PageNavigation adjacent={getAdjacentPages(resolved.path)} />
|
|
206
209
|
<Backlinks links={getBacklinks(resolved.path)} />
|
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,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
|
`}
|
|
@@ -25,6 +25,14 @@ export interface ContentDoc {
|
|
|
25
25
|
order: number;
|
|
26
26
|
/** Excluded from navigation, but still reachable by direct URL */
|
|
27
27
|
hidden: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Labels grouping this document with others across the folder tree.
|
|
30
|
+
*
|
|
31
|
+
* A file sits in exactly one directory, so the sidebar can only express one
|
|
32
|
+
* way of organising a wiki. Tags are the second axis: a page belongs to one
|
|
33
|
+
* section and to as many subjects as it touches.
|
|
34
|
+
*/
|
|
35
|
+
tags: string[];
|
|
28
36
|
/**
|
|
29
37
|
* Paths this document used to live at.
|
|
30
38
|
*
|
|
@@ -114,6 +122,40 @@ function readBoolean(value: unknown): boolean {
|
|
|
114
122
|
return false;
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Reads the `tags` frontmatter.
|
|
127
|
+
*
|
|
128
|
+
* Accepts a single tag or a list, and a comma-separated string, because all
|
|
129
|
+
* three are how people write this and none of them is wrong. Tags are compared
|
|
130
|
+
* case-insensitively — `Setup` and `setup` are one subject, and treating them
|
|
131
|
+
* as two would split a wiki quietly — but the first spelling seen is the one
|
|
132
|
+
* displayed.
|
|
133
|
+
*
|
|
134
|
+
* @param value - The raw frontmatter value
|
|
135
|
+
* @returns Tags in the order written, without duplicates
|
|
136
|
+
*/
|
|
137
|
+
function readTags(value: unknown): string[] {
|
|
138
|
+
const raw = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : [];
|
|
139
|
+
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
const tags: string[] = [];
|
|
142
|
+
|
|
143
|
+
for (const entry of raw) {
|
|
144
|
+
if (typeof entry !== 'string') continue;
|
|
145
|
+
|
|
146
|
+
const tag = entry.trim();
|
|
147
|
+
if (!tag) continue;
|
|
148
|
+
|
|
149
|
+
const key = tag.toLowerCase();
|
|
150
|
+
if (seen.has(key)) continue;
|
|
151
|
+
|
|
152
|
+
seen.add(key);
|
|
153
|
+
tags.push(tag);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return tags;
|
|
157
|
+
}
|
|
158
|
+
|
|
117
159
|
/**
|
|
118
160
|
* Reads the `aliases` frontmatter into a list of content paths.
|
|
119
161
|
*
|
|
@@ -228,6 +270,7 @@ function readDoc(filePath: string): ContentDoc | null {
|
|
|
228
270
|
description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
|
|
229
271
|
order: readOrder(frontmatter.order),
|
|
230
272
|
hidden: readBoolean(frontmatter.hidden) || frontmatter.nav === false,
|
|
273
|
+
tags: readTags(frontmatter.tags),
|
|
231
274
|
aliases: readAliases(frontmatter.aliases),
|
|
232
275
|
frontmatter,
|
|
233
276
|
content,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getTags, getTag, getTagsFor, findTagRouteCollisions } from './tags';
|
|
3
|
+
import { getContentRegistry } from './registry';
|
|
4
|
+
import { getSite } from '../site';
|
|
5
|
+
|
|
6
|
+
describe('getTags', () => {
|
|
7
|
+
it('gathers pages by subject across the folder tree', () => {
|
|
8
|
+
for (const tag of getTags()) {
|
|
9
|
+
expect(tag.pages.length).toBeGreaterThan(0);
|
|
10
|
+
expect(tag.slug).toBe(tag.name.toLowerCase());
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('sorts subjects by name', () => {
|
|
15
|
+
const names = getTags().map((tag) => tag.name);
|
|
16
|
+
|
|
17
|
+
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// A page kept off the sidebar on purpose should not reappear here, or the
|
|
21
|
+
// tag index becomes a way of enumerating exactly what was unlisted.
|
|
22
|
+
it('leaves hidden pages out', () => {
|
|
23
|
+
const { hiddenPaths } = getSite();
|
|
24
|
+
const listed = getTags().flatMap((tag) => tag.pages.map((page) => page.path));
|
|
25
|
+
|
|
26
|
+
for (const hidden of hiddenPaths) {
|
|
27
|
+
expect(listed).not.toContain(hidden);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('emits URLs in the form the export serves', () => {
|
|
32
|
+
for (const tag of getTags()) {
|
|
33
|
+
for (const page of tag.pages) {
|
|
34
|
+
expect(page.url).toMatch(/^\/.*\/$/);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lists a page under each of its tags', () => {
|
|
40
|
+
const { docs } = getContentRegistry();
|
|
41
|
+
const { hiddenPaths } = getSite();
|
|
42
|
+
|
|
43
|
+
for (const doc of docs) {
|
|
44
|
+
if (hiddenPaths.has(doc.path) || doc.tags.length === 0) continue;
|
|
45
|
+
|
|
46
|
+
for (const name of doc.tags) {
|
|
47
|
+
const tag = getTag(name.toLowerCase());
|
|
48
|
+
expect(tag?.pages.some((page) => page.path === doc.path)).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getTag', () => {
|
|
55
|
+
it('finds a subject however its name is cased', () => {
|
|
56
|
+
const [first] = getTags();
|
|
57
|
+
if (!first) return;
|
|
58
|
+
|
|
59
|
+
expect(getTag(first.slug)?.slug).toBe(first.slug);
|
|
60
|
+
expect(getTag(first.slug.toUpperCase())?.slug).toBe(first.slug);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('finds nothing for a subject no page carries', () => {
|
|
64
|
+
expect(getTag('no-such-subject')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('getTagsFor', () => {
|
|
69
|
+
it('returns the tags a page carries', () => {
|
|
70
|
+
const { docs } = getContentRegistry();
|
|
71
|
+
const { hiddenPaths } = getSite();
|
|
72
|
+
const tagged = docs.find((doc) => doc.tags.length > 0 && !hiddenPaths.has(doc.path));
|
|
73
|
+
if (!tagged) return;
|
|
74
|
+
|
|
75
|
+
expect(
|
|
76
|
+
getTagsFor(tagged.path)
|
|
77
|
+
.map((tag) => tag.slug)
|
|
78
|
+
.sort(),
|
|
79
|
+
).toEqual(tagged.tags.map((tag) => tag.toLowerCase()).sort());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns nothing for an untagged page', () => {
|
|
83
|
+
expect(getTagsFor('no/such/page')).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// `/tags/…` is a route of its own and Next resolves it before the catch-all,
|
|
88
|
+
// so a page published there would be unreachable.
|
|
89
|
+
describe('findTagRouteCollisions', () => {
|
|
90
|
+
it('reports no collision in this wiki', () => {
|
|
91
|
+
expect(findTagRouteCollisions()).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { getContentRegistry, type ContentDoc } from './registry';
|
|
2
|
+
import { getSite } from '../site';
|
|
3
|
+
import { docPathToUrl } from '../navigation/url';
|
|
4
|
+
import { cached } from '../cache';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Subjects, gathered across the folder tree.
|
|
8
|
+
*
|
|
9
|
+
* A file lives in one directory, so the sidebar can only ever show one way of
|
|
10
|
+
* organising a wiki. Tags are the other way: a page sits in one section and
|
|
11
|
+
* touches as many subjects as it touches. Where the graph says which pages
|
|
12
|
+
* mention each other, tags say which are about the same thing whether or not
|
|
13
|
+
* anyone thought to link them.
|
|
14
|
+
*
|
|
15
|
+
* Server-only: reads the content registry.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Route segment the tag pages live under. */
|
|
19
|
+
export const TAGS_SEGMENT = 'tags';
|
|
20
|
+
|
|
21
|
+
/** A page carrying a tag. */
|
|
22
|
+
export interface TaggedPage {
|
|
23
|
+
/** Content path */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Display title */
|
|
26
|
+
title: string;
|
|
27
|
+
/** Href, in the site's configured URL form */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Short summary, when the page has one */
|
|
30
|
+
description?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A subject and the pages about it. */
|
|
34
|
+
export interface Tag {
|
|
35
|
+
/** The tag as first written by an author */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Lowercased form, used in URLs and for comparison */
|
|
38
|
+
slug: string;
|
|
39
|
+
/** Pages carrying it, in reading order */
|
|
40
|
+
pages: TaggedPage[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let memo: Tag[] | null = null;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Converts a page to the shape a listing needs.
|
|
47
|
+
*/
|
|
48
|
+
function toTaggedPage(doc: ContentDoc): TaggedPage | null {
|
|
49
|
+
const url = docPathToUrl(getSite().urlMap, doc.path);
|
|
50
|
+
if (!url) return null;
|
|
51
|
+
|
|
52
|
+
return { path: doc.path, title: doc.title, url: `/${url}/`, description: doc.description };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Collects every tag and the pages carrying it.
|
|
57
|
+
*
|
|
58
|
+
* Hidden pages are left out. A page kept off the sidebar on purpose should not
|
|
59
|
+
* reappear in a tag listing, which would make the tag index a way of
|
|
60
|
+
* enumerating exactly what was meant to stay unlisted.
|
|
61
|
+
*
|
|
62
|
+
* @returns Tags sorted by name, each with its pages
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* getTags().map((tag) => `${tag.name} (${tag.pages.length})`);
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function getTags(): Tag[] {
|
|
70
|
+
const hit = cached(memo);
|
|
71
|
+
if (hit) return hit;
|
|
72
|
+
|
|
73
|
+
const { docs } = getContentRegistry();
|
|
74
|
+
const { hiddenPaths } = getSite();
|
|
75
|
+
const bySlug = new Map<string, Tag>();
|
|
76
|
+
|
|
77
|
+
for (const doc of docs) {
|
|
78
|
+
if (hiddenPaths.has(doc.path)) continue;
|
|
79
|
+
|
|
80
|
+
const page = toTaggedPage(doc);
|
|
81
|
+
if (!page) continue;
|
|
82
|
+
|
|
83
|
+
for (const name of doc.tags) {
|
|
84
|
+
const slug = name.toLowerCase();
|
|
85
|
+
const existing = bySlug.get(slug);
|
|
86
|
+
|
|
87
|
+
// The first spelling wins, so a wiki that writes `Setup` once and `setup`
|
|
88
|
+
// thereafter still shows one tag rather than two.
|
|
89
|
+
if (existing) existing.pages.push(page);
|
|
90
|
+
else bySlug.set(slug, { name, slug, pages: [page] });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
memo = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
95
|
+
return memo;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Finds one tag by its slug.
|
|
100
|
+
*
|
|
101
|
+
* @param slug - Lowercased tag name from the URL
|
|
102
|
+
* @returns The tag, or null when nothing carries it
|
|
103
|
+
*/
|
|
104
|
+
export function getTag(slug: string): Tag | null {
|
|
105
|
+
const wanted = decodeURIComponent(slug).toLowerCase();
|
|
106
|
+
return getTags().find((tag) => tag.slug === wanted) ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the tags on one page, in the order they were written.
|
|
111
|
+
*
|
|
112
|
+
* @param path - Content path
|
|
113
|
+
* @returns The page's tags, empty when it has none
|
|
114
|
+
*/
|
|
115
|
+
export function getTagsFor(path: string): Tag[] {
|
|
116
|
+
return getTags().filter((tag) => tag.pages.some((page) => page.path === path));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reports a content page whose URL the tag routes would shadow.
|
|
121
|
+
*
|
|
122
|
+
* `/tags/…` is a route of its own, and Next resolves it before the catch-all
|
|
123
|
+
* that serves content, so a page published at that address would become
|
|
124
|
+
* unreachable. Surfacing it is better than letting a page quietly disappear;
|
|
125
|
+
* the fix is to rename the file or the directory.
|
|
126
|
+
*
|
|
127
|
+
* @returns Paths that collide, empty when none do
|
|
128
|
+
*/
|
|
129
|
+
export function findTagRouteCollisions(): string[] {
|
|
130
|
+
const { docs } = getContentRegistry();
|
|
131
|
+
const { urlMap } = getSite();
|
|
132
|
+
|
|
133
|
+
return docs
|
|
134
|
+
.filter((doc) => {
|
|
135
|
+
const url = docPathToUrl(urlMap, doc.path);
|
|
136
|
+
return url === TAGS_SEGMENT || url?.startsWith(`${TAGS_SEGMENT}/`);
|
|
137
|
+
})
|
|
138
|
+
.map((doc) => doc.path);
|
|
139
|
+
}
|
|
@@ -13,6 +13,7 @@ export const payloadSchema = {
|
|
|
13
13
|
title: { type: 'string', minLength: 1 },
|
|
14
14
|
description: { type: 'string', minLength: 1 },
|
|
15
15
|
favicon: { type: 'string' },
|
|
16
|
+
lang: { type: 'string', minLength: 2 },
|
|
16
17
|
baseUrl: { type: 'string', format: 'uri' },
|
|
17
18
|
repoUrl: { type: 'string', format: 'uri' },
|
|
18
19
|
urlStrategy: { type: 'string', enum: ['path', 'hash'] },
|
|
@@ -28,6 +28,14 @@ export interface GlobalConfig {
|
|
|
28
28
|
description: string;
|
|
29
29
|
/** Path to favicon */
|
|
30
30
|
favicon?: string;
|
|
31
|
+
/**
|
|
32
|
+
* BCP 47 language tag for the content, e.g. `ko`, `ja`, `en-GB`.
|
|
33
|
+
*
|
|
34
|
+
* Announced on the root element. Screen readers pick pronunciation from it
|
|
35
|
+
* and translation tools decide what to offer, so a Korean wiki left at the
|
|
36
|
+
* `en` default is read aloud as English. Defaults to `en`.
|
|
37
|
+
*/
|
|
38
|
+
lang?: string;
|
|
31
39
|
/** Base URL for the site */
|
|
32
40
|
baseUrl?: string;
|
|
33
41
|
/**
|
|
@@ -366,10 +366,13 @@ details.ezw-callout:not([open]) > summary.ezw-callout__title {
|
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
/* A pointer is not always available. On touch the anchor is simply shown,
|
|
369
|
-
since there is no hover to reveal it with
|
|
369
|
+
since there is no hover to reveal it with, and padded out to a target a
|
|
370
|
+
finger can hit — the glyph stays the same size, the area around it grows. */
|
|
370
371
|
@media (hover: none) {
|
|
371
372
|
.ezw-heading__anchor {
|
|
372
373
|
opacity: 0.5;
|
|
374
|
+
padding: 0 0.3em;
|
|
375
|
+
margin-left: 0.1em;
|
|
373
376
|
}
|
|
374
377
|
}
|
|
375
378
|
|