@janga/norna 0.7.27 → 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.
- package/README.md +37 -28
- package/astro.config.mjs +19 -1
- package/bin/norna-cli.mjs +5 -3
- package/package.json +9 -5
- package/schemas/card-list.schema.json +1 -1
- package/schemas/config.schema.json +66 -66
- package/schemas/content-frontmatter.schema.json +16 -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 +13 -161
- package/scripts/build-site.mjs +11 -6
- package/scripts/check-config.mjs +7 -23
- package/scripts/dev-local.mjs +17 -10
- package/scripts/export-theme-preset.mjs +2 -2
- package/scripts/generate-images.mjs +7 -6
- package/scripts/generate-schemas.mjs +9 -6
- package/scripts/lib/category-destinations.mjs +13 -12
- package/scripts/lib/documentation-links.mjs +1 -1
- package/scripts/lib/documentation-routes.json +67 -60
- package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
- package/scripts/lib/editor-file-policy.mjs +18 -0
- package/scripts/lib/editor-language-service.mjs +53 -55
- package/scripts/lib/editor-page-addresses.mjs +55 -0
- package/scripts/lib/editor-page-placement.mjs +172 -0
- package/scripts/lib/editor-site-editing-tree.mjs +146 -0
- package/scripts/lib/editor-site-files.mjs +153 -0
- package/scripts/lib/editor-site-links.mjs +79 -0
- package/scripts/lib/editor-site-tree.mjs +151 -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/navigation-model.mjs +2 -44
- package/scripts/lib/navigation-review.mjs +14 -50
- package/scripts/lib/norna-markdown-blocks.mjs +1 -1
- package/scripts/lib/page-aliases.mjs +3 -1
- package/scripts/lib/page-model.mjs +17 -20
- package/scripts/lib/page-move-apply.mjs +117 -0
- package/scripts/lib/page-move-plan.mjs +27 -59
- package/scripts/lib/presentation.mjs +3 -3
- package/scripts/lib/project-config.mjs +7 -8
- package/scripts/lib/schema-definitions.mjs +11 -38
- package/scripts/lib/schema-editor-metadata.mjs +12 -59
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/site-content.mjs +7 -24
- package/scripts/lib/site-conventions.mjs +19 -1
- package/scripts/lib/site-navigation-tree.mjs +2 -7
- package/scripts/lib/site-node-create.mjs +169 -0
- package/scripts/lib/site-paths.mjs +22 -10
- package/scripts/lib/site-structure.mjs +72 -82
- package/scripts/lib/site-upgrade.mjs +30 -0
- package/scripts/lib/source-files.mjs +34 -0
- 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 +3 -124
- package/scripts/show-typography.mjs +32 -17
- package/scripts/sync-content-sections.mjs +9 -7
- package/scripts/sync-site-public.mjs +1 -1
- 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/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 +9 -1
- package/src/components/SectionNavigationScript.astro +106 -26
- package/src/components/SiteNavigation.astro +45 -20
- package/src/components/SitePage.astro +37 -61
- 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 +10 -8
- package/src/layouts/BaseLayout.astro +15 -39
- package/src/lib/areaNavigation.ts +42 -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 +3 -2
- package/src/lib/siteNavigation.ts +6 -0
- package/src/lib/sitePages.ts +9 -6
- package/src/pages/404.astro +1 -1
- package/src/pages/[...slug].astro +5 -15
- package/src/pages/index.astro +1 -3
- package/src/styles/area-navigation.css +399 -0
- package/src/styles/content.css +2 -29
- 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/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/{config.yaml → site-config/settings.yaml} +0 -0
- /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
|
@@ -1,77 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from './
|
|
7
|
-
import {
|
|
8
|
-
sitePagesDir,
|
|
9
|
-
sitePagesLabel,
|
|
10
|
-
siteThemeLabel,
|
|
11
|
-
siteThemePath,
|
|
12
|
-
} from './site-paths.mjs';
|
|
3
|
+
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
4
|
+
import { siteDir, siteThemePath } from './site-paths.mjs';
|
|
5
|
+
import { readThemePackage } from './theme-packages.mjs';
|
|
6
|
+
import { getSourcePageLocation } from './source-files.mjs';
|
|
13
7
|
import { resolveThemePresentation } from './presentation.mjs';
|
|
14
|
-
import { resolveThemeConfig } from './theme-presets.mjs';
|
|
15
|
-
import { pageThemeSchema, themeVisualSchema } from './schema-definitions.mjs';
|
|
16
|
-
import { parseYamlConfig } from './yaml-config.mjs';
|
|
17
8
|
|
|
18
9
|
export const readThemeConfig = async () => {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
throw error;
|
|
25
|
-
});
|
|
26
|
-
const config = parseYamlConfig(themeFile, siteThemeLabel, {
|
|
27
|
-
schema: themeVisualSchema,
|
|
28
|
-
validateStructure: validateThemeYamlStructure,
|
|
29
|
-
});
|
|
30
|
-
resolveThemeConfig(config, siteThemeLabel);
|
|
31
|
-
resolveThemePresentation(config, siteThemeLabel);
|
|
32
|
-
|
|
33
|
-
return config;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const getPageThemeFiles = async (directory, relativeDirectory = '') => {
|
|
37
|
-
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
38
|
-
if (error?.code === 'ENOENT') return [];
|
|
39
|
-
throw error;
|
|
40
|
-
});
|
|
41
|
-
const files = [];
|
|
42
|
-
|
|
43
|
-
for (const entry of entries) {
|
|
44
|
-
const relativePath = path.join(relativeDirectory, entry.name);
|
|
45
|
-
const absolutePath = path.join(directory, entry.name);
|
|
46
|
-
if (entry.isDirectory()) {
|
|
47
|
-
files.push(...await getPageThemeFiles(absolutePath, relativePath));
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (entry.name === 'theme.yaml') {
|
|
52
|
-
files.push({
|
|
53
|
-
path: absolutePath,
|
|
54
|
-
label: `${sitePagesLabel}/${relativePath.split(path.sep).join('/')}`,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return files;
|
|
10
|
+
const theme = await readThemePackage({ siteRoot: siteDir, filename: siteThemePath });
|
|
11
|
+
resolveThemePresentation(theme.config, theme.label);
|
|
12
|
+
return theme.config;
|
|
60
13
|
};
|
|
61
14
|
|
|
62
15
|
export const validatePageThemeFiles = async () => {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const config = parseYamlConfig(source, file.label, {
|
|
69
|
-
schema: pageThemeSchema,
|
|
70
|
-
validateStructure: validatePageThemeYamlStructure,
|
|
16
|
+
const themes = [];
|
|
17
|
+
const visit = async (directory) => {
|
|
18
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
19
|
+
if (error.code === 'ENOENT') return [];
|
|
20
|
+
throw error;
|
|
71
21
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const filename = path.join(directory, entry.name);
|
|
24
|
+
if (entry.isDirectory()) { await visit(filename); continue; }
|
|
25
|
+
if (!['tree-theme.yaml', 'page-theme.yaml', 'theme.yaml'].includes(entry.name)) continue;
|
|
26
|
+
const location = getSourcePageLocation(siteDir, directory);
|
|
27
|
+
if (!location) continue;
|
|
28
|
+
if (entry.name === 'theme.yaml') throw new Error(`${filename} uses the former theme filename. Use page-theme.yaml for one page only or tree-theme.yaml for inherited branch settings.`);
|
|
29
|
+
if (filename === siteThemePath) continue;
|
|
30
|
+
themes.push({ ...await readThemePackage({ siteRoot: siteDir, filename }), pageDirectory: location.pageDirectory });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
await visit(getSiteSourcePaths(siteDir).root);
|
|
34
|
+
return themes;
|
|
77
35
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getPageDirectoryAncestors, parsePageDirectoryPath } from './page-model.mjs';
|
|
4
|
+
import { getPageSourceDirectory } from './site-conventions.mjs';
|
|
5
|
+
import { getSourceFileDefinition } from './source-files.mjs';
|
|
6
|
+
import { mergeThemeOverrides } from './theme-presets.mjs';
|
|
7
|
+
import { resolveThemePresentation } from './presentation.mjs';
|
|
8
|
+
import { validateThemeYamlStructure } from './site-content.mjs';
|
|
9
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
10
|
+
|
|
11
|
+
export const readThemePackage = async ({ siteRoot, filename, sources = new Map() }) => {
|
|
12
|
+
const definition = getSourceFileDefinition(siteRoot, filename);
|
|
13
|
+
if (!definition || !['rootTheme', 'theme', 'pageTheme'].includes(definition.schemaKind)) throw new Error(`Not a permitted theme location: ${filename}.`);
|
|
14
|
+
const source = sources.has(filename) ? sources.get(filename) : await readFile(filename, 'utf8').catch((error) => {
|
|
15
|
+
if (error.code !== 'ENOENT') throw error;
|
|
16
|
+
if (definition.required) throw new Error(`${filename} is required. Create the root tree theme before running Norna.`);
|
|
17
|
+
return null;
|
|
18
|
+
});
|
|
19
|
+
if (source === null) return null;
|
|
20
|
+
return { path: filename, label: filename, config: parseYamlConfig(source, filename, { schema: definition.schema, validateStructure: validateThemeYamlStructure }) };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Resolve authored overrides only; preset expansion stays in the shared visual
|
|
24
|
+
// resolvers. A page-only file is never considered while walking ancestors.
|
|
25
|
+
export const selectPageTheme = async ({ siteRoot, pageDirectory = '.', sources, includePageTheme = true }) => {
|
|
26
|
+
siteRoot = path.resolve(siteRoot);
|
|
27
|
+
parsePageDirectoryPath(pageDirectory);
|
|
28
|
+
const ancestors = pageDirectory === '.' ? ['.'] : ['.', ...getPageDirectoryAncestors(pageDirectory)];
|
|
29
|
+
let treeTheme;
|
|
30
|
+
const sourceFiles = [];
|
|
31
|
+
for (const ancestor of ancestors) {
|
|
32
|
+
const filename = path.join(siteRoot, getPageSourceDirectory(ancestor), 'tree-theme.yaml');
|
|
33
|
+
const candidate = await readThemePackage({ siteRoot, filename, sources });
|
|
34
|
+
if (candidate) {
|
|
35
|
+
treeTheme = { ...candidate, config: mergeThemeOverrides(treeTheme?.config, candidate.config) };
|
|
36
|
+
sourceFiles.push(candidate);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const pageTheme = includePageTheme
|
|
40
|
+
? await readThemePackage({ siteRoot, filename: path.join(siteRoot, getPageSourceDirectory(pageDirectory), 'page-theme.yaml'), sources })
|
|
41
|
+
: null;
|
|
42
|
+
const selected = pageTheme ? { ...pageTheme, config: mergeThemeOverrides(treeTheme.config, pageTheme.config) } : treeTheme;
|
|
43
|
+
if (pageTheme) sourceFiles.push(pageTheme);
|
|
44
|
+
resolveThemePresentation(treeTheme.config, treeTheme.label);
|
|
45
|
+
resolveThemePresentation(selected.config, selected.label);
|
|
46
|
+
return { treeTheme, pageTheme, selected, sourceFiles };
|
|
47
|
+
};
|
|
@@ -93,7 +93,7 @@ export const getThemePresetMetadata = (presetName) => {
|
|
|
93
93
|
};
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
-
export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
|
|
96
|
+
export const getThemePreset = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
|
|
97
97
|
const preset = themePresets[presetName];
|
|
98
98
|
|
|
99
99
|
if (!preset) {
|
|
@@ -103,62 +103,29 @@ export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
|
|
|
103
103
|
return structuredClone(preset);
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
// Keep authored fields separate from preset defaults so navigation can distinguish
|
|
107
|
+
// an explicit surface request from an adaptable preset default.
|
|
108
|
+
export const mergeThemeOverrides = (base = {}, override = {}) => {
|
|
109
|
+
const merged = override.preset !== undefined ? structuredClone(override) : mergeDeep(base, override);
|
|
110
|
+
if (override.images?.presentation === 'prose-aligned' && override.images.maxAvailableHeightPercent === undefined) {
|
|
111
|
+
delete merged.images.maxAvailableHeightPercent;
|
|
112
|
+
}
|
|
113
|
+
return merged;
|
|
114
|
+
};
|
|
109
115
|
|
|
116
|
+
export const resolveThemeConfig = (theme = {}, sourceLabel = 'root/tree-theme.yaml') => {
|
|
117
|
+
const presetName = theme?.preset;
|
|
110
118
|
const overrides = structuredClone(theme ?? {});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const resolved = {
|
|
114
|
-
preset: presetName,
|
|
119
|
+
const resolved = presetName === undefined ? overrides : {
|
|
115
120
|
...mergeDeep(getThemePreset(presetName, sourceLabel), overrides),
|
|
116
121
|
};
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
overrides.images?.presentation === 'prose-aligned'
|
|
120
|
-
&& overrides.images.maxAvailableHeightPercent === undefined
|
|
121
|
-
) {
|
|
122
|
+
if (overrides.images?.presentation === 'prose-aligned' && overrides.images.maxAvailableHeightPercent === undefined) {
|
|
122
123
|
delete resolved.images.maxAvailableHeightPercent;
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const mergePageThemePart = (base, override, keys) => Object.fromEntries(keys
|
|
129
|
-
.filter((key) => override?.[key] !== undefined || base?.[key] !== undefined)
|
|
130
|
-
.map((key) => [key, override?.[key] ?? base?.[key]]));
|
|
131
|
-
|
|
132
|
-
export const mergePageThemeConfig = (base = {}, override = {}) => {
|
|
133
|
-
const images = {
|
|
134
|
-
...(base.images ?? {}),
|
|
135
|
-
...mergePageThemePart(base.images, override.images, [
|
|
136
|
-
'presentation',
|
|
137
|
-
'width',
|
|
138
|
-
'maxAvailableWidthPercent',
|
|
139
|
-
'maxAvailableHeightPercent',
|
|
140
|
-
]),
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
override.images?.presentation === 'prose-aligned'
|
|
145
|
-
&& override.images.maxAvailableHeightPercent === undefined
|
|
146
|
-
) {
|
|
147
|
-
delete images.maxAvailableHeightPercent;
|
|
125
|
+
if (resolved.images?.presentation === 'prose-aligned' && resolved.images.maxAvailableHeightPercent !== undefined) {
|
|
126
|
+
throw new Error(`images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}. Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".`);
|
|
148
127
|
}
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
...base,
|
|
152
|
-
layout: {
|
|
153
|
-
...(base.layout ?? {}),
|
|
154
|
-
...mergePageThemePart(base.layout, override.layout, ['contentSpacing', 'textWidth']),
|
|
155
|
-
},
|
|
156
|
-
images,
|
|
157
|
-
sections: {
|
|
158
|
-
...(base.sections ?? {}),
|
|
159
|
-
...mergePageThemePart(base.sections, override.sections, ['backgroundPattern']),
|
|
160
|
-
},
|
|
161
|
-
};
|
|
128
|
+
return resolved;
|
|
162
129
|
};
|
|
163
130
|
|
|
164
131
|
const quote = (value) => JSON.stringify(value);
|
|
@@ -171,7 +138,7 @@ const responsiveValueLines = (label, value, indent = 2) => {
|
|
|
171
138
|
];
|
|
172
139
|
};
|
|
173
140
|
|
|
174
|
-
export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
|
|
141
|
+
export const renderThemePresetReference = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
|
|
175
142
|
const preset = getThemePreset(presetName, sourceLabel);
|
|
176
143
|
const metadata = getThemePresetMetadata(presetName);
|
|
177
144
|
const { appearance, corners, layout, images, blocks, typography, palette, sections } = preset;
|
|
@@ -179,8 +146,8 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
|
|
|
179
146
|
return [
|
|
180
147
|
`# Original values for Norna's "${presetName}" theme preset.`,
|
|
181
148
|
`# ${metadata.description}`,
|
|
182
|
-
'# This is a reference file. Norna
|
|
183
|
-
'# Keep the preset in theme.yaml and copy only the values you want to override.',
|
|
149
|
+
'# This is a reference file. Norna loads root/tree-theme.yaml for the shared theme.',
|
|
150
|
+
'# Keep the preset in root/tree-theme.yaml and copy only the values you want to override.',
|
|
184
151
|
`# Available theme presets: ${themePresetNames.join(', ')}.`,
|
|
185
152
|
`preset: ${presetName}`,
|
|
186
153
|
'',
|
|
@@ -152,7 +152,7 @@ const evaluateResponsiveSize = (value, viewportWidth) => {
|
|
|
152
152
|
return Math.min(parsed.maximumRem * 16, Math.max(parsed.minimumRem * 16, preferredPixels));
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
export const assertTypographyContract = (typography, sourceLabel = 'theme.yaml') => {
|
|
155
|
+
export const assertTypographyContract = (typography, sourceLabel = 'root/tree-theme.yaml') => {
|
|
156
156
|
const values = typography?.values ?? typography;
|
|
157
157
|
if (!values?.headings || !values?.body || !values?.caption) {
|
|
158
158
|
throw new Error(`Incomplete typography configuration in ${sourceLabel}.`);
|
|
@@ -427,7 +427,7 @@ export const defaultTypography = {
|
|
|
427
427
|
rhythm: 'normal',
|
|
428
428
|
};
|
|
429
429
|
|
|
430
|
-
export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'theme.yaml') => {
|
|
430
|
+
export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'root/tree-theme.yaml') => {
|
|
431
431
|
const profileName = typography?.profile ?? defaultTypography.profile;
|
|
432
432
|
const rhythmName = typography?.rhythm ?? defaultTypography.rhythm;
|
|
433
433
|
const profile = typographyProfiles[profileName];
|
|
@@ -60,7 +60,7 @@ export const parseYamlConfig = (source, label, {
|
|
|
60
60
|
if (issues.length > 0) {
|
|
61
61
|
throw new Error([
|
|
62
62
|
`${label} has invalid YAML structure.`,
|
|
63
|
-
...issues.map((issue) => `- ${issue.message}`),
|
|
63
|
+
...issues.map((issue) => `- ${issue.message}${issue.fix ? ` ${issue.fix}` : ''}`),
|
|
64
64
|
].join('\n'));
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
2
|
+
|
|
3
|
+
const collectComments = (token, start, end, comments = []) => {
|
|
4
|
+
if (!token || typeof token !== 'object') return comments;
|
|
5
|
+
if (token.type === 'comment' && token.offset >= start && token.offset < end) comments.push(token.source);
|
|
6
|
+
for (const value of Object.values(token)) {
|
|
7
|
+
if (Array.isArray(value)) for (const item of value) collectComments(item, start, end, comments);
|
|
8
|
+
else if (value && typeof value === 'object') collectComments(value, start, end, comments);
|
|
9
|
+
}
|
|
10
|
+
return comments;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const changeYamlField = (yaml, keys, value, eol) => {
|
|
14
|
+
const document = parseDocument(yaml, { keepSourceTokens: true });
|
|
15
|
+
if (document.errors.length) throw new Error('Repair the YAML before editing it automatically.');
|
|
16
|
+
if (document.contents && !isMap(document.contents)) throw new Error('This YAML document requires a mapping.');
|
|
17
|
+
const scalar = (data) => JSON.stringify(data);
|
|
18
|
+
const entryText = (key, data, indent) => {
|
|
19
|
+
const prefix = ' '.repeat(indent);
|
|
20
|
+
if (Array.isArray(data)) return `${prefix}${key}:${eol}${data.map((entry) => `${prefix} - ${scalar(entry)}${eol}`).join('')}`;
|
|
21
|
+
return data && typeof data === 'object' && !Array.isArray(data)
|
|
22
|
+
? `${prefix}${key}:${eol}${Object.entries(data).map(([child, value]) => entryText(child, value, indent + 2)).join('')}`
|
|
23
|
+
: `${prefix}${key}: ${scalar(data)}${eol}`;
|
|
24
|
+
};
|
|
25
|
+
const edit = (start, end, text) => yaml.slice(0, start) + text + yaml.slice(end);
|
|
26
|
+
const insert = (map, key, data) => {
|
|
27
|
+
if (!map) return yaml + (yaml && !yaml.endsWith('\n') ? eol : '') + entryText(key, data, 0);
|
|
28
|
+
if (map.flow) {
|
|
29
|
+
const position = map.range[1] - 1;
|
|
30
|
+
const separator = map.items.length && !yaml.slice(0, position).trimEnd().endsWith(',') ? ', ' : '';
|
|
31
|
+
return edit(position, position, `${separator}${key}: ${scalar(data)}`);
|
|
32
|
+
}
|
|
33
|
+
const indent = map.items[0]?.key.srcToken.indent ?? 0;
|
|
34
|
+
const position = map.range[2];
|
|
35
|
+
return edit(position, position, `${position && yaml[position - 1] !== '\n' ? eol : ''}${entryText(key, data, indent)}`);
|
|
36
|
+
};
|
|
37
|
+
const visit = (map, remaining, ancestors = []) => {
|
|
38
|
+
const [key, ...rest] = remaining;
|
|
39
|
+
const pair = map?.items.find((item) => item.key.value === key);
|
|
40
|
+
if (!pair) {
|
|
41
|
+
if (value === undefined) return yaml;
|
|
42
|
+
return insert(map, key, rest.reduceRight((child, part) => ({ [part]: child }), value));
|
|
43
|
+
}
|
|
44
|
+
if (rest.length) {
|
|
45
|
+
if (!isMap(pair.value)) throw new Error(`Edit ${key} as a YAML mapping in the source before editing it automatically.`);
|
|
46
|
+
return visit(pair.value, rest, [...ancestors, { map, pair }]);
|
|
47
|
+
}
|
|
48
|
+
if (value !== undefined) {
|
|
49
|
+
if ((!isScalar(pair.value) && !(Array.isArray(value) && isSeq(pair.value))) || pair.value.anchor || pair.value.tag
|
|
50
|
+
|| (isSeq(pair.value) && pair.value.items.some((item) => !isScalar(item) || item.anchor || item.tag))) {
|
|
51
|
+
throw new Error(`Edit ${keys.join('.')} in the source; this field uses YAML anchors, tags or values that cannot be changed automatically.`);
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(value) && !map.flow) {
|
|
54
|
+
// Keep aliases in block form so a later page:move can preserve
|
|
55
|
+
// old addresses without asking the author to reformat our output.
|
|
56
|
+
const start = yaml.lastIndexOf('\n', pair.key.range[0] - 1) + 1;
|
|
57
|
+
const end = pair.value.range[2];
|
|
58
|
+
const indent = pair.key.srcToken.indent ?? 0;
|
|
59
|
+
const comments = collectComments(pair.srcToken, start, end);
|
|
60
|
+
return edit(start, end, comments.map((comment) => `${' '.repeat(indent)}${comment}${eol}`).join('')
|
|
61
|
+
+ entryText(pair.key.value, value, indent));
|
|
62
|
+
}
|
|
63
|
+
const start = pair.value.range[0];
|
|
64
|
+
const end = pair.value.range[1];
|
|
65
|
+
const comments = collectComments(pair.value.srcToken, start, end);
|
|
66
|
+
const trailing = yaml.slice(start, end).endsWith('\n') ? eol : '';
|
|
67
|
+
return edit(start, end, scalar(value) + comments.map((comment) => ` ${comment}`).join('') + trailing);
|
|
68
|
+
}
|
|
69
|
+
return remove(map, pair, ancestors);
|
|
70
|
+
};
|
|
71
|
+
const remove = (map, pair, ancestors) => {
|
|
72
|
+
if (!map.flow && map.items.length === 1 && ancestors.length) {
|
|
73
|
+
const parent = ancestors.pop();
|
|
74
|
+
return remove(parent.map, parent.pair, ancestors);
|
|
75
|
+
}
|
|
76
|
+
let start = pair.key.range[0];
|
|
77
|
+
let end = pair.value?.range[2] ?? pair.key.range[2];
|
|
78
|
+
if (map.flow) {
|
|
79
|
+
end = pair.value?.range[1] ?? pair.key.range[1];
|
|
80
|
+
const index = map.items.indexOf(pair);
|
|
81
|
+
const previousComma = pair.srcToken?.start.find((token) => token.type === 'comma');
|
|
82
|
+
const nextComma = map.items[index + 1]?.srcToken?.start.find((token) => token.type === 'comma');
|
|
83
|
+
if (previousComma) start = previousComma.offset;
|
|
84
|
+
else if (nextComma) end = nextComma.offset + 1;
|
|
85
|
+
} else {
|
|
86
|
+
start = yaml.lastIndexOf('\n', start - 1) + 1;
|
|
87
|
+
}
|
|
88
|
+
const comments = collectComments(pair.srcToken, start, end);
|
|
89
|
+
const indent = pair.key.srcToken.indent ?? 0;
|
|
90
|
+
const replacement = comments.map((comment) => `${' '.repeat(indent)}${comment}${eol}`).join('');
|
|
91
|
+
return edit(start, end, replacement);
|
|
92
|
+
};
|
|
93
|
+
return visit(document.contents, keys);
|
|
94
|
+
};
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
access,
|
|
4
|
-
mkdir,
|
|
5
|
-
readFile,
|
|
6
|
-
rename,
|
|
7
|
-
rm,
|
|
8
|
-
rmdir,
|
|
9
|
-
writeFile,
|
|
10
|
-
} from 'node:fs/promises';
|
|
1
|
+
import { applyPageMovePlan } from './lib/page-move-apply.mjs';
|
|
11
2
|
import path from 'node:path';
|
|
12
3
|
import {
|
|
13
4
|
createPageMovePlan,
|
|
@@ -21,6 +12,7 @@ import {
|
|
|
21
12
|
sitePagesDir,
|
|
22
13
|
sitePagesLabel,
|
|
23
14
|
siteProjectRoot,
|
|
15
|
+
siteDir,
|
|
24
16
|
} from './lib/site-paths.mjs';
|
|
25
17
|
import { getSiteStructure } from './lib/site-structure.mjs';
|
|
26
18
|
|
|
@@ -101,119 +93,6 @@ const toDisplayPath = (filePath) => {
|
|
|
101
93
|
: filePath).split(path.sep).join('/');
|
|
102
94
|
};
|
|
103
95
|
|
|
104
|
-
const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
|
|
105
|
-
|
|
106
|
-
const writeFileAtomically = async (filePath, source, mode) => {
|
|
107
|
-
const temporaryPath = path.join(
|
|
108
|
-
path.dirname(filePath),
|
|
109
|
-
`.${path.basename(filePath)}.norna-${process.pid}-${randomUUID()}.tmp`,
|
|
110
|
-
);
|
|
111
|
-
try {
|
|
112
|
-
await writeFile(temporaryPath, source, { mode: mode & 0o777 });
|
|
113
|
-
await rename(temporaryPath, filePath);
|
|
114
|
-
} finally {
|
|
115
|
-
await rm(temporaryPath, { force: true });
|
|
116
|
-
}
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const assertSourcesUnchanged = async (plan) => {
|
|
120
|
-
for (const file of plan.sourceFiles) {
|
|
121
|
-
const currentSource = await readFile(file.contentPath, 'utf8');
|
|
122
|
-
if (currentSource !== file.source) {
|
|
123
|
-
throw new Error([
|
|
124
|
-
`Page move stopped because ${file.contentLabel} changed after the plan was created.`,
|
|
125
|
-
'Run the command again to build a fresh plan. No file was changed.',
|
|
126
|
-
].join('\n'));
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const assertFinalSite = async () => {
|
|
132
|
-
const structure = await getSiteStructure();
|
|
133
|
-
const graph = await getSiteLinkGraph({ siteStructure: structure });
|
|
134
|
-
if (graph.diagnostics.length === 0) return;
|
|
135
|
-
throw new Error([
|
|
136
|
-
'Post-move link validation failed.',
|
|
137
|
-
...graph.diagnostics.flatMap((diagnostic) => [
|
|
138
|
-
`- ${diagnostic.message}`,
|
|
139
|
-
...(diagnostic.fix ? [` Fix: ${diagnostic.fix}`] : []),
|
|
140
|
-
]),
|
|
141
|
-
].join('\n'));
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const applyPlan = async (plan) => {
|
|
145
|
-
await assertSourcesUnchanged(plan);
|
|
146
|
-
let collectionCreated = false;
|
|
147
|
-
let directoryMoved = false;
|
|
148
|
-
let fileWritten = false;
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
if (plan.mode === 'move') {
|
|
152
|
-
if (!plan.destinationCollectionExists) {
|
|
153
|
-
await mkdir(plan.destinationCollectionDirectory);
|
|
154
|
-
collectionCreated = true;
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
await rename(plan.sourceDirectory, plan.destinationDirectory);
|
|
158
|
-
directoryMoved = true;
|
|
159
|
-
} catch (error) {
|
|
160
|
-
if (error?.code === 'EXDEV') {
|
|
161
|
-
throw new Error('Page directories must stay on one filesystem so Norna can move the complete subtree atomically.');
|
|
162
|
-
}
|
|
163
|
-
throw error;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
for (const change of plan.fileChanges) {
|
|
168
|
-
await writeFileAtomically(change.finalPath, change.updatedSource, change.mode);
|
|
169
|
-
fileWritten = true;
|
|
170
|
-
}
|
|
171
|
-
await assertFinalSite();
|
|
172
|
-
} catch (error) {
|
|
173
|
-
const changesStarted = directoryMoved || fileWritten;
|
|
174
|
-
const rollbackErrors = [];
|
|
175
|
-
if (directoryMoved) {
|
|
176
|
-
try {
|
|
177
|
-
await rename(plan.destinationDirectory, plan.sourceDirectory);
|
|
178
|
-
directoryMoved = false;
|
|
179
|
-
} catch (rollbackError) {
|
|
180
|
-
rollbackErrors.push(`Could not restore the page directory: ${rollbackError.message}`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
for (const change of plan.fileChanges) {
|
|
185
|
-
try {
|
|
186
|
-
const restorePath = directoryMoved && change.finalPath !== change.originalPath
|
|
187
|
-
? change.finalPath
|
|
188
|
-
: change.originalPath;
|
|
189
|
-
await writeFileAtomically(restorePath, change.originalSource, change.mode);
|
|
190
|
-
} catch (rollbackError) {
|
|
191
|
-
rollbackErrors.push(`Could not restore ${change.contentLabel}: ${rollbackError.message}`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (collectionCreated && !directoryMoved) {
|
|
196
|
-
try {
|
|
197
|
-
await rmdir(plan.destinationCollectionDirectory);
|
|
198
|
-
} catch (rollbackError) {
|
|
199
|
-
if (rollbackError?.code !== 'ENOENT' && rollbackError?.code !== 'ENOTEMPTY') {
|
|
200
|
-
rollbackErrors.push(`Could not remove the newly created pages directory: ${rollbackError.message}`);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
throw new Error([
|
|
206
|
-
error instanceof Error ? error.message : String(error),
|
|
207
|
-
rollbackErrors.length === 0
|
|
208
|
-
? (changesStarted
|
|
209
|
-
? 'The original page directory and content files were restored.'
|
|
210
|
-
: 'No source file was changed.')
|
|
211
|
-
: 'Rollback was incomplete. Inspect these paths before running page:move again:',
|
|
212
|
-
...rollbackErrors.map((message) => `- ${message}`),
|
|
213
|
-
].join('\n'));
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
|
|
217
96
|
const main = async () => {
|
|
218
97
|
const options = parseArgs(process.argv.slice(2));
|
|
219
98
|
if (options.help) {
|
|
@@ -244,7 +123,7 @@ const main = async () => {
|
|
|
244
123
|
return;
|
|
245
124
|
}
|
|
246
125
|
|
|
247
|
-
await
|
|
126
|
+
await applyPageMovePlan(plan, { siteRoot: siteDir });
|
|
248
127
|
console.log(`\nPage move completed: ${plan.from} -> ${plan.to}`);
|
|
249
128
|
};
|
|
250
129
|
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
defaultTypography,
|
|
3
|
-
resolveTypographyConfig,
|
|
4
2
|
toYamlLines,
|
|
5
3
|
typographyProfiles,
|
|
6
4
|
typographyRhythms,
|
|
@@ -13,10 +11,13 @@ import {
|
|
|
13
11
|
} from './lib/site-content.mjs';
|
|
14
12
|
import { parsePageMarkdown } from './lib/page-markdown.mjs';
|
|
15
13
|
import {
|
|
14
|
+
siteDir,
|
|
16
15
|
siteThemeLabel,
|
|
17
16
|
} from './lib/site-paths.mjs';
|
|
18
17
|
import { readThemeConfig } from './lib/theme-config.mjs';
|
|
19
18
|
import { resolveThemeConfig } from './lib/theme-presets.mjs';
|
|
19
|
+
import { selectPageTheme } from './lib/theme-packages.mjs';
|
|
20
|
+
import { resolveThemePresentation } from './lib/presentation.mjs';
|
|
20
21
|
|
|
21
22
|
const mode = process.argv[2] ?? 'show';
|
|
22
23
|
|
|
@@ -137,14 +138,14 @@ const defaultSources = (profileName, rhythmName) => {
|
|
|
137
138
|
return sources;
|
|
138
139
|
};
|
|
139
140
|
|
|
140
|
-
const applyOverrideSources = (sources, typographyConfig,
|
|
141
|
+
const applyOverrideSources = (sources, typographyConfig, sourceFor) => {
|
|
141
142
|
const overrides = typographyConfig?.overrides;
|
|
142
143
|
if (!overrides) return sources;
|
|
143
144
|
|
|
144
145
|
for (const path of typographyValuePaths) {
|
|
145
146
|
if (hasPath(overrides, path)) {
|
|
146
147
|
setPath(sources, path, {
|
|
147
|
-
source: `${
|
|
148
|
+
source: `${sourceFor(['typography', 'overrides', ...path])} override`,
|
|
148
149
|
inherited: false,
|
|
149
150
|
});
|
|
150
151
|
}
|
|
@@ -153,25 +154,38 @@ const applyOverrideSources = (sources, typographyConfig, sourceLabel) => {
|
|
|
153
154
|
return sources;
|
|
154
155
|
};
|
|
155
156
|
|
|
156
|
-
const resolveAnnotatedTypographyConfig = (
|
|
157
|
-
const
|
|
157
|
+
const resolveAnnotatedTypographyConfig = (theme, sourceLabel, sourceFiles) => {
|
|
158
|
+
const normalized = resolveThemeConfig(theme, sourceLabel);
|
|
159
|
+
const typographyConfig = normalized.typography;
|
|
160
|
+
const resolved = resolveThemePresentation(theme, sourceLabel).typography;
|
|
161
|
+
// Attribute a value to its last authored setting, stopping at the preset
|
|
162
|
+
// that replaced its base. The resolver still determines all effective values.
|
|
163
|
+
const sourceFor = (propertyPath) => {
|
|
164
|
+
for (const file of sourceFiles.toReversed()) {
|
|
165
|
+
if (hasPath(file.config, propertyPath)) return file.label;
|
|
166
|
+
if (file.config.preset !== undefined) return `preset:${file.config.preset} (${file.label})`;
|
|
167
|
+
}
|
|
168
|
+
return 'engine default';
|
|
169
|
+
};
|
|
158
170
|
const sources = applyOverrideSources(
|
|
159
171
|
defaultSources(resolved.profile, resolved.rhythm),
|
|
160
172
|
typographyConfig,
|
|
161
|
-
|
|
173
|
+
sourceFor,
|
|
162
174
|
);
|
|
175
|
+
if (normalized.layout?.textWidth !== undefined) {
|
|
176
|
+
setPath(sources, ['body', 'width'], { source: sourceFor(['layout', 'textWidth']), inherited: false });
|
|
177
|
+
}
|
|
163
178
|
|
|
164
179
|
return {
|
|
165
180
|
profile: {
|
|
166
181
|
value: resolved.profile,
|
|
167
|
-
source:
|
|
168
|
-
...(typographyConfig?.profile ? {} : { inherited: true }),
|
|
182
|
+
source: sourceFor(['typography', 'profile']),
|
|
169
183
|
},
|
|
170
184
|
rhythm: {
|
|
171
185
|
value: resolved.rhythm,
|
|
172
|
-
source:
|
|
173
|
-
...(typographyConfig?.rhythm ? {} : { inherited: true }),
|
|
186
|
+
source: sourceFor(['typography', 'rhythm']),
|
|
174
187
|
},
|
|
188
|
+
fontFamily: { value: typographyConfig.fontFamily, source: sourceFor(['typography', 'fontFamily']) },
|
|
175
189
|
resolved,
|
|
176
190
|
sources,
|
|
177
191
|
};
|
|
@@ -180,16 +194,16 @@ const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
|
|
|
180
194
|
const formatAnnotatedTypography = (annotated) => ({
|
|
181
195
|
profile: annotated.profile,
|
|
182
196
|
rhythm: annotated.rhythm,
|
|
197
|
+
fontFamily: annotated.fontFamily,
|
|
183
198
|
resolved: annotateResolvedValues(annotated.resolved, annotated.sources),
|
|
184
199
|
});
|
|
185
200
|
|
|
186
201
|
const readThemeTypography = async () => {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
return resolveAnnotatedTypographyConfig(themeTypographyConfig ?? defaultTypography, siteThemeLabel);
|
|
202
|
+
const config = await readThemeConfig();
|
|
203
|
+
return resolveAnnotatedTypographyConfig(config, siteThemeLabel, [{ config, label: siteThemeLabel }]);
|
|
190
204
|
};
|
|
191
205
|
|
|
192
|
-
const readPageTypography = async (contentFile
|
|
206
|
+
const readPageTypography = async (contentFile) => {
|
|
193
207
|
const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
194
208
|
const indentationIssues = [];
|
|
195
209
|
validateFrontmatterIndentation(frontmatter, (issue) => indentationIssues.push(issue));
|
|
@@ -201,7 +215,8 @@ const readPageTypography = async (contentFile, siteThemeTypography) => {
|
|
|
201
215
|
].join('\n'));
|
|
202
216
|
}
|
|
203
217
|
|
|
204
|
-
const
|
|
218
|
+
const { selected, sourceFiles } = await selectPageTheme({ siteRoot: siteDir, pageDirectory: contentFile.pageDirectory });
|
|
219
|
+
const pageTypography = resolveAnnotatedTypographyConfig(selected.config, selected.label, sourceFiles);
|
|
205
220
|
const sections = (await parsePageMarkdown(body, { label: contentFile.contentLabel })).sections
|
|
206
221
|
.map((section) => {
|
|
207
222
|
return {
|
|
@@ -225,7 +240,7 @@ if (mode === 'profiles') {
|
|
|
225
240
|
}).join('\n'));
|
|
226
241
|
} else if (mode === 'show') {
|
|
227
242
|
const themeTypography = await readThemeTypography();
|
|
228
|
-
const pages = await Promise.all((await getContentFiles()).map(
|
|
243
|
+
const pages = await Promise.all((await getContentFiles()).map(readPageTypography));
|
|
229
244
|
const output = {
|
|
230
245
|
theme: {
|
|
231
246
|
source: siteThemeLabel,
|