create-eziwiki 0.1.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/README.md +46 -0
- package/bin/create-eziwiki.mjs +83 -0
- package/lib/scaffold.mjs +230 -0
- package/lib/scaffold.test.mjs +270 -0
- package/package.json +38 -0
- package/template/README.md +39 -0
- package/template/app/[...slug]/page.tsx +162 -0
- package/template/app/error.tsx +77 -0
- package/template/app/global-error.tsx +76 -0
- package/template/app/globals.css +44 -0
- package/template/app/graph/page.tsx +62 -0
- package/template/app/layout.tsx +164 -0
- package/template/app/not-found.tsx +48 -0
- package/template/app/page.tsx +34 -0
- package/template/app/robots.ts +20 -0
- package/template/app/sitemap.ts +48 -0
- package/template/components/ThemeToggle.tsx +77 -0
- package/template/components/graph/GraphView.tsx +156 -0
- package/template/components/layout/Backlinks.tsx +42 -0
- package/template/components/layout/Breadcrumb.tsx +88 -0
- package/template/components/layout/MobileMenu.tsx +299 -0
- package/template/components/layout/NavigationButtons.tsx +87 -0
- package/template/components/layout/PageLayout.tsx +89 -0
- package/template/components/layout/Sidebar.tsx +376 -0
- package/template/components/layout/TabBar.tsx +312 -0
- package/template/components/layout/TabBarSkeleton.tsx +12 -0
- package/template/components/layout/TabInitializer.tsx +99 -0
- package/template/components/layout/TableOfContents.tsx +138 -0
- package/template/components/markdown/CodeCopy.tsx +65 -0
- package/template/components/markdown/MarkdownContent.tsx +38 -0
- package/template/components/markdown/PageTransition.tsx +56 -0
- package/template/components/providers/UrlMapProvider.tsx +68 -0
- package/template/components/search/SearchDialog.tsx +286 -0
- package/template/components/search/SearchTrigger.tsx +41 -0
- package/template/content/guides/_meta.json +4 -0
- package/template/content/guides/writing.md +82 -0
- package/template/content/intro.md +29 -0
- package/template/eslintignore +7 -0
- package/template/eslintrc.js +40 -0
- package/template/gitignore +40 -0
- package/template/lib/basePath.test.ts +120 -0
- package/template/lib/basePath.ts +108 -0
- package/template/lib/cache.ts +36 -0
- package/template/lib/content/registry.ts +311 -0
- package/template/lib/content/resolver.ts +109 -0
- package/template/lib/graph/build.ts +214 -0
- package/template/lib/graph/layout.test.ts +189 -0
- package/template/lib/graph/layout.ts +247 -0
- package/template/lib/markdown/languages.test.ts +85 -0
- package/template/lib/markdown/languages.ts +103 -0
- package/template/lib/markdown/rehype-plugins.ts +240 -0
- package/template/lib/markdown/remark-wikilink.ts +141 -0
- package/template/lib/markdown/render.ts +175 -0
- package/template/lib/markdown/wikilink.test.ts +91 -0
- package/template/lib/markdown/wikilink.ts +85 -0
- package/template/lib/navigation/auto.ts +227 -0
- package/template/lib/navigation/builder.test.ts +129 -0
- package/template/lib/navigation/builder.ts +122 -0
- package/template/lib/navigation/hash.ts +32 -0
- package/template/lib/navigation/url.test.ts +88 -0
- package/template/lib/navigation/url.ts +108 -0
- package/template/lib/navigation/urlMap.ts +81 -0
- package/template/lib/payload/schema.ts +81 -0
- package/template/lib/payload/types.ts +105 -0
- package/template/lib/payload/validator.ts +56 -0
- package/template/lib/search/build.ts +204 -0
- package/template/lib/search/client.ts +190 -0
- package/template/lib/search/tokenizer.test.ts +60 -0
- package/template/lib/search/tokenizer.ts +83 -0
- package/template/lib/search/types.ts +40 -0
- package/template/lib/site.ts +86 -0
- package/template/lib/store/searchStore.ts +26 -0
- package/template/lib/store/tabStore.ts +313 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.js +43 -0
- package/template/package-lock.json +9933 -0
- package/template/package.json +69 -0
- package/template/payload/config.ts +34 -0
- package/template/postcss.config.js +6 -0
- package/template/prettierignore +7 -0
- package/template/prettierrc +8 -0
- package/template/public/favicon.svg +10 -0
- package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
- package/template/public/images/.gitkeep +0 -0
- package/template/scripts/build-search-index.ts +36 -0
- package/template/scripts/check-links.ts +40 -0
- package/template/scripts/show-urls.ts +48 -0
- package/template/scripts/validate-payload.ts +30 -0
- package/template/styles/markdown.css +167 -0
- package/template/styles/theme.css +65 -0
- package/template/tailwind.config.ts +156 -0
- package/template/tsconfig.json +32 -0
- package/template/vitest.config.ts +30 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractAllPaths, findActiveItem } from './builder';
|
|
3
|
+
import { NavigationItem } from '../payload/types';
|
|
4
|
+
|
|
5
|
+
describe('extractAllPaths', () => {
|
|
6
|
+
it('should extract paths from flat navigation structure', () => {
|
|
7
|
+
const navigation: NavigationItem[] = [
|
|
8
|
+
{ name: 'Home', path: 'home' },
|
|
9
|
+
{ name: 'About', path: 'about' },
|
|
10
|
+
{ name: 'Contact', path: 'contact' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const paths = extractAllPaths(navigation);
|
|
14
|
+
expect(paths).toEqual(['home', 'about', 'contact']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should extract paths from nested navigation structure', () => {
|
|
18
|
+
const navigation: NavigationItem[] = [
|
|
19
|
+
{ name: 'Home', path: 'home' },
|
|
20
|
+
{
|
|
21
|
+
name: 'Guides',
|
|
22
|
+
children: [
|
|
23
|
+
{ name: 'Quick Start', path: 'guides/quick-start' },
|
|
24
|
+
{ name: 'Configuration', path: 'guides/configuration' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const paths = extractAllPaths(navigation);
|
|
30
|
+
expect(paths).toEqual(['home', 'guides/quick-start', 'guides/configuration']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should handle deeply nested structures', () => {
|
|
34
|
+
const navigation: NavigationItem[] = [
|
|
35
|
+
{
|
|
36
|
+
name: 'Docs',
|
|
37
|
+
children: [
|
|
38
|
+
{
|
|
39
|
+
name: 'API',
|
|
40
|
+
children: [
|
|
41
|
+
{ name: 'Authentication', path: 'docs/api/auth' },
|
|
42
|
+
{ name: 'Users', path: 'docs/api/users' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const paths = extractAllPaths(navigation);
|
|
50
|
+
expect(paths).toEqual(['docs/api/auth', 'docs/api/users']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle items without paths', () => {
|
|
54
|
+
const navigation: NavigationItem[] = [
|
|
55
|
+
{ name: 'Section', children: [{ name: 'Page', path: 'page' }] },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const paths = extractAllPaths(navigation);
|
|
59
|
+
expect(paths).toEqual(['page']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should return empty array for empty navigation', () => {
|
|
63
|
+
const paths = extractAllPaths([]);
|
|
64
|
+
expect(paths).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('findActiveItem', () => {
|
|
69
|
+
it('should find item in flat navigation structure', () => {
|
|
70
|
+
const navigation: NavigationItem[] = [
|
|
71
|
+
{ name: 'Home', path: 'home' },
|
|
72
|
+
{ name: 'About', path: 'about' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const item = findActiveItem(navigation, 'about');
|
|
76
|
+
expect(item).toEqual({ name: 'About', path: 'about' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should find item in nested navigation structure', () => {
|
|
80
|
+
const navigation: NavigationItem[] = [
|
|
81
|
+
{ name: 'Home', path: 'home' },
|
|
82
|
+
{
|
|
83
|
+
name: 'Guides',
|
|
84
|
+
children: [
|
|
85
|
+
{ name: 'Quick Start', path: 'guides/quick-start' },
|
|
86
|
+
{ name: 'Configuration', path: 'guides/configuration' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const item = findActiveItem(navigation, 'guides/configuration');
|
|
92
|
+
expect(item).toEqual({ name: 'Configuration', path: 'guides/configuration' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should find item in deeply nested structure', () => {
|
|
96
|
+
const navigation: NavigationItem[] = [
|
|
97
|
+
{
|
|
98
|
+
name: 'Docs',
|
|
99
|
+
children: [
|
|
100
|
+
{
|
|
101
|
+
name: 'API',
|
|
102
|
+
children: [
|
|
103
|
+
{ name: 'Authentication', path: 'docs/api/auth' },
|
|
104
|
+
{ name: 'Users', path: 'docs/api/users' },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const item = findActiveItem(navigation, 'docs/api/users');
|
|
112
|
+
expect(item).toEqual({ name: 'Users', path: 'docs/api/users' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should return null when path is not found', () => {
|
|
116
|
+
const navigation: NavigationItem[] = [
|
|
117
|
+
{ name: 'Home', path: 'home' },
|
|
118
|
+
{ name: 'About', path: 'about' },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const item = findActiveItem(navigation, 'nonexistent');
|
|
122
|
+
expect(item).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return null for empty navigation', () => {
|
|
126
|
+
const item = findActiveItem([], 'any-path');
|
|
127
|
+
expect(item).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { NavigationItem } from '../payload/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively extracts all paths from a navigation tree structure
|
|
5
|
+
*
|
|
6
|
+
* This function traverses the entire navigation hierarchy and collects
|
|
7
|
+
* all path values, which are used to generate static pages at build time.
|
|
8
|
+
* Items without a path property are skipped (they act as section headers).
|
|
9
|
+
* Hidden items are included in the extraction (they're just hidden from UI).
|
|
10
|
+
*
|
|
11
|
+
* @param items - Array of navigation items to process
|
|
12
|
+
* @returns Flat array of all path strings found in the navigation tree
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const navigation = [
|
|
17
|
+
* { name: 'Home', path: 'home' },
|
|
18
|
+
* {
|
|
19
|
+
* name: 'Guides',
|
|
20
|
+
* children: [
|
|
21
|
+
* { name: 'Quick Start', path: 'guides/quick-start' },
|
|
22
|
+
* { name: 'Config', path: 'guides/config' }
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
* ];
|
|
26
|
+
*
|
|
27
|
+
* const paths = extractAllPaths(navigation);
|
|
28
|
+
* // Returns: ['home', 'guides/quick-start', 'guides/config']
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function extractAllPaths(items: NavigationItem[]): string[] {
|
|
32
|
+
const paths: string[] = [];
|
|
33
|
+
|
|
34
|
+
function traverse(items: NavigationItem[]) {
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
if (item.path) {
|
|
37
|
+
paths.push(item.path);
|
|
38
|
+
}
|
|
39
|
+
if (item.children) {
|
|
40
|
+
traverse(item.children);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
traverse(items);
|
|
46
|
+
return paths;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Filter out hidden navigation items
|
|
51
|
+
*
|
|
52
|
+
* Recursively removes items marked as hidden from the navigation tree.
|
|
53
|
+
* This is used to hide items from the sidebar while keeping them accessible via URL.
|
|
54
|
+
*
|
|
55
|
+
* @param items - Array of navigation items to filter
|
|
56
|
+
* @returns Filtered navigation tree without hidden items
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const navigation = [
|
|
61
|
+
* { name: 'Public', path: 'public' },
|
|
62
|
+
* { name: 'Secret', path: 'secret', hidden: true }
|
|
63
|
+
* ];
|
|
64
|
+
*
|
|
65
|
+
* const visible = filterHiddenItems(navigation);
|
|
66
|
+
* // Returns: [{ name: 'Public', path: 'public' }]
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function filterHiddenItems(items: NavigationItem[]): NavigationItem[] {
|
|
70
|
+
return items
|
|
71
|
+
.filter((item) => !item.hidden)
|
|
72
|
+
.map((item) => ({
|
|
73
|
+
...item,
|
|
74
|
+
children: item.children ? filterHiddenItems(item.children) : undefined,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Recursively searches navigation tree to find the item matching the current path
|
|
80
|
+
*
|
|
81
|
+
* This function is used to highlight the active navigation item in the sidebar.
|
|
82
|
+
* It performs a depth-first search through the navigation hierarchy to find
|
|
83
|
+
* the item whose path matches the current page path.
|
|
84
|
+
*
|
|
85
|
+
* @param items - Array of navigation items to search
|
|
86
|
+
* @param currentPath - Current page path to match against
|
|
87
|
+
* @returns The matching NavigationItem if found, null otherwise
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```typescript
|
|
91
|
+
* const navigation = [
|
|
92
|
+
* { name: 'Home', path: 'home' },
|
|
93
|
+
* {
|
|
94
|
+
* name: 'Guides',
|
|
95
|
+
* children: [
|
|
96
|
+
* { name: 'Quick Start', path: 'guides/quick-start' }
|
|
97
|
+
* ]
|
|
98
|
+
* }
|
|
99
|
+
* ];
|
|
100
|
+
*
|
|
101
|
+
* const activeItem = findActiveItem(navigation, 'guides/quick-start');
|
|
102
|
+
* // Returns: { name: 'Quick Start', path: 'guides/quick-start' }
|
|
103
|
+
*
|
|
104
|
+
* const notFound = findActiveItem(navigation, 'nonexistent');
|
|
105
|
+
* // Returns: null
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function findActiveItem(
|
|
109
|
+
items: NavigationItem[],
|
|
110
|
+
currentPath: string,
|
|
111
|
+
): NavigationItem | null {
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
if (item.path === currentPath) {
|
|
114
|
+
return item;
|
|
115
|
+
}
|
|
116
|
+
if (item.children) {
|
|
117
|
+
const found = findActiveItem(item.children, currentPath);
|
|
118
|
+
if (found) return found;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic path hashing for the `hash` URL strategy.
|
|
5
|
+
*
|
|
6
|
+
* Node-only: this is used when building the URL map on the server. Client
|
|
7
|
+
* components consume the finished map through `UrlMapProvider` and never hash
|
|
8
|
+
* anything themselves, which keeps the digest implementation out of the browser
|
|
9
|
+
* bundle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generates a stable, URL-safe hash for a content path.
|
|
14
|
+
*
|
|
15
|
+
* The same input always produces the same hash, so URLs stay valid across
|
|
16
|
+
* builds as long as the file does not move.
|
|
17
|
+
*
|
|
18
|
+
* @param filePath - Content-relative path (e.g. 'guides/quick-start')
|
|
19
|
+
* @returns Hash formatted as three 8-character groups
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* generatePathHash('guides/quick-start');
|
|
24
|
+
* // 'a3f2e9d1-4b8c7e6f-9d2a1b3c'
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function generatePathHash(filePath: string): string {
|
|
28
|
+
const hash = crypto.createHash('sha256').update(filePath).digest('hex');
|
|
29
|
+
|
|
30
|
+
// Format: 8chars-8chars-8chars for readability
|
|
31
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 16)}-${hash.slice(16, 24)}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { docPathToUrl, hrefFor, normalizeSlug, urlToDocPath, type UrlMap } from './url';
|
|
3
|
+
import { buildUrlMap } from './urlMap';
|
|
4
|
+
|
|
5
|
+
const PATHS = ['intro', 'guides/quick-start', 'guides/nested/deep'];
|
|
6
|
+
|
|
7
|
+
describe('normalizeSlug', () => {
|
|
8
|
+
it('strips leading and trailing slashes', () => {
|
|
9
|
+
expect(normalizeSlug('/guides/quick-start/')).toBe('guides/quick-start');
|
|
10
|
+
expect(normalizeSlug('guides/quick-start')).toBe('guides/quick-start');
|
|
11
|
+
expect(normalizeSlug('/')).toBe('');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('buildUrlMap', () => {
|
|
16
|
+
it('maps a path to itself under the path strategy', () => {
|
|
17
|
+
const map = buildUrlMap(PATHS, 'path');
|
|
18
|
+
|
|
19
|
+
expect(map.toUrl['guides/quick-start']).toBe('guides/quick-start');
|
|
20
|
+
expect(map.toPath['guides/quick-start']).toBe('guides/quick-start');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('maps a path to a deterministic hash under the hash strategy', () => {
|
|
24
|
+
const first = buildUrlMap(PATHS, 'hash');
|
|
25
|
+
const second = buildUrlMap(PATHS, 'hash');
|
|
26
|
+
|
|
27
|
+
expect(first.toUrl['intro']).toMatch(/^[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{8}$/);
|
|
28
|
+
expect(first.toUrl['intro']).toBe(second.toUrl['intro']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('round-trips every path through the map under both strategies', () => {
|
|
32
|
+
for (const strategy of ['path', 'hash'] as const) {
|
|
33
|
+
const map = buildUrlMap(PATHS, strategy);
|
|
34
|
+
|
|
35
|
+
for (const path of PATHS) {
|
|
36
|
+
const url = docPathToUrl(map, path);
|
|
37
|
+
expect(url).not.toBeNull();
|
|
38
|
+
expect(urlToDocPath(map, url!)).toBe(path);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('ignores empty paths rather than mapping them to the root', () => {
|
|
44
|
+
const map = buildUrlMap(['', '/', 'intro'], 'path');
|
|
45
|
+
|
|
46
|
+
expect(map.toPath['']).toBeUndefined();
|
|
47
|
+
expect(Object.keys(map.toUrl)).toEqual(['intro']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('normalises surrounding slashes before mapping', () => {
|
|
51
|
+
const map = buildUrlMap(['/intro/'], 'path');
|
|
52
|
+
|
|
53
|
+
expect(map.toUrl['intro']).toBe('intro');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('docPathToUrl / urlToDocPath', () => {
|
|
58
|
+
const map = buildUrlMap(PATHS, 'path');
|
|
59
|
+
|
|
60
|
+
it('tolerates surrounding slashes on lookup', () => {
|
|
61
|
+
expect(docPathToUrl(map, '/intro/')).toBe('intro');
|
|
62
|
+
expect(urlToDocPath(map, '/guides/quick-start/')).toBe('guides/quick-start');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns null for unknown paths', () => {
|
|
66
|
+
expect(docPathToUrl(map, 'nope')).toBeNull();
|
|
67
|
+
expect(urlToDocPath(map, 'nope')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('hrefFor', () => {
|
|
72
|
+
const map = buildUrlMap(PATHS, 'path');
|
|
73
|
+
|
|
74
|
+
it('builds a root-relative href', () => {
|
|
75
|
+
expect(hrefFor(map, 'guides/quick-start')).toBe('/guides/quick-start');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('falls back to the root for unknown or missing paths', () => {
|
|
79
|
+
expect(hrefFor(map, undefined)).toBe('/');
|
|
80
|
+
expect(hrefFor(map, '')).toBe('/');
|
|
81
|
+
expect(hrefFor(map, 'nope')).toBe('/');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('never emits a literal null segment', () => {
|
|
85
|
+
const empty: UrlMap = { strategy: 'path', toUrl: {}, toPath: {} };
|
|
86
|
+
expect(hrefFor(empty, 'anything')).toBe('/');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL strategy determining how content paths appear in the address bar.
|
|
3
|
+
*
|
|
4
|
+
* - `path` — readable, SEO-friendly URLs mirroring the content tree
|
|
5
|
+
* (`/guides/quick-start`)
|
|
6
|
+
* - `hash` — opaque, deterministic hashes that conceal the content structure
|
|
7
|
+
* (`/a3f2e9d1-4b8c7e6f-9d2a1b3c`)
|
|
8
|
+
*/
|
|
9
|
+
export type UrlStrategy = 'path' | 'hash';
|
|
10
|
+
|
|
11
|
+
/** Strategy applied when the payload does not specify one. */
|
|
12
|
+
export const DEFAULT_URL_STRATEGY: UrlStrategy = 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A precomputed, bidirectional mapping between content paths and URL segments.
|
|
16
|
+
*
|
|
17
|
+
* The map is built once on the server and handed to client components as plain
|
|
18
|
+
* data. Keeping it serialisable is deliberate: it means the browser never needs
|
|
19
|
+
* the hashing implementation, only the results.
|
|
20
|
+
*/
|
|
21
|
+
export interface UrlMap {
|
|
22
|
+
/** Strategy this map was built with */
|
|
23
|
+
strategy: UrlStrategy;
|
|
24
|
+
/** Content path to URL segment (e.g. 'guides/quick-start' -> 'a3f2e9d1-...') */
|
|
25
|
+
toUrl: Record<string, string>;
|
|
26
|
+
/** URL segment back to content path */
|
|
27
|
+
toPath: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An empty map, used as a safe default before hydration. */
|
|
31
|
+
export const EMPTY_URL_MAP: UrlMap = {
|
|
32
|
+
strategy: DEFAULT_URL_STRATEGY,
|
|
33
|
+
toUrl: {},
|
|
34
|
+
toPath: {},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Strips leading and trailing slashes from a URL fragment.
|
|
39
|
+
*
|
|
40
|
+
* Route params arrive in several shapes depending on `trailingSlash` and on
|
|
41
|
+
* whether the value came from `usePathname` or from a slug array; normalising
|
|
42
|
+
* here keeps every caller from repeating the same trimming.
|
|
43
|
+
*
|
|
44
|
+
* @param value - Raw path or slug fragment
|
|
45
|
+
* @returns The fragment without surrounding slashes
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* normalizeSlug('/guides/quick-start/'); // 'guides/quick-start'
|
|
50
|
+
* normalizeSlug('guides/quick-start'); // 'guides/quick-start'
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeSlug(value: string): string {
|
|
54
|
+
return value.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves a content path to its URL segment.
|
|
59
|
+
*
|
|
60
|
+
* @param map - Precomputed URL mapping
|
|
61
|
+
* @param docPath - Content-relative path without extension
|
|
62
|
+
* @returns The URL segment, or null when the path is not part of the site
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* docPathToUrl(map, 'guides/quick-start');
|
|
67
|
+
* // 'guides/quick-start' with the path strategy
|
|
68
|
+
* // 'a3f2e9d1-4b8c7e6f-9d2a1b3c' with the hash strategy
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function docPathToUrl(map: UrlMap, docPath: string): string | null {
|
|
72
|
+
const normalized = normalizeSlug(docPath);
|
|
73
|
+
return map.toUrl[normalized] ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolves a URL segment back to its content path.
|
|
78
|
+
*
|
|
79
|
+
* @param map - Precomputed URL mapping
|
|
80
|
+
* @param slug - URL segment, with or without surrounding slashes
|
|
81
|
+
* @returns The content path, or null when the segment matches no document
|
|
82
|
+
*/
|
|
83
|
+
export function urlToDocPath(map: UrlMap, slug: string): string | null {
|
|
84
|
+
const normalized = normalizeSlug(slug);
|
|
85
|
+
return map.toPath[normalized] ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds an `href` for a content path, ready to hand to a link or router.
|
|
90
|
+
*
|
|
91
|
+
* Falls back to the root path when the document is unknown, which keeps
|
|
92
|
+
* navigation from emitting `/null` for a stale or mistyped reference.
|
|
93
|
+
*
|
|
94
|
+
* @param map - Precomputed URL mapping
|
|
95
|
+
* @param docPath - Content-relative path without extension
|
|
96
|
+
* @returns A root-relative href
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* hrefFor(map, 'guides/quick-start'); // '/guides/quick-start'
|
|
101
|
+
* hrefFor(map, 'does-not-exist'); // '/'
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function hrefFor(map: UrlMap, docPath: string | undefined): string {
|
|
105
|
+
if (!docPath) return '/';
|
|
106
|
+
const url = docPathToUrl(map, docPath);
|
|
107
|
+
return url ? `/${url}` : '/';
|
|
108
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { getAllDocPaths } from '../content/registry';
|
|
2
|
+
import { payload } from '@/payload/config';
|
|
3
|
+
import { generatePathHash } from './hash';
|
|
4
|
+
import { DEFAULT_URL_STRATEGY, normalizeSlug, type UrlMap, type UrlStrategy } from './url';
|
|
5
|
+
import { cached } from '../cache';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Server-side construction of the {@link UrlMap}.
|
|
9
|
+
*
|
|
10
|
+
* This module reaches into the filesystem and into Node's crypto, so it must
|
|
11
|
+
* only be imported from server components, route handlers, and build scripts.
|
|
12
|
+
* Client components receive the finished map through `UrlMapProvider` instead.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns the URL strategy declared by the payload, or the default.
|
|
17
|
+
*/
|
|
18
|
+
export function getUrlStrategy(): UrlStrategy {
|
|
19
|
+
return payload.global.urlStrategy ?? DEFAULT_URL_STRATEGY;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds a bidirectional URL mapping for the given content paths.
|
|
24
|
+
*
|
|
25
|
+
* Under the `hash` strategy a collision would make two documents unreachable
|
|
26
|
+
* through one another's URL, so collisions are surfaced as a build error rather
|
|
27
|
+
* than silently resolving to whichever document happened to be scanned last.
|
|
28
|
+
*
|
|
29
|
+
* @param docPaths - Content-relative paths to include
|
|
30
|
+
* @param strategy - URL strategy to apply
|
|
31
|
+
* @returns The populated mapping
|
|
32
|
+
* @throws Error if two distinct paths produce the same URL segment
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const map = buildUrlMap(['intro', 'guides/quick-start'], 'path');
|
|
37
|
+
* map.toUrl['guides/quick-start']; // 'guides/quick-start'
|
|
38
|
+
* map.toPath['intro']; // 'intro'
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function buildUrlMap(docPaths: string[], strategy: UrlStrategy): UrlMap {
|
|
42
|
+
const toUrl: Record<string, string> = {};
|
|
43
|
+
const toPath: Record<string, string> = {};
|
|
44
|
+
|
|
45
|
+
for (const rawPath of docPaths) {
|
|
46
|
+
const docPath = normalizeSlug(rawPath);
|
|
47
|
+
if (!docPath) continue;
|
|
48
|
+
|
|
49
|
+
const url = strategy === 'hash' ? generatePathHash(docPath) : docPath;
|
|
50
|
+
const existing = toPath[url];
|
|
51
|
+
|
|
52
|
+
if (existing !== undefined && existing !== docPath) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`URL collision under the '${strategy}' strategy: ` +
|
|
55
|
+
`'${existing}' and '${docPath}' both map to '${url}'.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
toUrl[docPath] = url;
|
|
60
|
+
toPath[url] = docPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { strategy, toUrl, toPath };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let memo: UrlMap | null = null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns the URL map for the whole content tree, memoised per process.
|
|
70
|
+
*
|
|
71
|
+
* Every document is included, not only those reachable from the sidebar, so
|
|
72
|
+
* hidden pages and documents omitted from navigation still resolve.
|
|
73
|
+
*
|
|
74
|
+
* @returns The site-wide URL mapping
|
|
75
|
+
*/
|
|
76
|
+
export function getUrlMap(): UrlMap {
|
|
77
|
+
const hit = cached(memo);
|
|
78
|
+
if (hit) return hit;
|
|
79
|
+
memo = buildUrlMap(getAllDocPaths(), getUrlStrategy());
|
|
80
|
+
return memo;
|
|
81
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema definition for payload validation
|
|
3
|
+
*/
|
|
4
|
+
export const payloadSchema = {
|
|
5
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
6
|
+
type: 'object',
|
|
7
|
+
required: ['global'],
|
|
8
|
+
properties: {
|
|
9
|
+
global: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
required: ['title', 'description'],
|
|
12
|
+
properties: {
|
|
13
|
+
title: { type: 'string', minLength: 1 },
|
|
14
|
+
description: { type: 'string', minLength: 1 },
|
|
15
|
+
favicon: { type: 'string' },
|
|
16
|
+
baseUrl: { type: 'string', format: 'uri' },
|
|
17
|
+
urlStrategy: { type: 'string', enum: ['path', 'hash'] },
|
|
18
|
+
autoNavigation: { type: 'boolean' },
|
|
19
|
+
seo: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
openGraph: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
title: { type: 'string' },
|
|
26
|
+
description: { type: 'string' },
|
|
27
|
+
images: {
|
|
28
|
+
type: 'array',
|
|
29
|
+
items: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
required: ['url'],
|
|
32
|
+
properties: {
|
|
33
|
+
url: { type: 'string' },
|
|
34
|
+
width: { type: 'number' },
|
|
35
|
+
height: { type: 'number' },
|
|
36
|
+
alt: { type: 'string' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
navigation: {
|
|
47
|
+
type: 'array',
|
|
48
|
+
items: {
|
|
49
|
+
$ref: '#/definitions/navigationItem',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
theme: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
primary: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' },
|
|
56
|
+
secondary: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' },
|
|
57
|
+
background: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' },
|
|
58
|
+
text: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' },
|
|
59
|
+
sidebarBg: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' },
|
|
60
|
+
codeBg: { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
definitions: {
|
|
65
|
+
navigationItem: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
required: ['name'],
|
|
68
|
+
properties: {
|
|
69
|
+
name: { type: 'string', minLength: 1 },
|
|
70
|
+
path: { type: 'string' },
|
|
71
|
+
icon: { type: 'string' },
|
|
72
|
+
color: { type: 'string' },
|
|
73
|
+
hidden: { type: 'boolean' },
|
|
74
|
+
children: {
|
|
75
|
+
type: 'array',
|
|
76
|
+
items: { $ref: '#/definitions/navigationItem' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|