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.
Files changed (98) hide show
  1. package/README.md +46 -0
  2. package/bin/create-eziwiki.mjs +83 -0
  3. package/lib/scaffold.mjs +230 -0
  4. package/lib/scaffold.test.mjs +270 -0
  5. package/package.json +38 -0
  6. package/template/README.md +39 -0
  7. package/template/app/[...slug]/page.tsx +162 -0
  8. package/template/app/error.tsx +77 -0
  9. package/template/app/global-error.tsx +76 -0
  10. package/template/app/globals.css +44 -0
  11. package/template/app/graph/page.tsx +62 -0
  12. package/template/app/layout.tsx +164 -0
  13. package/template/app/not-found.tsx +48 -0
  14. package/template/app/page.tsx +34 -0
  15. package/template/app/robots.ts +20 -0
  16. package/template/app/sitemap.ts +48 -0
  17. package/template/components/ThemeToggle.tsx +77 -0
  18. package/template/components/graph/GraphView.tsx +156 -0
  19. package/template/components/layout/Backlinks.tsx +42 -0
  20. package/template/components/layout/Breadcrumb.tsx +88 -0
  21. package/template/components/layout/MobileMenu.tsx +299 -0
  22. package/template/components/layout/NavigationButtons.tsx +87 -0
  23. package/template/components/layout/PageLayout.tsx +89 -0
  24. package/template/components/layout/Sidebar.tsx +376 -0
  25. package/template/components/layout/TabBar.tsx +312 -0
  26. package/template/components/layout/TabBarSkeleton.tsx +12 -0
  27. package/template/components/layout/TabInitializer.tsx +99 -0
  28. package/template/components/layout/TableOfContents.tsx +138 -0
  29. package/template/components/markdown/CodeCopy.tsx +65 -0
  30. package/template/components/markdown/MarkdownContent.tsx +38 -0
  31. package/template/components/markdown/PageTransition.tsx +56 -0
  32. package/template/components/providers/UrlMapProvider.tsx +68 -0
  33. package/template/components/search/SearchDialog.tsx +286 -0
  34. package/template/components/search/SearchTrigger.tsx +41 -0
  35. package/template/content/guides/_meta.json +4 -0
  36. package/template/content/guides/writing.md +82 -0
  37. package/template/content/intro.md +29 -0
  38. package/template/eslintignore +7 -0
  39. package/template/eslintrc.js +40 -0
  40. package/template/gitignore +40 -0
  41. package/template/lib/basePath.test.ts +120 -0
  42. package/template/lib/basePath.ts +108 -0
  43. package/template/lib/cache.ts +36 -0
  44. package/template/lib/content/registry.ts +311 -0
  45. package/template/lib/content/resolver.ts +109 -0
  46. package/template/lib/graph/build.ts +214 -0
  47. package/template/lib/graph/layout.test.ts +189 -0
  48. package/template/lib/graph/layout.ts +247 -0
  49. package/template/lib/markdown/languages.test.ts +85 -0
  50. package/template/lib/markdown/languages.ts +103 -0
  51. package/template/lib/markdown/rehype-plugins.ts +240 -0
  52. package/template/lib/markdown/remark-wikilink.ts +141 -0
  53. package/template/lib/markdown/render.ts +175 -0
  54. package/template/lib/markdown/wikilink.test.ts +91 -0
  55. package/template/lib/markdown/wikilink.ts +85 -0
  56. package/template/lib/navigation/auto.ts +227 -0
  57. package/template/lib/navigation/builder.test.ts +129 -0
  58. package/template/lib/navigation/builder.ts +122 -0
  59. package/template/lib/navigation/hash.ts +32 -0
  60. package/template/lib/navigation/url.test.ts +88 -0
  61. package/template/lib/navigation/url.ts +108 -0
  62. package/template/lib/navigation/urlMap.ts +81 -0
  63. package/template/lib/payload/schema.ts +81 -0
  64. package/template/lib/payload/types.ts +105 -0
  65. package/template/lib/payload/validator.ts +56 -0
  66. package/template/lib/search/build.ts +204 -0
  67. package/template/lib/search/client.ts +190 -0
  68. package/template/lib/search/tokenizer.test.ts +60 -0
  69. package/template/lib/search/tokenizer.ts +83 -0
  70. package/template/lib/search/types.ts +40 -0
  71. package/template/lib/site.ts +86 -0
  72. package/template/lib/store/searchStore.ts +26 -0
  73. package/template/lib/store/tabStore.ts +313 -0
  74. package/template/next-env.d.ts +5 -0
  75. package/template/next.config.js +43 -0
  76. package/template/package-lock.json +9933 -0
  77. package/template/package.json +69 -0
  78. package/template/payload/config.ts +34 -0
  79. package/template/postcss.config.js +6 -0
  80. package/template/prettierignore +7 -0
  81. package/template/prettierrc +8 -0
  82. package/template/public/favicon.svg +10 -0
  83. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  84. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  85. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  86. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  87. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  88. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
  89. package/template/public/images/.gitkeep +0 -0
  90. package/template/scripts/build-search-index.ts +36 -0
  91. package/template/scripts/check-links.ts +40 -0
  92. package/template/scripts/show-urls.ts +48 -0
  93. package/template/scripts/validate-payload.ts +30 -0
  94. package/template/styles/markdown.css +167 -0
  95. package/template/styles/theme.css +65 -0
  96. package/template/tailwind.config.ts +156 -0
  97. package/template/tsconfig.json +32 -0
  98. 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
+ };