@janga/norna 0.7.27 → 0.7.29
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 +41 -29
- package/astro.config.mjs +33 -3
- package/bin/norna-cli.mjs +5 -3
- package/package.json +18 -12
- package/schemas/card-list.schema.json +1 -1
- package/schemas/config.schema.json +81 -66
- package/schemas/content-frontmatter.schema.json +23 -7
- package/schemas/manifest.json +49 -3
- package/schemas/page-theme.schema.json +1585 -41
- package/schemas/root-theme.schema.json +1767 -0
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +243 -243
- package/scripts/add-site-node.mjs +31 -250
- package/scripts/build-site.mjs +11 -6
- package/scripts/check-config.mjs +7 -23
- package/scripts/dev-local.mjs +51 -17
- package/scripts/export-theme-preset.mjs +2 -2
- package/scripts/generate-images.mjs +89 -70
- package/scripts/generate-schemas.mjs +9 -6
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/category-destinations.mjs +13 -12
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-links.mjs +1 -1
- package/scripts/lib/documentation-routes.json +70 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +20 -0
- package/scripts/lib/editor-language-service.mjs +74 -73
- package/scripts/lib/editor-page-addresses.mjs +57 -0
- package/scripts/lib/editor-page-placement.mjs +102 -0
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +181 -0
- package/scripts/lib/editor-site-files.mjs +154 -0
- package/scripts/lib/editor-site-links.mjs +81 -0
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +158 -0
- package/scripts/lib/editor-source-files.mjs +87 -0
- package/scripts/lib/editor-theme-help.mjs +43 -0
- package/scripts/lib/locale-registry.mjs +1 -1
- package/scripts/lib/locales/en.mjs +1 -1
- package/scripts/lib/logo-assets.mjs +2 -1
- package/scripts/lib/markdown-images.mjs +59 -0
- package/scripts/lib/markdown-links.mjs +29 -9
- package/scripts/lib/navigation-model.mjs +2 -44
- package/scripts/lib/navigation-review.mjs +14 -50
- package/scripts/lib/norna-markdown-blocks.mjs +1 -58
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +8 -1
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +19 -36
- package/scripts/lib/page-move-apply.mjs +119 -0
- package/scripts/lib/page-move-plan.mjs +64 -78
- package/scripts/lib/page-order-source.mjs +22 -0
- package/scripts/lib/page-order.mjs +45 -0
- package/scripts/lib/page-placement-cli.mjs +50 -0
- package/scripts/lib/presentation.mjs +3 -3
- package/scripts/lib/project-config.mjs +10 -8
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +15 -38
- package/scripts/lib/schema-editor-metadata.mjs +16 -60
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/site-content.mjs +8 -25
- package/scripts/lib/site-conventions.mjs +19 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-navigation-tree.mjs +2 -7
- package/scripts/lib/site-node-create.mjs +182 -0
- package/scripts/lib/site-paths.mjs +24 -12
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +86 -95
- package/scripts/lib/site-upgrade.mjs +32 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +34 -0
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/lib/theme-config.mjs +26 -68
- package/scripts/lib/theme-packages.mjs +47 -0
- package/scripts/lib/theme-presets.mjs +20 -53
- package/scripts/lib/typography.mjs +2 -2
- package/scripts/lib/yaml-config.mjs +1 -1
- package/scripts/lib/yaml-source-edit.mjs +94 -0
- package/scripts/move-site-page.mjs +46 -254
- package/scripts/show-typography.mjs +32 -17
- package/scripts/sync-content-sections.mjs +17 -17
- package/scripts/sync-site-public.mjs +44 -66
- package/scripts/upgrade-site.mjs +17 -0
- package/src/components/AreaNavigationMenu.astro +24 -0
- package/src/components/AreaNavigationPanel.astro +55 -0
- package/src/components/AreaNavigationSwitcher.astro +30 -0
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/ImageStackEnhancement.astro +0 -7
- package/src/components/NavigationContinuity.astro +26 -0
- package/src/components/NavigationPageTree.astro +13 -4
- package/src/components/NavigationState.astro +11 -0
- package/src/components/NavigationTreeControls.astro +3 -2
- package/src/components/SearchPage.astro +11 -2
- package/src/components/SectionNavigationScript.astro +106 -26
- package/src/components/SiteNavigation.astro +45 -20
- package/src/components/SitePage.astro +38 -61
- package/src/components/SiteSection.astro +1 -0
- package/src/components/SiteTreeNavigation.astro +38 -18
- package/src/components/TopPageMenu.astro +4 -3
- package/src/components/TreeNavigationScript.astro +50 -57
- package/src/content.config.ts +11 -12
- package/src/layouts/BaseLayout.astro +15 -39
- package/src/lib/areaNavigation.ts +42 -0
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/navigationContinuity.ts +212 -0
- package/src/lib/navigationFollowing.ts +28 -18
- package/src/lib/navigationState.mjs +88 -0
- package/src/lib/navigationStateScript.mjs +76 -0
- package/src/lib/navigationVisibility.ts +9 -0
- package/src/lib/pageThemes.ts +5 -42
- package/src/lib/sectionContent.ts +67 -21
- package/src/lib/siteNavigation.ts +6 -0
- package/src/lib/sitePages.ts +13 -36
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/404.astro +1 -1
- package/src/pages/[...slug].astro +7 -16
- package/src/pages/index.astro +1 -3
- package/src/styles/area-navigation.css +408 -0
- package/src/styles/content.css +8 -29
- package/src/styles/media.css +24 -0
- package/src/styles/navigation-continuity.css +163 -0
- package/src/styles/page-layout.css +57 -68
- package/src/styles/responsive.css +0 -4
- package/starters/basic/README.md +9 -8
- package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
- package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
- package/schemas/category.schema.json +0 -20
- package/src/components/PageContentsNavigation.astro +0 -20
- /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
- /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
- /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// One ordering contract for source discovery, placement and presentation.
|
|
2
|
+
export const pageOrderSpacing = 1000;
|
|
3
|
+
export const maximumPageOrder = Number.MAX_SAFE_INTEGER;
|
|
4
|
+
|
|
5
|
+
export const parsePageOrder = (value, label = 'navigation.order') => {
|
|
6
|
+
if (value === undefined || value === null) return null;
|
|
7
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
8
|
+
throw new Error(`${label} must be a positive safe integer (1–${maximumPageOrder}).`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const comparePageOrder = (left, right) => {
|
|
14
|
+
const a = left.pageOrder ?? null, b = right.pageOrder ?? null;
|
|
15
|
+
return (a === null ? b === null ? 0 : 1 : b === null ? -1 : a - b)
|
|
16
|
+
|| (left.pageId < right.pageId ? -1 : left.pageId > right.pageId ? 1 : 0);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Siblings exclude the inserted page and are already in presentation order.
|
|
20
|
+
export const planPageOrder = (siblings, index) => {
|
|
21
|
+
if (!Number.isInteger(index) || index < 0 || index > siblings.length) throw new Error('Invalid page insertion position.');
|
|
22
|
+
const before = index === 0 ? 0 : siblings[index - 1].pageOrder;
|
|
23
|
+
const after = siblings[index]?.pageOrder ?? null;
|
|
24
|
+
let order = null;
|
|
25
|
+
if (before !== null && before !== undefined) {
|
|
26
|
+
if (after !== null && after - before > 1) order = before + Math.floor((after - before) / 2);
|
|
27
|
+
else if (after === null && before <= maximumPageOrder - pageOrderSpacing) order = before + pageOrderSpacing;
|
|
28
|
+
}
|
|
29
|
+
if (order !== null) return { order, orderChanges: [] };
|
|
30
|
+
if (siblings.length + 1 > Math.floor(maximumPageOrder / pageOrderSpacing)) throw new Error('Too many siblings to assign safe page order values.');
|
|
31
|
+
const orderChanges = siblings.flatMap((node, position) => {
|
|
32
|
+
const newOrder = (position + (position >= index ? 2 : 1)) * pageOrderSpacing;
|
|
33
|
+
return node.pageOrder === newOrder ? [] : [{ node, oldOrder: node.pageOrder, newOrder }];
|
|
34
|
+
});
|
|
35
|
+
return { order: (index + 1) * pageOrderSpacing, orderChanges };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const describeRenumbering = (changes, displayPath = value => value) => changes.length ? [
|
|
39
|
+
`This placement requires new order values for ${changes.length} existing sibling page(s). Their content.md files will be updated:`,
|
|
40
|
+
...changes.map(change => `- ${displayPath(change.contentPath ?? change.node.contentPath)}: ${change.oldOrder ?? 'unset'} → ${change.newOrder}`),
|
|
41
|
+
].join('\n') : '';
|
|
42
|
+
|
|
43
|
+
export const assertRenumberingAllowed = (changes, allowRenumber) => {
|
|
44
|
+
if (changes.length && allowRenumber !== true) throw new Error('Renumbering existing sibling pages requires confirmation. Review the plan; use --allow-renumber for unattended commands. No files changed.');
|
|
45
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { parsePageOrder, assertRenumberingAllowed, describeRenumbering } from './page-order.mjs';
|
|
3
|
+
|
|
4
|
+
export const parsePlacementArguments = (args) => {
|
|
5
|
+
const options = { positionals: [], order: null, parent: null, slug: null, placement: null, target: null, preserveAliases: true };
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
const flags = { '--help': 'help', '-h': 'help', '--dry-run': 'dryRun', '--write': 'write', '--allow-renumber': 'allowRenumber', '--no-aliases': 'noAliases', '--first': 'first', '--last': 'last' };
|
|
8
|
+
const values = { '--parent': 'parent', '--slug': 'slug', '--order': 'order', '--before': 'before', '--after': 'after' };
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const [name, ...tail] = args[i].split('=');
|
|
11
|
+
const key = flags[name] ?? values[name];
|
|
12
|
+
if (!key) {
|
|
13
|
+
if (args[i].startsWith('-')) throw new Error(`Unknown option: ${args[i]}`);
|
|
14
|
+
options.positionals.push(args[i]); continue;
|
|
15
|
+
}
|
|
16
|
+
if (seen.has(key)) throw new Error(`${name} may be specified only once.`);
|
|
17
|
+
seen.add(key);
|
|
18
|
+
if (flags[name]) {
|
|
19
|
+
if (tail.length) throw new Error(`${name} does not take a value.`);
|
|
20
|
+
options[key] = true;
|
|
21
|
+
} else {
|
|
22
|
+
const value = tail.length ? tail.join('=') : args[++i];
|
|
23
|
+
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value.`);
|
|
24
|
+
options[key] = value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (options.order !== null) {
|
|
28
|
+
if (!/^\d+$/.test(options.order)) throw new Error('--order must be a positive integer.');
|
|
29
|
+
options.order = parsePageOrder(Number(options.order), '--order');
|
|
30
|
+
}
|
|
31
|
+
const positions = ['before', 'after', 'first', 'last'].filter(key => seen.has(key));
|
|
32
|
+
if (positions.length > 1) throw new Error('Choose only one of --before, --after, --first or --last.');
|
|
33
|
+
options.placement = positions[0] ?? (options.parent !== null ? 'last' : null);
|
|
34
|
+
if ((options.before || options.after) && options.parent !== null) throw new Error('--before/--after determine the parent; do not combine them with --parent.');
|
|
35
|
+
if (positions.length && options.order !== null) throw new Error('Do not combine --order with a placement selector.');
|
|
36
|
+
if (options.dryRun && options.write) throw new Error('Choose --dry-run or --write, not both.');
|
|
37
|
+
options.target = options.before ?? options.after ?? options.parent ?? '/';
|
|
38
|
+
options.preserveAliases = !options.noAliases;
|
|
39
|
+
return options;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const confirmCliRenumbering = async (changes, options) => {
|
|
43
|
+
if (!changes.length) return true;
|
|
44
|
+
if (options.allowRenumber) return true;
|
|
45
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) assertRenumberingAllowed(changes, false);
|
|
46
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
47
|
+
try { return /^(y|yes)$/i.test((await prompt.question('Apply these order changes? [y/N] ')).trim()); }
|
|
48
|
+
finally { prompt.close(); }
|
|
49
|
+
};
|
|
50
|
+
export { describeRenumbering };
|
|
@@ -453,7 +453,7 @@ export const getPresentationCssVariables = (presentation) => {
|
|
|
453
453
|
return variables;
|
|
454
454
|
};
|
|
455
455
|
|
|
456
|
-
const getSectionSurfaces = (pattern = 'uniform', sourceLabel = 'theme.yaml') => {
|
|
456
|
+
const getSectionSurfaces = (pattern = 'uniform', sourceLabel = 'root/tree-theme.yaml') => {
|
|
457
457
|
const surfaces = sectionBackgroundPatterns[pattern];
|
|
458
458
|
if (!surfaces) {
|
|
459
459
|
throw new Error(`sections.backgroundPattern must be one of ${Object.keys(sectionBackgroundPatterns).join(', ')} in ${sourceLabel}.`);
|
|
@@ -462,7 +462,7 @@ const getSectionSurfaces = (pattern = 'uniform', sourceLabel = 'theme.yaml') =>
|
|
|
462
462
|
return surfaces;
|
|
463
463
|
};
|
|
464
464
|
|
|
465
|
-
export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
|
|
465
|
+
export const resolveThemePresentation = (theme, sourceLabel = 'root/tree-theme.yaml') => {
|
|
466
466
|
const normalizedTheme = resolveThemeConfig(theme, sourceLabel);
|
|
467
467
|
const paletteName = normalizedTheme.palette ?? 'near-monochrome';
|
|
468
468
|
const palette = getPresentationPalette(paletteName);
|
|
@@ -509,7 +509,7 @@ export const resolveThemePresentation = (theme, sourceLabel = 'theme.yaml') => {
|
|
|
509
509
|
|
|
510
510
|
export const assertSectionBackgroundPatternCompatibility = (
|
|
511
511
|
theme,
|
|
512
|
-
sourceLabel = 'theme.yaml',
|
|
512
|
+
sourceLabel = 'root/tree-theme.yaml',
|
|
513
513
|
navigationMode = 'sections',
|
|
514
514
|
) => {
|
|
515
515
|
const requestedPattern = theme?.sections?.backgroundPattern;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { assertSiteSourceFormat } from './site-source-format.mjs';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
2
5
|
import {
|
|
3
6
|
validateConfigYamlStructure,
|
|
4
7
|
} from './site-content.mjs';
|
|
@@ -11,6 +14,7 @@ import { navigationModeNames } from './navigation-model.mjs';
|
|
|
11
14
|
import {
|
|
12
15
|
siteConfigLabel,
|
|
13
16
|
siteConfigPath,
|
|
17
|
+
siteDir,
|
|
14
18
|
siteThemeLabel,
|
|
15
19
|
} from './site-paths.mjs';
|
|
16
20
|
import { localEditorNames, normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
|
|
@@ -20,6 +24,9 @@ import { resolveThemeConfig } from './theme-presets.mjs';
|
|
|
20
24
|
import { parseYamlConfig } from './yaml-config.mjs';
|
|
21
25
|
|
|
22
26
|
const readSiteConfig = async () => {
|
|
27
|
+
if (existsSync(path.join(siteDir, 'site-config/site-theme.yaml')) || existsSync(path.join(siteDir, 'root/theme.yaml')) || existsSync(path.join(siteDir, 'config.yaml')) || existsSync(path.join(siteDir, 'page-theme.yaml')) || existsSync(path.join(siteDir, 'sitewide-content.yaml'))) {
|
|
28
|
+
throw new Error('This site has configuration files in the former locations. Stop its development server and follow the source-layout reference to place technical settings in site-config/ and page files in root/. Move the shared theme to root/tree-theme.yaml with an explicit preset and homepage-only overrides to root/page-theme.yaml. Automatic historical conversion is not provided.');
|
|
29
|
+
}
|
|
23
30
|
const source = await readFile(siteConfigPath, 'utf8').catch((error) => {
|
|
24
31
|
if (error?.code === 'ENOENT') {
|
|
25
32
|
throw new Error(`${siteConfigLabel} is required. Create it before running Norna.`);
|
|
@@ -28,6 +35,7 @@ const readSiteConfig = async () => {
|
|
|
28
35
|
throw error;
|
|
29
36
|
});
|
|
30
37
|
|
|
38
|
+
await assertSiteSourceFormat(siteDir);
|
|
31
39
|
return parseYamlConfig(source, siteConfigLabel, {
|
|
32
40
|
validateStructure: validateConfigYamlStructure,
|
|
33
41
|
});
|
|
@@ -302,13 +310,6 @@ export const resolveThemeVisualConfig = (theme, sourceLabel = siteThemeLabel) =>
|
|
|
302
310
|
defaultImagePresentation,
|
|
303
311
|
sourceLabel,
|
|
304
312
|
);
|
|
305
|
-
if (imagePresentation === 'prose-aligned' && rawImagesConfig.maxAvailableHeightPercent !== undefined) {
|
|
306
|
-
throw new Error([
|
|
307
|
-
`images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}.`,
|
|
308
|
-
'Prose-aligned images follow available horizontal space instead of a viewport-height limit.',
|
|
309
|
-
'Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".',
|
|
310
|
-
].join('\n'));
|
|
311
|
-
}
|
|
312
313
|
|
|
313
314
|
return Object.freeze({
|
|
314
315
|
layout: Object.freeze({
|
|
@@ -371,7 +372,7 @@ export const resolveNavigationConfig = (config, sourceLabel = siteConfigLabel) =
|
|
|
371
372
|
if (Object.hasOwn(rawNavigation, 'sectionTracking')) {
|
|
372
373
|
throw new Error([
|
|
373
374
|
`navigation.sectionTracking is no longer supported in ${sourceLabel}.`,
|
|
374
|
-
'Section tracking is automatic on pages with
|
|
375
|
+
'Section tracking is automatic on tree-navigation pages with H2 headings.',
|
|
375
376
|
'Remove navigation.sectionTracking.',
|
|
376
377
|
].join('\n'));
|
|
377
378
|
}
|
|
@@ -424,6 +425,7 @@ export const projectConfig = Object.freeze({
|
|
|
424
425
|
url: siteUrl.href,
|
|
425
426
|
}),
|
|
426
427
|
...resolveThemeVisualConfig(rawTheme, siteThemeLabel),
|
|
428
|
+
verificationFiles: Object.freeze(rawConfig.verificationFiles ?? []),
|
|
427
429
|
editLink: resolveEditLinkConfig(rawConfig, siteConfigLabel),
|
|
428
430
|
search: Object.freeze({
|
|
429
431
|
enabled: readBoolean(rawConfig, 'search', 'config YAML', false),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { logoAssetFilenames, browserIconAssetFilenames, socialImageAssetFilenames } from './public-asset-conventions.mjs';
|
|
2
|
+
|
|
3
|
+
export const resourcePrefix = '/_norna/';
|
|
4
|
+
export const attachmentOutputPrefix = `${resourcePrefix}downloads/`;
|
|
5
|
+
export const sharedOutputPrefix = `${resourcePrefix}shared/`;
|
|
6
|
+
export const searchOutputDirectory = '_norna/search';
|
|
7
|
+
export const compiledOutputDirectory = '_norna/assets';
|
|
8
|
+
const technicalFiles = new Set(['robots.txt', 'CNAME', 'llms.txt']);
|
|
9
|
+
const siteImages = new Set([...logoAssetFilenames, ...browserIconAssetFilenames, ...socialImageAssetFilenames]);
|
|
10
|
+
const key = value => value.normalize('NFC').toLowerCase();
|
|
11
|
+
const reservedRoots = new Set(['_norna', '_astro', 'images', 'sitemap.xml', '404.html', '.well-known']);
|
|
12
|
+
const safeSegment = value => Boolean(value) && value === value.trim() && !/[\\/\x00-\x1f\x7f<>:"|?*#%]/.test(value) && !/^\.{1,2}$/.test(value) && !/[. ]$/.test(value);
|
|
13
|
+
|
|
14
|
+
export const isVerificationFilename = value => typeof value === 'string' && safeSegment(value)
|
|
15
|
+
&& !value.startsWith('.') && !reservedRoots.has(key(value))
|
|
16
|
+
&& ![...technicalFiles, ...siteImages].some(name => key(name) === key(value));
|
|
17
|
+
|
|
18
|
+
export const attachmentPublicationPath = (pagePathname, filename) => {
|
|
19
|
+
// Double hyphens and ~ cannot occur inside a page slug. Keep the complete
|
|
20
|
+
// page identity in one segment so extensionless files cannot shadow children.
|
|
21
|
+
const pageKey = pagePathname === '/' ? '~home' : pagePathname.slice(1, -1).split('/').join('--');
|
|
22
|
+
return `${attachmentOutputPrefix}${pageKey}/${filename}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const publicFilePublication = (relativePath, { verificationFiles = [] } = {}, { directory = false } = {}) => {
|
|
26
|
+
const segments = relativePath.split('/');
|
|
27
|
+
if (!segments.every(safeSegment)) throw new Error(`public/${relativePath} has an unsafe path. Use regular names without URL punctuation or parent-directory segments.`);
|
|
28
|
+
if (directory) {
|
|
29
|
+
if (segments[0] === '.well-known') return { pathname: '/' + relativePath, kind: 'public-directory' };
|
|
30
|
+
throw new Error(`public/${relativePath}/ is not a supported public folder. Put content images in the owning page’s images/ beside content.md. Only .well-known/ is supported here, for technical files.`);
|
|
31
|
+
}
|
|
32
|
+
if (technicalFiles.has(relativePath) || segments[0] === '.well-known' && segments.length > 1 || verificationFiles.includes(relativePath) && isVerificationFilename(relativePath)) {
|
|
33
|
+
return { pathname: '/' + relativePath, kind: 'technical-file' };
|
|
34
|
+
}
|
|
35
|
+
if (siteImages.has(relativePath)) {
|
|
36
|
+
return { pathname: sharedOutputPrefix + relativePath, kind: 'shared-image' };
|
|
37
|
+
}
|
|
38
|
+
const generated = ['_norna', '_astro', 'sitemap.xml', '404.html'].includes(key(segments[0]));
|
|
39
|
+
throw new Error(`public/${relativePath} ${generated ? 'conflicts with a generated resource area' : 'is not a supported public file'}. Put content images in the owning page’s images/ beside content.md and documents in its downloads/. Public images must use a recognized logo, favicon, apple-touch-icon or social-image filename. Declare exact root verification filenames in verificationFiles.`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const publicationIssues = ({ pages = [], publicFiles = [], attachments = [], images = [], generatedRoutes = [] } = {}) => {
|
|
43
|
+
const issues = [];
|
|
44
|
+
const outputs = [];
|
|
45
|
+
const add = (pathname, label, owner) => outputs.push({ pathname, label, owner });
|
|
46
|
+
for (const page of pages) {
|
|
47
|
+
const label = page.contentFile?.contentLabel ?? page.contentLabel ?? page.pathname;
|
|
48
|
+
for (const route of [page.pathname, ...(page.aliases ?? [])]) {
|
|
49
|
+
if (!route) continue;
|
|
50
|
+
if (['/_norna', '/_astro', '/.well-known'].some(prefix => key(route) === prefix || key(route).startsWith(prefix + '/'))) {
|
|
51
|
+
issues.push({ severity: 'error', code: 'reserved-publication-path', message: `${label} claims ${route}, which belongs to Norna or technical public files.`, fix: 'Choose a page or alias outside the reserved area.' });
|
|
52
|
+
}
|
|
53
|
+
add(route + 'index.html', route === page.pathname ? label : `${label} (alias ${route})`, 'page');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const file of publicFiles) add(file.pathname, file.label ?? file.filePath ?? file.pathname, 'public');
|
|
57
|
+
for (const file of attachments) add(file.pathname, file.filePath ?? file.pathname, 'attachment');
|
|
58
|
+
for (const file of images) add(file.pathname, file.label, 'image');
|
|
59
|
+
for (const route of generatedRoutes) add(route.pathname.endsWith('/') ? route.pathname + 'index.html' : route.pathname, route.label ?? route.pathname, 'generated');
|
|
60
|
+
// 404 is always generated, including sites without a search page.
|
|
61
|
+
if (!generatedRoutes.some(route => route.pathname === '/404.html')) add('/404.html', 'Norna generated 404 page', 'generated');
|
|
62
|
+
const seen = [];
|
|
63
|
+
for (const output of outputs) {
|
|
64
|
+
const target = key(output.pathname);
|
|
65
|
+
const conflict = seen.find(candidate => target === candidate.key || target.startsWith(candidate.key + '/') || candidate.key.startsWith(target + '/'));
|
|
66
|
+
if (conflict) issues.push({ severity: 'error', code: output.owner === 'attachment' || conflict.owner === 'attachment' ? 'attachment-output-collision' : 'publication-collision',
|
|
67
|
+
message: `${output.label} publishes ${output.pathname}, which conflicts with ${conflict.label} at ${conflict.pathname}.`,
|
|
68
|
+
fix: 'Rename or relocate the conflicting source. Each published path must have one owner.' });
|
|
69
|
+
seen.push({ ...output, key: target });
|
|
70
|
+
}
|
|
71
|
+
return issues;
|
|
72
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isVerificationFilename } from './publication-paths.mjs';
|
|
1
2
|
import { z } from 'astro/zod';
|
|
2
3
|
import { localEditorNames } from './edit-source-link.mjs';
|
|
3
4
|
import { imagePresentationNames } from './image-presentation.mjs';
|
|
@@ -167,13 +168,6 @@ const themeLayout = z.object({
|
|
|
167
168
|
gutter: responsiveCssLength.optional().describe('Horizontal page gutter as one value or separate desktop and mobile values.'),
|
|
168
169
|
spacing: themeLayoutSpacing.optional().describe('Fine-grained spacing overrides.'),
|
|
169
170
|
}).strict().describe('Optional layout overrides applied after the preset.');
|
|
170
|
-
const pageThemeLayout = z.object({
|
|
171
|
-
contentSpacing: contentSpacing.optional().describe('Page content spacing. Descendant pages inherit this value.'),
|
|
172
|
-
textWidth: textWidth.optional().describe('Body-text line length. Descendant pages inherit this value.'),
|
|
173
|
-
}).strict().refine(
|
|
174
|
-
(value) => value.contentSpacing !== undefined || value.textWidth !== undefined,
|
|
175
|
-
'Specify contentSpacing, textWidth, or both.',
|
|
176
|
-
).describe('Page-local layout settings inherited by descendant pages.');
|
|
177
171
|
const themeImages = z.object({
|
|
178
172
|
presentation: imagePresentation.optional().describe('Managed-image presentation method. Omit this to keep the selected preset or inherited page setting.'),
|
|
179
173
|
width: visualCssLength.optional().describe('Maximum managed-image width. Omit this to keep the selected preset.'),
|
|
@@ -182,26 +176,21 @@ const themeImages = z.object({
|
|
|
182
176
|
}).strict().describe('Optional defaults for placing managed images inside the page content area. Persistent navigation remains outside this area.');
|
|
183
177
|
const themeCardList = z.object({
|
|
184
178
|
width: cardListWidth,
|
|
185
|
-
}).strict().describe('
|
|
179
|
+
}).strict().describe('Card-list defaults.');
|
|
186
180
|
const themeBlocks = z.object({
|
|
187
181
|
cardList: themeCardList.optional(),
|
|
188
182
|
}).strict().refine(
|
|
189
183
|
(value) => value.cardList !== undefined,
|
|
190
184
|
'Specify cardList.',
|
|
191
|
-
).describe('Optional
|
|
185
|
+
).describe('Optional defaults for structured Norna content blocks.');
|
|
192
186
|
const configNavigation = z.object({
|
|
193
187
|
mode: navigationMode.optional().describe('How Norna presents the discovered page and heading hierarchy. Omit this to select automatic.'),
|
|
194
188
|
}).strict().describe('Site-wide navigation behavior.');
|
|
195
189
|
const themeSections = z.object({
|
|
196
190
|
backgroundPattern: backgroundPattern.optional().describe('Section background pattern. Alternating and accented create full-width bands with sections or top navigation; tree navigation requires uniform.'),
|
|
197
191
|
}).strict().describe('Defaults for page section presentation.');
|
|
198
|
-
const pageThemeSections = z.object({
|
|
199
|
-
backgroundPattern: backgroundPattern.optional().describe('Section background sequence inherited by descendant pages. Non-uniform patterns are invalid with tree navigation.'),
|
|
200
|
-
}).strict().refine(
|
|
201
|
-
(value) => value.backgroundPattern !== undefined,
|
|
202
|
-
'Specify backgroundPattern.',
|
|
203
|
-
).describe('Page-local section presentation inherited by descendant pages.');
|
|
204
192
|
const pageNavigation = z.object({
|
|
193
|
+
order: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional().describe('Sibling presentation order. Positive integer; lower values appear first. Omit to follow ordered siblings, sorted by slug. Duplicate values warn and use slug to break ties.'),
|
|
205
194
|
listed: z.boolean().optional().default(true).describe('List this page in site navigation. The page remains public when false.'),
|
|
206
195
|
}).strict();
|
|
207
196
|
const pageAlias = z.string().regex(
|
|
@@ -210,6 +199,7 @@ const pageAlias = z.string().regex(
|
|
|
210
199
|
).describe('Previous site-relative URL that permanently identifies this page.');
|
|
211
200
|
const pageMetadata = z.object({
|
|
212
201
|
description: z.string().min(1).optional().describe('Page description used in metadata, social previews and generated child-page lists.'),
|
|
202
|
+
listChildren: z.boolean().optional().describe('Append links to every listed direct child page after this page\'s authored content.'),
|
|
213
203
|
aliases: z.array(pageAlias)
|
|
214
204
|
.min(1)
|
|
215
205
|
.refine((values) => new Set(values).size === values.length, 'Page aliases must be unique within the page.')
|
|
@@ -237,8 +227,10 @@ const sitewideFooter = z.object({
|
|
|
237
227
|
}).strict().describe('Site-wide footer content.');
|
|
238
228
|
|
|
239
229
|
const configShape = {
|
|
230
|
+
sourceFormat: z.literal(2).describe('Source format 2: page directories use slugs and sibling order lives in navigation.order frontmatter. Convert older sites before setting this value.'),
|
|
240
231
|
url: z.string().url().describe('Absolute public URL for the built site.'),
|
|
241
232
|
language: z.string().regex(supportedLocaleTagPattern).optional().describe('Site language tag selecting one complete built-in Norna interface language; the default is en.'),
|
|
233
|
+
verificationFiles: z.array(z.string().refine(isVerificationFilename, 'Use one verification filename outside Norna’s reserved and recognized paths.')).optional().describe('Exact filenames in public/ that verify ownership with an external service. Published unchanged at the site root. .well-known/ files need no declaration.'),
|
|
242
234
|
editLink: editLink.optional(),
|
|
243
235
|
navigation: configNavigation.optional(),
|
|
244
236
|
search: z.boolean().optional().default(false).describe('Generate a static site search page and Pagefind index. The default is false.'),
|
|
@@ -251,9 +243,9 @@ const siteShape = {
|
|
|
251
243
|
};
|
|
252
244
|
|
|
253
245
|
const themeVisualShape = {
|
|
254
|
-
preset: themePreset.optional().describe('
|
|
246
|
+
preset: themePreset.optional().describe('Select a new visual base, discarding all inherited theme values. Required in root/tree-theme.yaml; omit elsewhere to modify inherited values. Even the same preset starts a new base.'),
|
|
255
247
|
appearance: themeAppearance.optional(),
|
|
256
|
-
corners: cornerTreatment.optional().describe('
|
|
248
|
+
corners: cornerTreatment.optional().describe('Corner treatment. Omit this to use the selected preset.'),
|
|
257
249
|
layout: themeLayout.optional(),
|
|
258
250
|
images: themeImages.optional(),
|
|
259
251
|
blocks: themeBlocks.optional(),
|
|
@@ -262,44 +254,29 @@ const themeVisualShape = {
|
|
|
262
254
|
sections: themeSections.optional(),
|
|
263
255
|
};
|
|
264
256
|
|
|
265
|
-
const pageThemeShape = {
|
|
266
|
-
layout: pageThemeLayout.optional(),
|
|
267
|
-
images: themeImages.optional(),
|
|
268
|
-
sections: pageThemeSections.optional(),
|
|
269
|
-
};
|
|
270
|
-
|
|
271
257
|
const sitewideShape = {
|
|
272
258
|
logo: sitewideLogo.optional(),
|
|
273
259
|
banners: banners.describe('Site-wide dismissible notices.'),
|
|
274
260
|
footer: sitewideFooter.optional().describe('Site-wide footer content.'),
|
|
275
261
|
};
|
|
276
262
|
|
|
277
|
-
const categoryShape = {
|
|
278
|
-
label: z.string().trim().min(1).describe('Navigation label for a group of child pages without an authored page body.'),
|
|
279
|
-
};
|
|
280
|
-
|
|
281
263
|
export const schemaTopLevelKeys = Object.freeze({
|
|
282
264
|
config: Object.freeze(Object.keys(configShape)),
|
|
283
|
-
category: Object.freeze(Object.keys(categoryShape)),
|
|
284
265
|
content: Object.freeze(Object.keys(siteShape)),
|
|
285
266
|
sitewide: Object.freeze(Object.keys(sitewideShape)),
|
|
286
267
|
theme: Object.freeze(Object.keys(themeVisualShape)),
|
|
287
|
-
pageTheme: Object.freeze(Object.keys(
|
|
268
|
+
pageTheme: Object.freeze(Object.keys(themeVisualShape)),
|
|
288
269
|
});
|
|
289
270
|
|
|
290
271
|
export const configSchema = z.object(configShape).strict()
|
|
291
272
|
.describe('Technical settings for one Norna site. Additional pages cannot provide technical configuration.');
|
|
292
|
-
export const categorySchema = z.object(categoryShape).strict()
|
|
293
|
-
.describe('A navigation group without an authored page body. Opening its URL redirects when its first listed direct child is a page; otherwise it shows a generated child list.');
|
|
294
273
|
export const siteSchema = z.object(siteShape).strict()
|
|
295
274
|
.describe('Frontmatter for a homepage or additional page content.md file.');
|
|
296
275
|
export const themeVisualSchema = z.object(themeVisualShape).strict()
|
|
297
|
-
.describe('
|
|
298
|
-
export const
|
|
299
|
-
.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
)
|
|
303
|
-
.describe('Limited page presentation overrides inherited by descendant pages. Site colors, corners, typography, content-block defaults and navigation remain global.');
|
|
276
|
+
.describe('Visual settings for this page and its descendants. Omit preset to modify inherited values; an explicit preset replaces the complete inherited base, even when the preset name is unchanged.');
|
|
277
|
+
export const rootThemeSchema = z.object({ ...themeVisualShape, preset: themePreset }).strict()
|
|
278
|
+
.describe('Required root/tree-theme.yaml. Select a preset for the site, then add the visual settings you want to override.');
|
|
279
|
+
export const pageThemeSchema = z.object(themeVisualShape).strict()
|
|
280
|
+
.describe('Visual settings for this page only. Omit preset to modify the active tree theme; an explicit preset replaces that base. Descendants keep their tree theme.');
|
|
304
281
|
export const sitewideSchema = z.object(sitewideShape).strict()
|
|
305
282
|
.describe('Editorial content and optional navigation logo display settings shared by every page.');
|
|
@@ -150,15 +150,17 @@ const addSitewideLogoHelp = (jsonSchema) => {
|
|
|
150
150
|
|
|
151
151
|
const addConfigHelp = (jsonSchema) => {
|
|
152
152
|
jsonSchema.markdownDescription = [
|
|
153
|
-
yamlExample('
|
|
154
|
-
'`config.yaml` contains the few technical settings shared by the complete site.',
|
|
153
|
+
yamlExample('sourceFormat: 2\nurl: https://example.com/'),
|
|
154
|
+
'`site-config/settings.yaml` contains the few technical settings shared by the complete site.',
|
|
155
155
|
documentationLink('Configuration reference', 'configuration.md'),
|
|
156
156
|
].join('\n\n');
|
|
157
|
+
addFieldHelp(jsonSchema, 'sourceFormat', 'sourceFormat: 2', 'pages.md', 'page-source-layout');
|
|
157
158
|
addHelp(jsonSchema, 'url', [
|
|
158
159
|
yamlExample('url: https://example.com/'),
|
|
159
160
|
'The absolute public URL. Its path also determines the base path for generated links and assets.',
|
|
160
161
|
documentationLink('Public URL reference', 'configuration.md', 'url'),
|
|
161
162
|
], ['https://example.com/', 'https://owner.github.io/repository-name/']);
|
|
163
|
+
addFieldHelp(jsonSchema, 'verificationFiles', 'verificationFiles:\n - google-example.html', 'public-files.md', 'verification-files');
|
|
162
164
|
addHelp(jsonSchema, 'language', [
|
|
163
165
|
yamlExample('language: en-GB'),
|
|
164
166
|
'Sets the language of the complete site and selects the matching built-in Norna interface text. Regional tags are preserved. The default is `en`; editorial Markdown is not translated.',
|
|
@@ -283,7 +285,7 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
283
285
|
], presentationPaletteNames);
|
|
284
286
|
addHelp(jsonSchema, 'appearance', [
|
|
285
287
|
yamlExample('appearance:\n default: system'),
|
|
286
|
-
'Selects the initial Appearance. The Appearance choice is always available in the site-wide Display panel. Omit this setting to
|
|
288
|
+
'Selects the initial Appearance. The Appearance choice is always available in the site-wide Display panel. Omit this setting to keep the inherited or preset value.',
|
|
287
289
|
documentationLink('Appearance', 'theme.md', 'appearance'),
|
|
288
290
|
]);
|
|
289
291
|
addFieldHelp(jsonSchema, 'appearance.default', 'appearance:\n default: system', 'theme.md', 'appearance', ['system', 'light', 'dark']);
|
|
@@ -463,49 +465,10 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
463
465
|
})]);
|
|
464
466
|
};
|
|
465
467
|
|
|
466
|
-
const addPageThemeHelp = (jsonSchema) => {
|
|
467
|
-
jsonSchema.markdownDescription = [
|
|
468
|
-
yamlExample('layout:\n textWidth: narrow\n contentSpacing: compact'),
|
|
469
|
-
'A limited theme in a page or category directory may adjust only page layout, managed-image sizing and the section background pattern. Descendant pages inherit these values. Tree navigation requires a uniform section background. Site colors, corners, typography, content-block defaults and navigation stay consistent.',
|
|
470
|
-
documentationLink('Page theme reference', 'theme.md', 'page-themes'),
|
|
471
|
-
].join('\n\n');
|
|
472
|
-
addHelp(jsonSchema, 'layout', [
|
|
473
|
-
yamlExample('layout:\n textWidth: narrow\n contentSpacing: compact'),
|
|
474
|
-
'Adjusts body-text line length and vertical content spacing for pages below this directory.',
|
|
475
|
-
documentationLink('Page theme reference', 'theme.md', 'page-themes'),
|
|
476
|
-
]);
|
|
477
|
-
addFieldHelp(jsonSchema, 'layout.textWidth', 'layout:\n textWidth: narrow', 'theme.md', 'page-themes', ['narrow', 'normal', 'wide']);
|
|
478
|
-
addFieldHelp(jsonSchema, 'layout.contentSpacing', 'layout:\n contentSpacing: compact', 'theme.md', 'page-themes', ['compact', 'normal', 'spacious']);
|
|
479
|
-
addHelp(jsonSchema, 'images', [
|
|
480
|
-
yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
|
|
481
|
-
'Adjusts managed-image presentation and sizing inside the content area for pages below this directory. Persistent navigation remains outside this area.',
|
|
482
|
-
documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
|
|
483
|
-
]);
|
|
484
|
-
const imageFields = [
|
|
485
|
-
['images.presentation', 'images:\n presentation: prose-aligned'],
|
|
486
|
-
['images.width', 'images:\n width: 900px'],
|
|
487
|
-
['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
|
|
488
|
-
['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
|
|
489
|
-
['images.maxAvailableWidthPercent.mobile', 'images:\n maxAvailableWidthPercent:\n mobile: 100'],
|
|
490
|
-
['images.maxAvailableHeightPercent', 'images:\n maxAvailableHeightPercent:\n desktop: 74\n mobile: 68'],
|
|
491
|
-
['images.maxAvailableHeightPercent.desktop', 'images:\n maxAvailableHeightPercent:\n desktop: 74'],
|
|
492
|
-
['images.maxAvailableHeightPercent.mobile', 'images:\n maxAvailableHeightPercent:\n mobile: 68'],
|
|
493
|
-
];
|
|
494
|
-
for (const [propertyPath, example] of imageFields) {
|
|
495
|
-
addFieldHelp(jsonSchema, propertyPath, example, 'theme.md', 'image-sizing');
|
|
496
|
-
}
|
|
497
|
-
addHelp(jsonSchema, 'sections', [
|
|
498
|
-
yamlExample('sections:\n backgroundPattern: alternating'),
|
|
499
|
-
'Adjusts the H2 section background pattern for pages below this directory. Alternating and accented create full-width bands but are invalid when navigation resolves to tree.',
|
|
500
|
-
documentationLink('Page theme reference', 'theme.md', 'page-themes'),
|
|
501
|
-
]);
|
|
502
|
-
addFieldHelp(jsonSchema, 'sections.backgroundPattern', 'sections:\n backgroundPattern: alternating', 'theme.md', 'page-themes', ['uniform', 'alternating', 'accented']);
|
|
503
|
-
};
|
|
504
|
-
|
|
505
468
|
const addSitewideHelp = (jsonSchema) => {
|
|
506
469
|
jsonSchema.markdownDescription = [
|
|
507
470
|
yamlExample('footer:\n copyrightMessage: Copyright Example Owner.'),
|
|
508
|
-
'`
|
|
471
|
+
'`site-config/shared-content.yaml` optionally defines banners, footer content, and logo display settings shared by every page.',
|
|
509
472
|
documentationLink('Site-wide content reference', 'sitewide-content.md'),
|
|
510
473
|
].join('\n\n');
|
|
511
474
|
addSitewideLogoHelp(jsonSchema);
|
|
@@ -583,9 +546,10 @@ const addContentHelp = (jsonSchema) => {
|
|
|
583
546
|
]);
|
|
584
547
|
addHelp(jsonSchema, 'page.description', [
|
|
585
548
|
yamlExample('page:\n description: About this project.'),
|
|
586
|
-
'A page-specific description used in metadata and social previews. Generated child-page
|
|
549
|
+
'A page-specific description used in metadata and social previews. Generated child-page lists can show it beside the page title; it is not inserted into the page body.',
|
|
587
550
|
documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
|
|
588
551
|
], ['About this project.']);
|
|
552
|
+
addFieldHelp(jsonSchema, 'page.listChildren', 'page:\n listChildren: true', 'pages.md', 'list-child-pages', [true, false]);
|
|
589
553
|
addHelp(jsonSchema, 'page.aliases', [
|
|
590
554
|
yamlExample('page:\n aliases:\n - /old-about/'),
|
|
591
555
|
'Lists previous site-relative URLs that permanently identify this page. The page containing the list is always the target. Paths omit the configured base path and are excluded from the sitemap.',
|
|
@@ -601,31 +565,23 @@ const addContentHelp = (jsonSchema) => {
|
|
|
601
565
|
'Optionally excludes an additional page from site navigation without removing its public URL.',
|
|
602
566
|
documentationLink('Site navigation reference', 'pages.md', 'navigation'),
|
|
603
567
|
]);
|
|
568
|
+
addFieldHelp(jsonSchema, 'navigation.order', 'navigation:\n order: 1000', 'pages.md', 'navigation');
|
|
604
569
|
addFieldHelp(jsonSchema, 'navigation.listed', 'navigation:\n listed: false', 'pages.md', 'navigation', [true, false]);
|
|
605
570
|
};
|
|
606
571
|
|
|
607
|
-
const addCategoryHelp = (jsonSchema) => {
|
|
608
|
-
jsonSchema.markdownDescription = [
|
|
609
|
-
yamlExample('label: Guides'),
|
|
610
|
-
'`category.yaml` groups child pages without editorial content. Its URL opens the first listed direct page, or a generated child list when the first child is a category.',
|
|
611
|
-
documentationLink('Navigation category reference', 'pages.md', 'navigation-categories'),
|
|
612
|
-
].join('\n\n');
|
|
613
|
-
addHelp(jsonSchema, 'label', [
|
|
614
|
-
yamlExample('label: Guides'),
|
|
615
|
-
'The category label shown in global navigation, tree navigation, mobile navigation, and breadcrumbs.',
|
|
616
|
-
documentationLink('Navigation category reference', 'pages.md', 'navigation-categories'),
|
|
617
|
-
]);
|
|
618
|
-
};
|
|
619
|
-
|
|
620
572
|
export const applySchemaEditorMetadata = (filename, jsonSchema) => {
|
|
621
573
|
addValueDescriptions(jsonSchema);
|
|
622
|
-
if (filename === 'category.schema.json') addCategoryHelp(jsonSchema);
|
|
623
574
|
if (filename === 'config.schema.json') {
|
|
624
575
|
addLanguageSuggestions(jsonSchema);
|
|
625
576
|
addConfigHelp(jsonSchema);
|
|
626
577
|
}
|
|
627
|
-
if (
|
|
628
|
-
|
|
578
|
+
if (['root-theme.schema.json', 'theme.schema.json', 'page-theme.schema.json'].includes(filename)) {
|
|
579
|
+
addThemeHelp(jsonSchema);
|
|
580
|
+
jsonSchema.markdownDescription = [yamlExample(filename === 'root-theme.schema.json' ? 'preset: documentation' : 'layout:\n textWidth: wide'), jsonSchema.description, documentationLink('Theme inheritance', 'theme.md', 'page-themes')].join('\n\n');
|
|
581
|
+
addHelp(jsonSchema, 'preset', [yamlExample('preset: documentation'),
|
|
582
|
+
'Selects a new base and discards inherited theme values, even when the preset name is unchanged. Required in root/tree-theme.yaml. Omit in other theme files to modify inherited values.',
|
|
583
|
+
documentationLink('Theme inheritance', 'theme.md', 'page-themes'), documentationLink('Preset reference', 'theme.md', 'theme-presets')]);
|
|
584
|
+
}
|
|
629
585
|
if (filename === 'sitewide-content.schema.json') addSitewideHelp(jsonSchema);
|
|
630
586
|
if (filename === 'content-frontmatter.schema.json') addContentHelp(jsonSchema);
|
|
631
587
|
return jsonSchema;
|
|
@@ -26,7 +26,7 @@ export const schemaValueDefinitions = Object.freeze([
|
|
|
26
26
|
automatic: option('Automatic', 'Choose one site-wide navigation presentation from the listed page hierarchy. This is the default.'),
|
|
27
27
|
sections: option('Sections', 'Keep a one-page site title and its H2 sections in sticky page navigation.'),
|
|
28
28
|
top: option('Top', 'Keep top-level pages in the global row and give the current page section navigation when needed.'),
|
|
29
|
-
tree: option('Tree', '
|
|
29
|
+
tree: option('Tree', 'Group destinations in menus and show the selected group’s pages and H2 links in a left tree.'),
|
|
30
30
|
}),
|
|
31
31
|
definition(['vscode'], {
|
|
32
32
|
vscode: option('Visual Studio Code', 'Open the current page source in the locally installed Visual Studio Code application.'),
|
|
@@ -22,9 +22,6 @@ const frontmatterDelimiterRegex = /^---\s*$/;
|
|
|
22
22
|
const knownConfigTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.config);
|
|
23
23
|
const knownContentTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.content);
|
|
24
24
|
const knownThemeTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.theme);
|
|
25
|
-
const knownPageThemeTopLevelFrontmatterKeys = new Set(
|
|
26
|
-
schemaTopLevelKeys.pageTheme,
|
|
27
|
-
);
|
|
28
25
|
const knownSitewideTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.sitewide);
|
|
29
26
|
const knownNestedFrontmatterKeys = new Set([
|
|
30
27
|
'align',
|
|
@@ -57,6 +54,7 @@ const knownNestedFrontmatterKeys = new Set([
|
|
|
57
54
|
'listed',
|
|
58
55
|
'logo',
|
|
59
56
|
'lineHeight',
|
|
57
|
+
'listChildren',
|
|
60
58
|
'maxAvailableHeightPercent',
|
|
61
59
|
'maxAvailableWidthPercent',
|
|
62
60
|
'mobile',
|
|
@@ -152,30 +150,21 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
152
150
|
} else if (fileKind === 'content' && key === 'description') {
|
|
153
151
|
fix = 'Indent "description:" under "page:". The optional meta description belongs in the page object.';
|
|
154
152
|
} else if (fileKind === 'content' && key === 'images') {
|
|
155
|
-
fix = 'Put local image references in image-stack or image-carousel
|
|
153
|
+
fix = 'Put local image references in the Markdown body, using images/name.jpg or an image-stack or image-carousel block.';
|
|
156
154
|
} else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
|
|
157
|
-
fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
|
|
155
|
+
fix = `Move "${key}:" to site-config/site-theme.yaml. Visual settings do not belong in content frontmatter.`;
|
|
158
156
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'navigation') {
|
|
159
|
-
message = `Frontmatter line ${lineNumber}: navigation is technical, site-wide configuration and does not belong in ${fileKind}. Set it in config.yaml.`;
|
|
160
|
-
fix = 'Set the navigation mode under "navigation:" in config.yaml.';
|
|
157
|
+
message = `Frontmatter line ${lineNumber}: navigation is technical, site-wide configuration and does not belong in ${fileKind}. Set it in site-config/settings.yaml.`;
|
|
158
|
+
fix = 'Set the navigation mode under "navigation:" in site-config/settings.yaml.';
|
|
161
159
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'shape') {
|
|
162
|
-
message =
|
|
163
|
-
|
|
164
|
-
: `Frontmatter line ${lineNumber}: "shape" was replaced by "corners". Use "square" or replace the old "soft" value with "rounded".`;
|
|
165
|
-
fix = fileKind === 'page theme'
|
|
166
|
-
? 'Remove "shape:" from this page theme. Set "corners: square" or "corners: rounded" in the root theme.yaml when an override is needed.'
|
|
167
|
-
: 'Replace "shape:" with "corners:". Use "square" or replace the old "soft" value with "rounded".';
|
|
160
|
+
message = `Frontmatter line ${lineNumber}: "shape" was replaced by "corners".`;
|
|
161
|
+
fix = 'Use "corners: square" or "corners: rounded"; replace the old "soft" value with "rounded".';
|
|
168
162
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'colorMode') {
|
|
169
163
|
message = `Frontmatter line ${lineNumber}: Theme setting "colorMode" was replaced by "appearance".`;
|
|
170
|
-
fix =
|
|
171
|
-
? 'Remove "colorMode:" from this page theme. Set "appearance:" in the root theme.yaml when an override is needed.'
|
|
172
|
-
: 'Replace "colorMode:" with "appearance:".';
|
|
164
|
+
fix = 'Replace "colorMode:" with "appearance:".';
|
|
173
165
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'readerControls') {
|
|
174
166
|
message = `Frontmatter line ${lineNumber}: Theme setting "readerControls" was removed. Appearance and reading width are always available; Focus reading follows tree navigation.`;
|
|
175
167
|
fix = 'Remove "readerControls:". Use "appearance.default" for the initial Appearance; reading width is always available and tree navigation provides Focus reading.';
|
|
176
|
-
} else if (fileKind === 'page theme' && ['preset', 'appearance', 'corners', 'palette', 'typography'].includes(key)) {
|
|
177
|
-
message = `Frontmatter line ${lineNumber}: page themes may not define site-wide visual identity through "${key}".`;
|
|
178
|
-
fix = `Move "${key}:" to the root theme.yaml. Page themes may set only layout.textWidth, layout.contentSpacing, images, and sections.backgroundPattern.`;
|
|
179
168
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
|
|
180
169
|
message = `Frontmatter line ${lineNumber}: ${fileKind} may not define navigation logo settings. Optional logo display settings belong under "logo:" in ${sitewideContentLabel}.`;
|
|
181
170
|
fix = `Move only the logo display settings under "logo:" in ${sitewideContentLabel}; the homepage Markdown H1 supplies navigation text and logo alternative text.`;
|
|
@@ -223,12 +212,6 @@ export const validateThemeYamlStructure = (frontmatter, addIssue) =>
|
|
|
223
212
|
fileKind: 'theme',
|
|
224
213
|
});
|
|
225
214
|
|
|
226
|
-
export const validatePageThemeYamlStructure = (frontmatter, addIssue) =>
|
|
227
|
-
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
228
|
-
knownTopLevelFrontmatterKeys: knownPageThemeTopLevelFrontmatterKeys,
|
|
229
|
-
fileKind: 'page theme',
|
|
230
|
-
});
|
|
231
|
-
|
|
232
215
|
export const validateSitewideYamlStructure = (frontmatter, addIssue) =>
|
|
233
216
|
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
234
217
|
knownTopLevelFrontmatterKeys: knownSitewideTopLevelFrontmatterKeys,
|
|
@@ -1 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
// Logical identities stay independent of the physical root/ directory.
|
|
4
|
+
export const homePageDirectory = '.';
|
|
5
|
+
export const legacyHomePageDirectory = '000-home';
|
|
6
|
+
|
|
7
|
+
export const getSiteSourcePaths = (siteRoot) => {
|
|
8
|
+
const root = path.join(siteRoot, 'root');
|
|
9
|
+
return { root, pages: path.join(root, 'pages'), content: path.join(root, 'content.md'),
|
|
10
|
+
images: path.join(root, 'images'), theme: path.join(root, 'tree-theme.yaml') };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getPageSourceDirectory = (pageDirectory) => (
|
|
14
|
+
pageDirectory === homePageDirectory ? 'root' : `root/pages/${pageDirectory}`
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const getPageImageSourceKey = (pageDirectory, image) => (
|
|
18
|
+
[getPageSourceDirectory(pageDirectory), 'images', image].filter(Boolean).join('/')
|
|
19
|
+
);
|