@janga/norna 0.7.26 → 0.7.28

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 (110) hide show
  1. package/README.md +51 -35
  2. package/astro.config.mjs +19 -1
  3. package/bin/norna-cli.mjs +6 -4
  4. package/package.json +9 -5
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +66 -66
  7. package/schemas/content-frontmatter.schema.json +17 -8
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +13 -161
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +17 -10
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +7 -6
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/lib/category-destinations.mjs +13 -12
  21. package/scripts/lib/documentation-links.mjs +33 -2
  22. package/scripts/lib/documentation-routes.json +229 -0
  23. package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
  24. package/scripts/lib/editor-file-policy.mjs +18 -0
  25. package/scripts/lib/editor-language-service.mjs +53 -55
  26. package/scripts/lib/editor-page-addresses.mjs +55 -0
  27. package/scripts/lib/editor-page-placement.mjs +172 -0
  28. package/scripts/lib/editor-site-editing-tree.mjs +146 -0
  29. package/scripts/lib/editor-site-files.mjs +153 -0
  30. package/scripts/lib/editor-site-links.mjs +79 -0
  31. package/scripts/lib/editor-site-tree.mjs +151 -0
  32. package/scripts/lib/editor-source-files.mjs +87 -0
  33. package/scripts/lib/editor-theme-help.mjs +43 -0
  34. package/scripts/lib/locale-registry.mjs +1 -1
  35. package/scripts/lib/locales/en.mjs +1 -1
  36. package/scripts/lib/navigation-model.mjs +2 -44
  37. package/scripts/lib/navigation-review.mjs +14 -50
  38. package/scripts/lib/norna-markdown-blocks.mjs +1 -1
  39. package/scripts/lib/page-aliases.mjs +3 -1
  40. package/scripts/lib/page-model.mjs +39 -20
  41. package/scripts/lib/page-move-apply.mjs +117 -0
  42. package/scripts/lib/page-move-plan.mjs +27 -59
  43. package/scripts/lib/presentation.mjs +3 -3
  44. package/scripts/lib/project-config.mjs +7 -8
  45. package/scripts/lib/schema-definitions.mjs +12 -39
  46. package/scripts/lib/schema-editor-metadata.mjs +12 -59
  47. package/scripts/lib/schema-value-definitions.mjs +1 -1
  48. package/scripts/lib/site-content.mjs +7 -24
  49. package/scripts/lib/site-conventions.mjs +19 -1
  50. package/scripts/lib/site-navigation-tree.mjs +2 -7
  51. package/scripts/lib/site-node-create.mjs +169 -0
  52. package/scripts/lib/site-paths.mjs +22 -10
  53. package/scripts/lib/site-structure.mjs +72 -82
  54. package/scripts/lib/site-upgrade.mjs +30 -0
  55. package/scripts/lib/source-files.mjs +34 -0
  56. package/scripts/lib/theme-config.mjs +26 -68
  57. package/scripts/lib/theme-packages.mjs +47 -0
  58. package/scripts/lib/theme-presets.mjs +20 -53
  59. package/scripts/lib/typography.mjs +2 -2
  60. package/scripts/lib/yaml-config.mjs +1 -1
  61. package/scripts/lib/yaml-source-edit.mjs +94 -0
  62. package/scripts/move-site-page.mjs +3 -124
  63. package/scripts/show-typography.mjs +32 -17
  64. package/scripts/sync-content-sections.mjs +9 -7
  65. package/scripts/sync-site-public.mjs +1 -1
  66. package/scripts/upgrade-site.mjs +17 -0
  67. package/src/components/AreaNavigationMenu.astro +24 -0
  68. package/src/components/AreaNavigationPanel.astro +55 -0
  69. package/src/components/AreaNavigationSwitcher.astro +30 -0
  70. package/src/components/ImageStackEnhancement.astro +0 -7
  71. package/src/components/NavigationContinuity.astro +26 -0
  72. package/src/components/NavigationPageTree.astro +13 -4
  73. package/src/components/NavigationState.astro +11 -0
  74. package/src/components/NavigationTreeControls.astro +3 -2
  75. package/src/components/PageAliasRedirect.astro +44 -3
  76. package/src/components/SearchPage.astro +9 -1
  77. package/src/components/SectionNavigationScript.astro +106 -26
  78. package/src/components/SiteNavigation.astro +45 -20
  79. package/src/components/SitePage.astro +37 -61
  80. package/src/components/SiteTreeNavigation.astro +38 -18
  81. package/src/components/TopPageMenu.astro +4 -3
  82. package/src/components/TreeNavigationScript.astro +50 -57
  83. package/src/content.config.ts +15 -16
  84. package/src/layouts/BaseLayout.astro +15 -39
  85. package/src/lib/areaNavigation.ts +42 -0
  86. package/src/lib/navigationContinuity.ts +212 -0
  87. package/src/lib/navigationFollowing.ts +28 -18
  88. package/src/lib/navigationState.mjs +88 -0
  89. package/src/lib/navigationStateScript.mjs +76 -0
  90. package/src/lib/navigationVisibility.ts +9 -0
  91. package/src/lib/pageThemes.ts +5 -42
  92. package/src/lib/sectionContent.ts +3 -2
  93. package/src/lib/siteNavigation.ts +6 -0
  94. package/src/lib/sitePages.ts +12 -23
  95. package/src/pages/404.astro +1 -1
  96. package/src/pages/[...slug].astro +5 -15
  97. package/src/pages/index.astro +1 -3
  98. package/src/styles/area-navigation.css +399 -0
  99. package/src/styles/content.css +5 -31
  100. package/src/styles/navigation-continuity.css +163 -0
  101. package/src/styles/page-layout.css +57 -68
  102. package/src/styles/responsive.css +0 -4
  103. package/starters/basic/README.md +10 -9
  104. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  105. package/schemas/category.schema.json +0 -20
  106. package/src/components/PageContentsNavigation.astro +0 -20
  107. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  108. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  109. /package/starters/basic/site/{config.yaml → site-config/settings.yaml} +0 -0
  110. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -1,26 +1,15 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { mkdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
3
1
  import path from 'node:path';
4
- import { dump as dumpYaml } from 'js-yaml';
5
- import { slugifyAsciiIdentifier } from './lib/heading-ids.mjs';
6
- import { pageDirectoryPattern } from './lib/page-model.mjs';
7
- import {
8
- invocationRoot,
9
- siteDir,
10
- sitePagesDir,
11
- siteProjectRoot,
12
- } from './lib/site-paths.mjs';
13
- import { getSiteStructure } from './lib/site-structure.mjs';
2
+ import { invocationRoot, siteDir, siteProjectRoot } from './lib/site-paths.mjs';
3
+ import { createSiteNode, planSiteNodeCreation } from './lib/site-node-create.mjs';
14
4
 
15
5
  const usage = `
16
6
  Usage:
17
7
  norna page:add <title> [--parent <path>] [--slug <slug>] [--order <NNN>] [--dry-run]
18
- norna category:add <label> [--parent <path>] [--slug <slug>] [--order <NNN>] [--dry-run]
19
8
 
20
9
  Parent selection:
21
10
  --parent / Add a top-level node
22
11
  --parent /guides/ Add a child below the guides node
23
- no --parent Use the current node directory, or site/pages/
12
+ no --parent Use the site root, current node directory, or site/pages/
24
13
  `.trim();
25
14
 
26
15
  const parseValueOption = (rawArgs, index, name) => {
@@ -85,38 +74,6 @@ const parseArgs = (rawArgs) => {
85
74
  return options;
86
75
  };
87
76
 
88
- const parseOrder = (value) => {
89
- if (!/^\d{1,3}$/.test(value)) {
90
- throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
91
- }
92
- const order = Number.parseInt(value, 10);
93
- if (order < 1 || order > 999) {
94
- throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
95
- }
96
- return order;
97
- };
98
-
99
- const normalizeParentPath = (value) => {
100
- if (value === '/') return '';
101
- if (value.includes('\\') || value.includes('?') || value.includes('#')) {
102
- throw new Error(`Invalid --parent "${value}". Use a logical path such as /guides/installation/.`);
103
- }
104
- const normalized = value.replace(/^\/+|\/+$/g, '');
105
- if (!normalized || normalized.split('/').some((segment) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment))) {
106
- throw new Error(`Invalid --parent "${value}". Use / for the top level or a logical path such as /guides/installation/.`);
107
- }
108
- return normalized;
109
- };
110
-
111
- const getNextOrder = (siblings) => {
112
- const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
113
- const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
114
- if (nextOrder > 999) {
115
- throw new Error('No automatic page order remains below 1000. Reorder the sibling nodes before adding another one.');
116
- }
117
- return nextOrder;
118
- };
119
-
120
77
  const toDisplayPath = (filePath) => {
121
78
  const relativePath = path.relative(siteProjectRoot, filePath);
122
79
  return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
@@ -124,85 +81,8 @@ const toDisplayPath = (filePath) => {
124
81
  : filePath).split(path.sep).join('/');
125
82
  };
126
83
 
127
- const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
128
-
129
- const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
130
- if (error?.code === 'ENOENT') return path.resolve(directory);
131
- throw error;
132
- });
133
-
134
- const resolveParent = async ({ nodes, parent }) => {
135
- if (parent !== null) {
136
- const parentPath = normalizeParentPath(parent);
137
- if (!parentPath) {
138
- return { collectionDir: sitePagesDir, node: null, pagePath: '' };
139
- }
140
- const node = nodes.find((candidate) => candidate.pagePath === parentPath);
141
- if (!node) {
142
- throw new Error(`Cannot find parent "${parent}". Use an existing logical page/category path, or / for the top level.`);
143
- }
144
- if (node.isHome) throw new Error('The homepage cannot contain child pages or categories.');
145
- return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
146
- }
147
-
148
- const currentDirectory = await canonicalDirectory(invocationRoot);
149
- if (currentDirectory === await canonicalDirectory(sitePagesDir)) {
150
- return { collectionDir: sitePagesDir, node: null, pagePath: '' };
151
- }
152
- let node = null;
153
- for (const candidate of nodes) {
154
- if (await canonicalDirectory(candidate.nodeDir) === currentDirectory) {
155
- node = candidate;
156
- break;
157
- }
158
- }
159
- if (!node) {
160
- throw new Error([
161
- 'Cannot infer where to add the node from the current directory.',
162
- `Run the command from ${toDisplayPath(sitePagesDir)} for a top-level node, from an existing page/category directory for a child, or pass --parent.`,
163
- ].join('\n'));
164
- }
165
- if (node.isHome) throw new Error('The homepage cannot contain child pages or categories.');
166
- return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
167
- };
168
-
169
- const assertCollectionDirectory = async (collectionDir) => {
170
- try {
171
- const info = await stat(collectionDir);
172
- if (!info.isDirectory()) throw new Error(`${toDisplayPath(collectionDir)} exists but is not a directory.`);
173
- return false;
174
- } catch (error) {
175
- if (error?.code !== 'ENOENT') throw error;
176
- await mkdir(collectionDir);
177
- return true;
178
- }
179
- };
180
-
181
- const writeNode = async ({ destination, kind, title }) => {
182
- const stagingRoot = path.join(siteDir, '.norna', 'create');
183
- await mkdir(stagingRoot, { recursive: true });
184
- const stagingDirectory = path.join(stagingRoot, `node-${process.pid}-${randomUUID()}`);
185
- await mkdir(stagingDirectory);
186
-
187
- try {
188
- if (kind === 'page') {
189
- await writeFile(path.join(stagingDirectory, 'content.md'), `# ${escapeMarkdownHeading(title)}\n\n## Introduction\n\nStart writing here.\n`);
190
- await mkdir(path.join(stagingDirectory, 'images'));
191
- } else {
192
- await writeFile(path.join(stagingDirectory, 'category.yaml'), dumpYaml({ label: title }, {
193
- lineWidth: -1,
194
- noRefs: true,
195
- }));
196
- await mkdir(path.join(stagingDirectory, 'pages'));
197
- }
198
- await rename(stagingDirectory, destination);
199
- } finally {
200
- await rm(stagingDirectory, { force: true, recursive: true });
201
- }
202
- };
203
-
204
84
  const [kind, ...rawArgs] = process.argv.slice(2);
