@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.
- package/README.md +51 -35
- package/astro.config.mjs +19 -1
- package/bin/norna-cli.mjs +6 -4
- 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 +17 -8
- 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 +33 -2
- package/scripts/lib/documentation-routes.json +229 -0
- 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 +39 -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 +12 -39
- 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/PageAliasRedirect.astro +44 -3
- 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 +15 -16
- 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 +12 -23
- 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 +5 -31
- 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 +10 -9
- 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
|
@@ -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,
|
|
@@ -233,7 +233,6 @@ try {
|
|
|
233
233
|
const siteStructure = await getSiteStructure();
|
|
234
234
|
const contentFiles = siteStructure.contentFiles;
|
|
235
235
|
for (const warning of siteStructure.warnings) {
|
|
236
|
-
if (warning.code === 'empty-category') continue;
|
|
237
236
|
addIssue({
|
|
238
237
|
severity: 'warning',
|
|
239
238
|
message: warning.message,
|
|
@@ -424,17 +423,20 @@ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map
|
|
|
424
423
|
return {
|
|
425
424
|
node: {
|
|
426
425
|
...node,
|
|
427
|
-
navigation: {
|
|
428
|
-
listed: node.kind === 'category'
|
|
429
|
-
? true
|
|
430
|
-
: context?.frontmatterData.navigation?.listed ?? true,
|
|
431
|
-
},
|
|
426
|
+
navigation: { listed: context?.frontmatterData.navigation?.listed ?? true },
|
|
432
427
|
},
|
|
433
428
|
};
|
|
434
429
|
}));
|
|
435
430
|
|
|
436
431
|
for (const context of contentFileContexts) {
|
|
437
432
|
const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
|
|
433
|
+
if (context.frontmatterData.page?.listChildren && childPages.length === 0) {
|
|
434
|
+
addContentIssue(context.contentFile, {
|
|
435
|
+
severity: 'warning',
|
|
436
|
+
message: 'page.listChildren has no listed direct child pages to display.',
|
|
437
|
+
fix: 'Add a listed direct child page, or remove page.listChildren while this page has no children.',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
438
440
|
const warnedChildren = new Set();
|
|
439
441
|
|
|
440
442
|
for (const section of context.sections) {
|
|
@@ -444,7 +446,7 @@ for (const context of contentFileContexts) {
|
|
|
444
446
|
if (childPages.length === 0) {
|
|
445
447
|
addSectionIssue(context.contentFile, section, {
|
|
446
448
|
severity: 'error',
|
|
447
|
-
message: `page-list on line ${block.line} has no listed direct child pages to display
|
|
449
|
+
message: `page-list on line ${block.line} has no listed direct child pages to display.`,
|
|
448
450
|
fix: 'Add a listed direct child page or remove the block.',
|
|
449
451
|
});
|
|
450
452
|
continue;
|
|
@@ -33,7 +33,7 @@ const sourceEntries = await readDirectory(sitePublicDir);
|
|
|
33
33
|
const generatedPublicFiles = [
|
|
34
34
|
{
|
|
35
35
|
filename: sitemapFilename,
|
|
36
|
-
explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
|
|
36
|
+
explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/site-config/settings.yaml.`,
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
filename: '404.html',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { siteDir } from './lib/site-paths.mjs';
|
|
2
|
+
import { planSiteUpgrade } from './lib/site-upgrade.mjs';
|
|
3
|
+
|
|
4
|
+
const usage = 'Usage: norna site:upgrade [--apply]\nCheck the root/ source layout. Former layouts require manual conversion; --apply is retained but does not convert old sources.';
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
try {
|
|
7
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
8
|
+
console.log(usage);
|
|
9
|
+
} else {
|
|
10
|
+
if (args.length > 1 || args.some((arg) => arg !== '--apply')) throw new Error(usage);
|
|
11
|
+
await planSiteUpgrade(siteDir);
|
|
12
|
+
console.log('The site already uses the current configuration and homepage layout. No files changed.');
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error(error.message);
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
3
|
+
import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
|
|
4
|
+
import type { SiteNode } from '../lib/sitePages';
|
|
5
|
+
import AreaNavigationPanel from './AreaNavigationPanel.astro';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
node: SiteNavigationNode;
|
|
9
|
+
currentPage?: SiteNode;
|
|
10
|
+
categoryDestinations?: CategoryDestinationMap;
|
|
11
|
+
}
|
|
12
|
+
const { node, currentPage, categoryDestinations } = Astro.props;
|
|
13
|
+
const label = `${projectConfig.locale.labels.navigationMenu}: ${node.node.title}`;
|
|
14
|
+
const panelId = `area-menu-${encodeURIComponent(node.node.pagePath)}`;
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
{node.children.length > 0 && (
|
|
18
|
+
<details class="top-page-menu area-navigation-menu" data-area-menu={node.node.pagePath}>
|
|
19
|
+
<summary aria-label={label} aria-controls={panelId}>
|
|
20
|
+
<span class="top-page-menu-title">{node.node.title}</span>
|
|
21
|
+
</summary>
|
|
22
|
+
<AreaNavigationPanel id={panelId} node={node} currentPage={currentPage} categoryDestinations={categoryDestinations} />
|
|
23
|
+
</details>
|
|
24
|
+
)}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Check } from '@lucide/astro';
|
|
3
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
4
|
+
import { withBasePath } from '../lib/basePath';
|
|
5
|
+
import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
|
|
6
|
+
import { getAreaMenuGroups, resolveAreaNavigation } from '../lib/areaNavigation';
|
|
7
|
+
import type { SiteNode } from '../lib/sitePages';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
id: string;
|
|
11
|
+
node?: SiteNavigationNode;
|
|
12
|
+
currentPage?: SiteNode;
|
|
13
|
+
categoryDestinations?: CategoryDestinationMap;
|
|
14
|
+
otherRoots?: SiteNavigationNode[];
|
|
15
|
+
}
|
|
16
|
+
const { id, node, currentPage, categoryDestinations, otherRoots = [] } = Astro.props;
|
|
17
|
+
const { localRoot } = resolveAreaNavigation(node ? [node] : [], currentPage);
|
|
18
|
+
const { branches, pages } = node ? getAreaMenuGroups(node) : { branches: [], pages: [] };
|
|
19
|
+
const columnsFor = (count: number, maximum: number) => count === 0 ? 0 : Math.ceil(count / Math.ceil(count / maximum));
|
|
20
|
+
const branchColumns = columnsFor(branches.length, pages.length > 0 ? 2 : 3);
|
|
21
|
+
const pageColumns = branches.length === 0 ? columnsFor(pages.length, 3) : pages.length > 0 ? 1 : 0;
|
|
22
|
+
const groups = [
|
|
23
|
+
{ kind: 'branches', choices: branches, columns: branchColumns },
|
|
24
|
+
{ kind: 'pages', choices: pages, columns: pageColumns },
|
|
25
|
+
].filter(({ choices }) => choices.length > 0);
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<nav id={id} class="top-page-panel area-navigation-panel"
|
|
29
|
+
style={`--area-menu-columns: ${branchColumns + pageColumns || 2}`}
|
|
30
|
+
aria-label={node ? `${node.node.title}: ${projectConfig.locale.labels.navigationChildren}` : projectConfig.locale.labels.siteNavigation}>
|
|
31
|
+
{groups.map((group) => <ul class="area-navigation-choices" data-area-choice-group={group.kind}
|
|
32
|
+
style={`--area-group-columns: ${group.columns}; --area-group-rows: ${Math.ceil(group.choices.length / group.columns)}`}>
|
|
33
|
+
{group.choices.map((child) => {
|
|
34
|
+
const destination = getNavigationDestination(child.node, categoryDestinations);
|
|
35
|
+
const selected = localRoot === child || currentPage?.pagePath === child.node.pagePath;
|
|
36
|
+
const description = child.node.kind === 'page'
|
|
37
|
+
? child.node.entry.data.page?.description
|
|
38
|
+
: child.node.description;
|
|
39
|
+
return <li>
|
|
40
|
+
<a href={withBasePath(projectConfig.site.basePath, destination.pathname)}
|
|
41
|
+
aria-current={selected ? 'true' : undefined} data-area-choice={child.node.pagePath}>
|
|
42
|
+
<span class="area-navigation-title">{child.node.title}{selected && <Check aria-hidden="true" />}</span>
|
|
43
|
+
{group.kind === 'branches' && description && <span class="area-navigation-description">{description}</span>}
|
|
44
|
+
</a>
|
|
45
|
+
</li>;
|
|
46
|
+
})}
|
|
47
|
+
</ul>)}
|
|
48
|
+
{otherRoots.length > 0 && <ul class="area-navigation-other-roots" aria-label={projectConfig.locale.labels.siteNavigation}>
|
|
49
|
+
{otherRoots.map(({ node: root }) => <li>
|
|
50
|
+
<a href={withBasePath(projectConfig.site.basePath, getNavigationDestination(root, categoryDestinations).pathname)}>
|
|
51
|
+
{root.isHome ? projectConfig.locale.labels.returnHome : root.title}
|
|
52
|
+
</a>
|
|
53
|
+
</li>)}
|
|
54
|
+
</ul>}
|
|
55
|
+
</nav>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Menu } from '@lucide/astro';
|
|
3
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
4
|
+
import { hasLargeAreaMenu, resolveAreaNavigation } from '../lib/areaNavigation';
|
|
5
|
+
import type { CategoryDestinationMap, SiteNavigationNode } from '../lib/siteNavigation';
|
|
6
|
+
import type { SiteNode } from '../lib/sitePages';
|
|
7
|
+
import AreaNavigationPanel from './AreaNavigationPanel.astro';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
nodes: SiteNavigationNode[];
|
|
11
|
+
currentPage?: SiteNode;
|
|
12
|
+
categoryDestinations?: CategoryDestinationMap;
|
|
13
|
+
label?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { nodes, currentPage, categoryDestinations, label } = Astro.props;
|
|
17
|
+
const { globalRoot } = resolveAreaNavigation(nodes, currentPage);
|
|
18
|
+
const currentLabel = label ?? globalRoot?.node.title ?? projectConfig.locale.labels.navigationMenu;
|
|
19
|
+
const menuRoot = globalRoot && globalRoot.children.length > 0 && !hasLargeAreaMenu(globalRoot) ? globalRoot : undefined;
|
|
20
|
+
const otherRoots = nodes.filter((root) => root !== menuRoot);
|
|
21
|
+
const menuLabel = `${projectConfig.locale.labels.navigationMenu}: ${currentLabel}`;
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<details class="top-page-menu area-navigation-switcher" data-area-switcher>
|
|
25
|
+
<summary aria-label={menuLabel} title={menuLabel} aria-controls="area-switcher-panel">
|
|
26
|
+
<Menu aria-hidden="true" />
|
|
27
|
+
</summary>
|
|
28
|
+
<AreaNavigationPanel id="area-switcher-panel" node={menuRoot} currentPage={currentPage}
|
|
29
|
+
categoryDestinations={categoryDestinations} otherRoots={otherRoots} />
|
|
30
|
+
</details>
|
|
@@ -104,13 +104,6 @@ const labels = projectConfig.locale.labels;
|
|
|
104
104
|
const imageBounds = image.getBoundingClientRect();
|
|
105
105
|
if (imageBounds.height < usableViewportHeight * persistentCaptionHeightRatio) return;
|
|
106
106
|
|
|
107
|
-
const visibleContentsRail = Array.from(
|
|
108
|
-
layout.querySelectorAll<HTMLElement>('.page-contents-navigation-rail'),
|
|
109
|
-
).some(isVisible);
|
|
110
|
-
const reservesContentsLane = layout.dataset.pageContentsPlacement === 'contents-rail'
|
|
111
|
-
&& document.documentElement.dataset.focusReading !== 'on';
|
|
112
|
-
if (visibleContentsRail || reservesContentsLane) return;
|
|
113
|
-
|
|
114
107
|
const frameBounds = frame.getBoundingClientRect();
|
|
115
108
|
const figureBounds = figure.getBoundingClientRect();
|
|
116
109
|
const captionBounds = caption.getBoundingClientRect();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
import styles from '../styles/navigation-continuity.css?inline';
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<style is:inline set:html={styles} />
|
|
6
|
+
<script is:inline>
|
|
7
|
+
// A cold history arrival can restore its position at pagereveal, then lose
|
|
8
|
+
// it to native fragment scrolling when deferred modules finish parsing.
|
|
9
|
+
// Keep the browser's restored position until that initial load completes.
|
|
10
|
+
window.addEventListener('pagereveal', () => {
|
|
11
|
+
if (!location.hash || document.readyState === 'complete'
|
|
12
|
+
|| performance.getEntriesByType('navigation')[0]?.type !== 'back_forward'
|
|
13
|
+
|| history.scrollRestoration !== 'auto') return;
|
|
14
|
+
|
|
15
|
+
history.scrollRestoration = 'manual';
|
|
16
|
+
const release = () => {
|
|
17
|
+
history.scrollRestoration = 'auto';
|
|
18
|
+
window.removeEventListener('load', release);
|
|
19
|
+
window.removeEventListener('pagehide', release);
|
|
20
|
+
};
|
|
21
|
+
window.addEventListener('load', release);
|
|
22
|
+
window.addEventListener('pagehide', release);
|
|
23
|
+
}, { once: true });
|
|
24
|
+
</script>
|
|
25
|
+
<!-- A streamed response must finish the page before its transition snapshot. -->
|
|
26
|
+
<link rel="expect" blocking="render" href="#norna-navigation-ready" />
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
3
3
|
import { withBasePath } from '../lib/basePath';
|
|
4
|
-
import type
|
|
4
|
+
import { getNavigationDestination, type CategoryDestinationMap, type SiteNavigationNode } from '../lib/siteNavigation';
|
|
5
5
|
import type { SiteNode, SitePage } from '../lib/sitePages';
|
|
6
6
|
import PageContentsLinks from './PageContentsLinks.astro';
|
|
7
7
|
|
|
8
8
|
interface Props {
|
|
9
9
|
nodes: SiteNavigationNode[];
|
|
10
10
|
currentPage?: SiteNode;
|
|
11
|
+
categoryDestinations?: CategoryDestinationMap;
|
|
11
12
|
variant: 'mobile' | 'sidebar' | 'submenu';
|
|
12
13
|
integratePageSections?: boolean;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const { nodes, currentPage, variant, integratePageSections = false } = Astro.props;
|
|
16
|
+
const { nodes, currentPage, categoryDestinations, variant, integratePageSections = false } = Astro.props;
|
|
16
17
|
const pageHref = (page: SitePage) => withBasePath(projectConfig.site.basePath, page.pathname);
|
|
18
|
+
const categoryHref = (node: SiteNavigationNode) => withBasePath(
|
|
19
|
+
projectConfig.site.basePath, getNavigationDestination(node.node, categoryDestinations).pathname,
|
|
20
|
+
);
|
|
17
21
|
const hasBranch = (node: SiteNavigationNode) => node.children.length > 0;
|
|
18
22
|
const isCurrentPage = (page: SitePage) => currentPage?.pathname === page.pathname;
|
|
19
23
|
const isCurrentBranch = (node: SiteNode) => Boolean(currentPage && (
|
|
@@ -71,13 +75,17 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
|
|
|
71
75
|
class="navigation-page-summary"
|
|
72
76
|
aria-controls={getBranchId(node.node)}
|
|
73
77
|
>
|
|
74
|
-
|
|
78
|
+
<a class:list={['navigation-page-summary-title', 'navigation-category-link', {
|
|
79
|
+
'mobile-nav-destination': variant === 'mobile',
|
|
80
|
+
}]}
|
|
81
|
+
href={categoryHref(node)}>{node.node.title}</a>
|
|
75
82
|
<span class="navigation-page-chevron" aria-hidden="true"></span>
|
|
76
83
|
</summary>
|
|
77
84
|
<div id={getBranchId(node.node)} class="navigation-page-branch-content">
|
|
78
85
|
<Astro.self
|
|
79
86
|
nodes={node.children}
|
|
80
87
|
currentPage={currentPage}
|
|
88
|
+
categoryDestinations={categoryDestinations}
|
|
81
89
|
variant={variant}
|
|
82
90
|
integratePageSections={integratePageSections}
|
|
83
91
|
/>
|
|
@@ -123,6 +131,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
|
|
|
123
131
|
<Astro.self
|
|
124
132
|
nodes={node.children}
|
|
125
133
|
currentPage={currentPage}
|
|
134
|
+
categoryDestinations={categoryDestinations}
|
|
126
135
|
variant={variant}
|
|
127
136
|
integratePageSections={integratePageSections}
|
|
128
137
|
/>
|
|
@@ -174,7 +183,7 @@ const usesPageDisclosure = (node: SiteNavigationNode) => variant !== 'submenu' &
|
|
|
174
183
|
{node.node.title}
|
|
175
184
|
</a>
|
|
176
185
|
{node.children.length > 0 && (
|
|
177
|
-
<Astro.self nodes={node.children} currentPage={currentPage} variant={variant} />
|
|
186
|
+
<Astro.self nodes={node.children} currentPage={currentPage} categoryDestinations={categoryDestinations} variant={variant} />
|
|
178
187
|
)}
|
|
179
188
|
</>
|
|
180
189
|
)}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getNavigationStateScript } from '../lib/navigationStateScript.mjs';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
scope: 'desktop' | 'mobile';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { scope } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<script is:inline data-navigation-state-scope={scope} set:html={getNavigationStateScript()} />
|