205
- if (!['category', 'page'].includes(kind)) throw new Error(usage);
85
+ if (kind !== 'page') throw new Error(usage);
206
86
  const options = parseArgs(rawArgs);
207
87
  if (options.help) {
208
88
  console.log(usage);
@@ -210,42 +90,14 @@ if (options.help) {
210
90
  }
211
91
 
212
92
  const title = options.titleParts.join(' ').trim();
213
- if (!title || /[\r\n]/.test(title)) throw new Error(`A one-line ${kind === 'page' ? 'page title' : 'category label'} is required.\n${usage}`);
214
- const slug = options.slug ?? slugifyAsciiIdentifier(title);
215
- if (!slug || !pageDirectoryPattern.test(`010-${slug}`)) {
216
- throw new Error(`Invalid ${options.slug === null ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
217
- }
218
-
219
- const structure = await getSiteStructure();
220
- const parent = await resolveParent({ nodes: structure.nodes, parent: options.parent });
221
- const siblings = structure.nodes.filter((node) => node.parentPagePath === (parent.pagePath || null) && !node.isHome);
222
- if (siblings.some((node) => node.pageId === slug)) {
223
- throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
224
- }
225
- const order = options.order === null ? getNextOrder(siblings) : parseOrder(options.order);
226
- if (siblings.some((node) => node.pageOrder === order)) {
227
- throw new Error(`Cannot use order ${String(order).padStart(3, '0')} below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling already uses it.`);
228
- }
229
-
230
- const directoryName = `${String(order).padStart(3, '0')}-${slug}`;
231
- const destination = path.join(parent.collectionDir, directoryName);
232
- const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
93
+ if (!title || /[\r\n]/.test(title)) throw new Error(`A one-line page title is required.\n${usage}`);
94
+ const plan = await planSiteNodeCreation({
95
+ siteRoot: siteDir, kind, title, slug: options.slug, order: options.order,
96
+ parentPath: options.parent, invocationDirectory: invocationRoot,
97
+ });
98
+ const { destination, pagePath } = plan;
233
99
  const action = options.dryRun ? 'Would create' : 'Created';
100
+ if (!options.dryRun) await createSiteNode(plan);
234
101
 
235
- if (!options.dryRun) {
236
- const createdCollection = await assertCollectionDirectory(parent.collectionDir);
237
- try {
238
- await writeNode({ destination, kind, title });
239
- } catch (error) {
240
- if (createdCollection) await rmdir(parent.collectionDir).catch(() => {});
241
- throw error;
242
- }
243
- }
244
-
245
- console.log(`${action} ${kind} "${title}" at ${toDisplayPath(destination)}.`);
246
- if (kind === 'page') {
247
- console.log(`URL: /${pagePath}/`);
248
- } else {
249
- console.log('This navigation category has no page or URL of its own.');
250
- console.log(`Child URL prefix: /${pagePath}/`);
251
- }
102
+ console.log(`${action} page "${title}" at ${toDisplayPath(destination)}.`);
103
+ console.log(`URL: /${pagePath}/`);
@@ -9,9 +9,14 @@ const runScript = (relativePath, args = []) => runInherit(
9
9
  { cwd: siteProjectRoot },
10
10
  );
11
11
 
12
- await runScript('scripts/check-config.mjs');
13
- await runScript('scripts/sync-content-sections.mjs', ['--check']);
14
- await runScript('scripts/sync-site-public.mjs');
15
- await runScript('scripts/generate-images.mjs');
16
- await runAstroInherit(['build']);
17
- await runScript('scripts/generate-search-index.mjs');
12
+ try {
13
+ await runScript('scripts/check-config.mjs');
14
+ await runScript('scripts/sync-content-sections.mjs', ['--check']);
15
+ await runScript('scripts/sync-site-public.mjs');
16
+ await runScript('scripts/generate-images.mjs');
17
+ await runAstroInherit(['build']);
18
+ await runScript('scripts/generate-search-index.mjs');
19
+ } catch (error) {
20
+ console.error(error.message);
21
+ process.exitCode = 1;
22
+ }
@@ -1,12 +1,11 @@
1
- import path from 'node:path';
2
- import { siteConfigLabel, sitePagesDir, sitePublicLabel, siteThemeLabel } from './lib/site-paths.mjs';
1
+ import { siteConfigLabel, siteDir, sitePublicLabel } from './lib/site-paths.mjs';
3
2
  import { getLogoAssets, getPublicAssetInspection } from './lib/logo-assets.mjs';
4
3
  import { logoAssetFilenames, socialImageAssetFilenames } from './lib/public-asset-conventions.mjs';
5
4
  import { getSocialImageAssets } from './lib/social-image-assets.mjs';
6
5
  import { readSitewideContent } from './lib/sitewide-content.mjs';
7
6
  import { assertSectionBackgroundPatternCompatibility } from './lib/presentation.mjs';
8
7
  import { readThemeConfig, validatePageThemeFiles } from './lib/theme-config.mjs';
9
- import { mergePageThemeConfig } from './lib/theme-presets.mjs';
8
+ import { selectPageTheme } from './lib/theme-packages.mjs';
10
9
 
11
10
  const formatErrorMessage = (error) => {
12
11
  if (error instanceof Error) {
@@ -21,26 +20,11 @@ try {
21
20
  const themeConfig = await readThemeConfig();
22
21
  const sitewideContent = await readSitewideContent();
23
22
  const pageThemeFiles = await validatePageThemeFiles();
24
- const pageThemesByDirectory = new Map(pageThemeFiles.map((file) => [path.dirname(file.path), file]));
25
- for (const pageThemeFile of pageThemeFiles) {
26
- const ancestorThemes = [];
27
- let directory = path.dirname(pageThemeFile.path);
28
- while (directory.startsWith(`${sitePagesDir}${path.sep}`)) {
29
- const ancestorTheme = pageThemesByDirectory.get(directory);
30
- if (ancestorTheme) ancestorThemes.unshift(ancestorTheme);
31
- directory = path.dirname(directory);
32
- }
33
-
34
- const resolvedPageTheme = ancestorThemes.reduce(
35
- (current, ancestor) => mergePageThemeConfig(current, ancestor.config),
36
- themeConfig,
37
- );
38
- resolveThemeVisualConfig(resolvedPageTheme, pageThemeFile.label);
39
- }
40
- if (projectConfig.navigation.mode === 'tree') {
41
- assertSectionBackgroundPatternCompatibility(themeConfig, siteThemeLabel, 'tree');
42
- for (const pageThemeFile of pageThemeFiles) {
43
- assertSectionBackgroundPatternCompatibility(pageThemeFile.config, pageThemeFile.label, 'tree');
23
+ for (const pageDirectory of new Set(['.', ...pageThemeFiles.map((file) => file.pageDirectory)])) {
24
+ const { treeTheme, selected } = await selectPageTheme({ siteRoot: siteDir, pageDirectory });
25
+ for (const theme of [treeTheme, selected]) {
26
+ resolveThemeVisualConfig(theme.config, theme.label);
27
+ if (projectConfig.navigation.mode === 'tree') assertSectionBackgroundPatternCompatibility(theme.config, theme.label, 'tree');
44
28
  }
45
29
  }
46
30
  const logoAssets = getLogoAssets();
@@ -18,12 +18,24 @@ const port = Number.parseInt(process.env.NORNA_DEV_PORT ?? '4321', 10);
18
18
  if (!Number.isInteger(port) || port < 1 || port > 65_535) {
19
19
  throw new Error('NORNA_DEV_PORT must be an integer from 1 through 65535.');
20
20
  }
21
+ const args = process.argv.slice(2);
22
+ const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
23
+ const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
24
+ const shouldKillBlockingPort = args.includes('--kill');
25
+ const shouldFollowLogs = args.includes('--follow');
26
+
21
27
  let projectConfig;
22
- try {
23
- ({ default: projectConfig } = await import('./lib/project-config.mjs'));
24
- } catch (error) {
25
- console.error(error instanceof Error ? error.message : String(error));
26
- process.exit(1);
28
+ if (['stop', 'status', 'logs'].includes(command)) {
29
+ // Recovery commands use the recorded server even while sources need conversion.
30
+ const recorded = await readFile(path.join(siteStateDir, 'dev', 'state.json'), 'utf8').then(JSON.parse).catch(() => null);
31
+ projectConfig = { site: { basePath: recorded?.url ? new URL(recorded.url).pathname : '/' } };
32
+ } else {
33
+ try {
34
+ ({ default: projectConfig } = await import('./lib/project-config.mjs'));
35
+ } catch (error) {
36
+ console.error(error instanceof Error ? error.message : String(error));
37
+ process.exit(1);
38
+ }
27
39
  }
28
40
  const localHost = '127.0.0.1';
29
41
  const localUrl = `http://${localHost}:${port}${projectConfig.site.basePath}`;
@@ -38,11 +50,6 @@ const legacyStatePath = path.join(astroCacheDir, 'dev-local.json');
38
50
  const astroStatePath = path.join(astroCacheDir, 'dev.json');
39
51
  const logPath = path.join(astroCacheDir, 'dev.log');
40
52
  const preparationLogPath = path.join(stateDirectory, 'preparation.log');
41
- const args = process.argv.slice(2);
42
- const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
43
- const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
44
- const shouldKillBlockingPort = args.includes('--kill');
45
- const shouldFollowLogs = args.includes('--follow');
46
53
 
47
54
  const runAstro = async (args, options = {}) => execFileAsync(process.execPath, getAstroArgs(args), {
48
55
  cwd: siteProjectRoot,
@@ -4,7 +4,7 @@ import {
4
4
  renderThemePresetReference,
5
5
  themePresetNames,
6
6
  } from './lib/theme-presets.mjs';
7
- import { siteDir, siteThemeLabel } from './lib/site-paths.mjs';
7
+ import { siteDir, siteDirLabel, siteThemeLabel } from './lib/site-paths.mjs';
8
8
 
9
9
  const [presetName, ...extraArgs] = process.argv.slice(2);
10
10
 
@@ -14,7 +14,7 @@ if (!presetName || extraArgs.length > 0) {
14
14
 
15
15
  const filename = `orig-${presetName}-theme.yaml`;
16
16
  const outputPath = path.join(siteDir, filename);
17
- const outputLabel = path.posix.join(path.posix.dirname(siteThemeLabel), filename);
17
+ const outputLabel = path.posix.join(siteDirLabel, filename);
18
18
  const source = renderThemePresetReference(presetName, siteThemeLabel);
19
19
 
20
20
  try {
@@ -1,3 +1,4 @@
1
+ import { getPageImageSourceKey } from './lib/site-conventions.mjs';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
@@ -84,12 +85,12 @@ const getFilePathFromPublicPath = (publicPath) => path.join(astroPublicDir, publ
84
85
  const getSourceHashSlug = (sourceHash) => sourceHash.slice(0, sourceHashSlugLength);
85
86
 
86
87
  const getGeneratedPath = (sourceKey, sourceHash, width) => {
87
- const parsed = path.parse(sourceKey);
88
+ const parsed = path.parse(sourceKey.replace(/^root\//, ''));
88
89
  return path.join(generatedImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
89
90
  };
90
91
 
91
92
  const getOriginalPath = (sourceKey, sourceHash) => {
92
- const parsed = path.parse(sourceKey);
93
+ const parsed = path.parse(sourceKey.replace(/^root\//, ''));
93
94
  return path.join(originalImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}${parsed.ext}`);
94
95
  };
95
96
 
@@ -97,7 +98,7 @@ const fail = (message) => {
97
98
  throw new Error(message);
98
99
  };
99
100
 
100
- const getImageSourceKey = (contentFile, image) => `pages/${contentFile.pageDirectory}/images/${image}`;
101
+ const getImageSourceKey = (contentFile, image) => getPageImageSourceKey(contentFile.pageDirectory, image);
101
102
 
102
103
  const getReferencedImages = async (contentFile) => {
103
104
  const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
@@ -262,7 +263,7 @@ const hasGeneratedVariants = async (variants) => {
262
263
  return true;
263
264
  };
264
265
 
265
- const getReusableEntry = async (sourcePath, previousEntry, sourceHash) => {
266
+ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
266
267
  if (
267
268
  previousEntry?.sourceHash !== sourceHash
268
269
  || previousEntry?.outputVersion !== imageOutputVersion
@@ -272,7 +273,7 @@ const getReusableEntry = async (sourcePath, previousEntry, sourceHash) => {
272
273
  return null;
273
274
  }
274
275
 
275
- const variants = getVariants(sourcePath, sourceHash, getVariantWidths(previousEntry));
276
+ const variants = getVariants(sourceKey, sourceHash, getVariantWidths(previousEntry));
276
277
 
277
278
  if (!(await hasGeneratedVariants(variants))) {
278
279
  return null;
@@ -393,7 +394,7 @@ for (const { sourcePath, sourceKey } of sources) {
393
394
  continue;
394
395
  }
395
396
 
396
- const reusableEntry = await getReusableEntry(sourcePath, previousManifest[sourceKey], sourceHash);
397
+ const reusableEntry = await getReusableEntry(sourceKey, previousManifest[sourceKey], sourceHash);
397
398
 
398
399
  if (reusableEntry) {
399
400
  manifest[sourceKey] = reusableEntry;
@@ -3,22 +3,23 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { z } from 'astro/zod';
5
5
  import {
6
- categorySchema,
7
6
  configSchema,
7
+ rootThemeSchema,
8
8
  pageThemeSchema,
9
9
  siteSchema,
10
10
  sitewideSchema,
11
11
  themeVisualSchema,
12
12
  } from './lib/schema-definitions.mjs';
13
13
  import { applySchemaEditorMetadata } from './lib/schema-editor-metadata.mjs';
14
+ import { sourceFileDefinitions, sourcePageDirectoryPattern } from './lib/source-files.mjs';
14
15
  import { getNornaBlockSchema } from './lib/norna-markdown-blocks.mjs';
15
16
 
16
17
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
18
  const schemaDirectory = path.join(root, 'schemas');
18
19
  const definitions = [
19
- ['category.schema.json', 'Norna page category', categorySchema],
20
20
  ['config.schema.json', 'Norna site configuration', configSchema],
21
- ['theme.schema.json', 'Norna visual theme', themeVisualSchema],
21
+ ['theme.schema.json', 'Norna inherited tree theme', themeVisualSchema],
22
+ ['root-theme.schema.json', 'Norna root tree theme', rootThemeSchema],
22
23
  ['page-theme.schema.json', 'Norna page theme', pageThemeSchema],
23
24
  ['sitewide-content.schema.json', 'Norna site-wide content', sitewideSchema],
24
25
  ['content-frontmatter.schema.json', 'Norna page frontmatter', siteSchema],
@@ -44,15 +45,17 @@ for (const type of blockTypes) {
44
45
  }
45
46
 
46
47
  schemaFiles.set('manifest.json', `${JSON.stringify({
47
- editorApiVersion: 2,
48
- schemaVersion: 3,
48
+ editorApiVersion: 3,
49
+ schemaVersion: 6,
50
+ sourceFiles: sourceFileDefinitions,
51
+ pageDirectoryPattern: sourcePageDirectoryPattern,
49
52
  blockSchemas: Object.fromEntries(blockTypes.map((type) => [type, `${type}.schema.json`])),
50
53
  files: {
51
- category: 'category.schema.json',
52
54
  config: 'config.schema.json',
53
55
  contentFrontmatter: 'content-frontmatter.schema.json',
54
56
  sitewideContent: 'sitewide-content.schema.json',
55
57
  theme: 'theme.schema.json',
58
+ rootTheme: 'root-theme.schema.json',
56
59
  pageTheme: 'page-theme.schema.json',
57
60
  },
58
61
  }, null, 2)}\n`);
@@ -9,28 +9,28 @@ export const createCategoryDestinationModel = (nodes) => {
9
9
  const visit = (branch) => {
10
10
  const { node } = branch;
11
11
  if (node.navigation?.listed === false) return false;
12
- const children = branch.children.filter(({ node: child }) => child.navigation?.listed !== false);
13
- const reachable = children.map(visit).some(Boolean);
12
+ // Empty child categories retain their URLs but do not become menu choices
13
+ // or redirect targets for a parent that still has reachable pages.
14
+ const children = branch.children.filter(visit);
14
15
  if (node.kind !== 'category') return true;
15
- if (!reachable) {
16
+ if (children.length === 0) {
16
17
  const source = node.categorySourceLabel ?? node.nodeLabel ?? node.pageDirectory;
17
18
  diagnostics.push({
18
19
  code: 'category-without-listed-content',
19
- severity: 'error',
20
+ severity: 'warning',
20
21
  message: `${source} has no listed reachable content page.`,
21
- fix: 'Add a listed page under this category, or remove the category.',
22
+ fix: 'Add a listed page to include this category in navigation, or leave it empty while reorganizing.',
22
23
  });
23
- return false;
24
24
  }
25
- const first = children[0].node;
25
+ const first = children[0]?.node;
26
26
  destinations.push({
27
27
  category: node,
28
28
  pathname: getSiteNodePathname(node),
29
- kind: first.kind === 'page' ? 'redirect' : 'listing',
30
- target: first.kind === 'page' ? first : null,
29
+ kind: first?.kind === 'page' ? 'redirect' : 'listing',
30
+ target: first?.kind === 'page' ? first : null,
31
31
  children: children.map(({ node: child }) => child),
32
32
  });
33
- return true;
33
+ return children.length > 0;
34
34
  };
35
35
  getSiteNavigationTree(nodes.map((node) => ({ node }))).forEach(visit);
36
36
  return {
@@ -41,8 +41,9 @@ export const createCategoryDestinationModel = (nodes) => {
41
41
  };
42
42
 
43
43
  export const assertCategoryDestinationModel = (model) => {
44
- if (model.diagnostics.length) {
45
- throw new Error(model.diagnostics.map(({ message, fix }) => `${message}\n${fix}`).join('\n'));
44
+ const errors = model.diagnostics.filter(({ severity }) => severity === 'error');
45
+ if (errors.length) {
46
+ throw new Error(errors.map(({ message, fix }) => `${message}\n${fix}`).join('\n'));
46
47
  }
47
48
  return model;
48
49
  };
@@ -1,8 +1,39 @@
1
1
  import packageJson from '../../package.json' with { type: 'json' };
2
+ import routes from './documentation-routes.json' with { type: 'json' };
3
+
4
+ const websiteRoot = 'https://janga.github.io/norna/reference/';
5
+ const repositoryRoot = 'https://github.com/janga/norna/blob/';
6
+
7
+ // Release tags through 0.7.26 contain the reference under docs/. Newer tags
8
+ // contain its single authored source in the documentation site's page tree.
9
+ export const usesLegacyReference = (version) => {
10
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version ?? '');
11
+ if (!match) return false;
12
+ const [major, minor, patch] = match.slice(1).map(Number);
13
+ return major === 0 && (minor < 7 || (minor === 7 && patch <= 26));
14
+ };
15
+
16
+ const referenceDestination = (file, anchor) => {
17
+ const target = routes.legacy[`${file}#${anchor}`] ?? routes.legacy[file];
18
+ if (!target) throw new Error(`Unknown documentation destination: ${file}${anchor ? `#${anchor}` : ''}`);
19
+ const [route, targetAnchor] = target.split('#');
20
+ const source = routes.sources[route];
21
+ if (!source) throw new Error(`Missing documentation source for ${route}`);
22
+ return { route, source, anchor: targetAnchor, url: websiteRoot + target };
23
+ };
24
+
25
+ export const documentationLinkForVersion = (version, label, file, anchor) => {
26
+ const destination = referenceDestination(file, anchor);
27
+ const reference = version ? `v${version}` : 'main';
28
+ const source = usesLegacyReference(version)
29
+ ? `docs/${file}${anchor ? `#${anchor}` : ''}`
30
+ : `${version === '0.7.27' ? destination.source.replace('site/root/pages/', 'site/pages/') : destination.source}${destination.anchor ? `#${destination.anchor}` : ''}`;
31
+ const sourceLabel = version ? `Norna ${version} source reference` : 'Reference source';
32
+ return `[${label} (current)](${destination.url})\n\n[${sourceLabel}](${repositoryRoot}${reference}/${source})`;
33
+ };
2
34
 
3
35
  export const documentationRef = `v${packageJson.version}`;
4
- export const documentationRoot = `https://github.com/janga/norna/blob/${documentationRef}/docs`;
5
36
 
6
37
  export const documentationLink = (label, file, anchor) => (
7
- `[${label}](${documentationRoot}/${file}${anchor ? `#${anchor}` : ''})`
38
+ documentationLinkForVersion(packageJson.version, label, file, anchor)
8
39
  );