@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,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,256 +1,48 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
access,
|
|
4
|
-
mkdir,
|
|
5
|
-
readFile,
|
|
6
|
-
rename,
|
|
7
|
-
rm,
|
|
8
|
-
rmdir,
|
|
9
|
-
writeFile,
|
|
10
|
-
} from 'node:fs/promises';
|
|
11
1
|
import path from 'node:path';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from './lib/page-
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
Usage:
|
|
29
|
-
norna page:move <old-url> <new-url> [--order <NNN>] [--no-aliases] [--write]
|
|
30
|
-
|
|
31
|
-
Examples:
|
|
32
|
-
norna page:move /guides/install/ /guides/installation/
|
|
33
|
-
norna page:move /guides/install/ /reference/install/ --write
|
|
34
|
-
|
|
35
|
-
The command is a dry run unless --write is present. If the old page has
|
|
36
|
-
already been moved by hand, the same command reconciles its links and aliases.
|
|
37
|
-
`.trim();
|
|
38
|
-
|
|
39
|
-
const parseOrder = (value) => {
|
|
40
|
-
if (!/^\d{1,3}$/.test(value ?? '')) {
|
|
41
|
-
throw new Error(`Invalid --order "${value ?? ''}". Use an integer from 1 to 999.\n${usage}`);
|
|
42
|
-
}
|
|
43
|
-
const order = Number.parseInt(value, 10);
|
|
44
|
-
if (order < 1 || order > 999) {
|
|
45
|
-
throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.\n${usage}`);
|
|
46
|
-
}
|
|
47
|
-
return order;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const parseArgs = (rawArgs) => {
|
|
51
|
-
const options = {
|
|
52
|
-
help: false,
|
|
53
|
-
order: null,
|
|
54
|
-
preserveAliases: true,
|
|
55
|
-
urls: [],
|
|
56
|
-
write: false,
|
|
57
|
-
};
|
|
58
|
-
const seen = new Set();
|
|
59
|
-
|
|
60
|
-
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
61
|
-
const arg = rawArgs[index];
|
|
62
|
-
if (arg === '-h' || arg === '--help') {
|
|
63
|
-
options.help = true;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (arg === '--write') {
|
|
67
|
-
if (seen.has('write')) throw new Error(`--write may be specified only once.\n${usage}`);
|
|
68
|
-
seen.add('write');
|
|
69
|
-
options.write = true;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (arg === '--no-aliases') {
|
|
73
|
-
if (seen.has('aliases')) throw new Error(`--no-aliases may be specified only once.\n${usage}`);
|
|
74
|
-
seen.add('aliases');
|
|
75
|
-
options.preserveAliases = false;
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (arg === '--order' || arg.startsWith('--order=')) {
|
|
79
|
-
if (seen.has('order')) throw new Error(`--order may be specified only once.\n${usage}`);
|
|
80
|
-
seen.add('order');
|
|
81
|
-
const inlineValue = arg.startsWith('--order=') ? arg.slice('--order='.length) : null;
|
|
82
|
-
const value = inlineValue ?? rawArgs[index + 1];
|
|
83
|
-
if (inlineValue === null) index += 1;
|
|
84
|
-
options.order = parseOrder(value);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
88
|
-
options.urls.push(arg);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (!options.help && options.urls.length !== 2) {
|
|
92
|
-
throw new Error(`page:move requires an old URL and a new URL.\n${usage}`);
|
|
93
|
-
}
|
|
94
|
-
return options;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const toDisplayPath = (filePath) => {
|
|
98
|
-
const relativePath = path.relative(siteProjectRoot, filePath);
|
|
99
|
-
return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
|
100
|
-
? relativePath
|
|
101
|
-
: filePath).split(path.sep).join('/');
|
|
102
|
-
};
|
|
103
|
-
|
|
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
|
-
const main = async () => {
|
|
218
|
-
const options = parseArgs(process.argv.slice(2));
|
|
219
|
-
if (options.help) {
|
|
220
|
-
console.log(usage);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const siteStructure = await getSiteStructure();
|
|
225
|
-
const [graph, publicFiles] = await Promise.all([
|
|
226
|
-
getSiteLinkGraph({ siteStructure }),
|
|
227
|
-
getSitePublicFiles(),
|
|
228
|
-
]);
|
|
229
|
-
const plan = await createPageMovePlan({
|
|
230
|
-
from: options.urls[0],
|
|
231
|
-
graph,
|
|
232
|
-
order: options.order,
|
|
233
|
-
preserveAliases: options.preserveAliases,
|
|
234
|
-
publicFiles,
|
|
235
|
-
sitePagesDir,
|
|
236
|
-
sitePagesLabel,
|
|
237
|
-
siteStructure,
|
|
238
|
-
to: options.urls[1],
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
console.log(formatPageMovePlan(plan, toDisplayPath).join('\n'));
|
|
242
|
-
if (!options.write) {
|
|
243
|
-
console.log('\nDry run only. Run the same command with --write to apply this plan.');
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
await applyPlan(plan);
|
|
248
|
-
console.log(`\nPage move completed: ${plan.from} -> ${plan.to}`);
|
|
249
|
-
};
|
|
250
|
-
|
|
2
|
+
import { applyPageMovePlan } from './lib/page-move-apply.mjs';
|
|
3
|
+
import { createPageMovePlan, formatPageMovePlan } from './lib/page-move-plan.mjs';
|
|
4
|
+
import { planEditorPagePlacement, applyEditorPagePlacement } from './lib/editor-page-placement.mjs';
|
|
5
|
+
import { parsePlacementArguments, confirmCliRenumbering, describeRenumbering } from './lib/page-placement-cli.mjs';
|
|
6
|
+
import { readEditorLinkState } from './lib/editor-site-links.mjs';
|
|
7
|
+
import { sitePagesDir, sitePagesLabel, siteProjectRoot, siteDir } from './lib/site-paths.mjs';
|
|
8
|
+
|
|
9
|
+
const usage = `Usage:
|
|
10
|
+
norna page:move <old-url> <new-url> [--order <integer>]
|
|
11
|
+
norna page:move <page-url> --before <target-url> | --after <target-url>
|
|
12
|
+
norna page:move <page-url> --parent <parent-url> [--first | --last]
|
|
13
|
+
|
|
14
|
+
Options: --no-aliases, --write, --dry-run, --allow-renumber
|
|
15
|
+
Dry run unless --write is present. The URL-to-URL form also reconciles a page
|
|
16
|
+
already moved by hand. Renumbering other pages requires confirmation.`;
|
|
17
|
+
const display = filename => path.relative(siteProjectRoot, filename) || filename;
|
|
251
18
|
try {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
19
|
+
const options = parsePlacementArguments(process.argv.slice(2));
|
|
20
|
+
if (options.help) { console.log(usage); process.exit(0); }
|
|
21
|
+
if (options.slug !== null) throw new Error('Use the new URL to change a slug with page:move.');
|
|
22
|
+
const relative = options.placement !== null;
|
|
23
|
+
if (options.positionals.length !== (relative ? 1 : 2)) throw new Error(usage);
|
|
24
|
+
if (relative && options.order !== null) throw new Error('Do not combine --order with a placement selector.');
|
|
25
|
+
const state = await readEditorLinkState({ siteRoot: siteDir });
|
|
26
|
+
if (state.incomplete.length) throw new Error(state.incomplete.join('\n'));
|
|
27
|
+
let plan;
|
|
28
|
+
if (relative) {
|
|
29
|
+
const find = url => state.structure.nodes.find(node => (node.isHome ? '/' : `/${node.pagePath}/`) === url);
|
|
30
|
+
const source = find(options.positionals[0]), target = find(options.target);
|
|
31
|
+
if (!source || !target) throw new Error('Choose existing source and target page URLs.');
|
|
32
|
+
plan = await planEditorPagePlacement({ siteRoot: siteDir, sourcePath: source.contentPath, targetPath: target.contentPath,
|
|
33
|
+
placement: options.placement, preserveAliases: options.preserveAliases });
|
|
34
|
+
console.log(`Page placement: ${plan.sourceUrl} ${options.placement} ${options.target}\nOrder: ${plan.sourceOrder}`);
|
|
35
|
+
if (plan.movePreview) console.log(formatPageMovePlan(plan.movePreview, display).join('\n'));
|
|
36
|
+
else if (plan.orderChanges.length) console.log(describeRenumbering(plan.orderChanges, display));
|
|
37
|
+
} else {
|
|
38
|
+
plan = await createPageMovePlan({ from: options.positionals[0], to: options.positionals[1], order: options.order,
|
|
39
|
+
preserveAliases: options.preserveAliases, graph: state.graph, publicFiles: state.publicFiles,
|
|
40
|
+
siteStructure: state.structure, sitePagesDir, sitePagesLabel, generatedRoutes: state.generatedRoutes });
|
|
41
|
+
console.log(formatPageMovePlan(plan, display).join('\n'));
|
|
42
|
+
}
|
|
43
|
+
if (!options.write) { console.log('\nDry run only. Run with --write to apply this plan.'); process.exit(0); }
|
|
44
|
+
if (!await confirmCliRenumbering(plan.orderChanges, options)) { console.log('Cancelled. No files changed.'); process.exit(0); }
|
|
45
|
+
if (relative) await applyEditorPagePlacement(plan, { allowRenumber: true });
|
|
46
|
+
else await applyPageMovePlan(plan, { siteRoot: siteDir, generatedRoutes: state.generatedRoutes, allowRenumber: true });
|
|
47
|
+
console.log(`Page move completed: ${plan.sourceUrl ?? plan.from} -> ${plan.destinationUrl ?? plan.to}. ${plan.orderChanges.length} existing sibling page(s) renumbered.`);
|
|
48
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
@@ -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,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getSiteAttachments } from './lib/page-attachments.mjs';
|
|
1
2
|
import { createInterface } from 'node:readline/promises';
|
|
2
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -233,7 +234,6 @@ try {
|
|
|
233
234
|
const siteStructure = await getSiteStructure();
|
|
234
235
|
const contentFiles = siteStructure.contentFiles;
|
|
235
236
|
for (const warning of siteStructure.warnings) {
|
|
236
|
-
if (warning.code === 'empty-category') continue;
|
|
237
237
|
addIssue({
|
|
238
238
|
severity: 'warning',
|
|
239
239
|
message: warning.message,
|
|
@@ -273,7 +273,7 @@ for (const contentFile of contentFiles) {
|
|
|
273
273
|
prelude,
|
|
274
274
|
regions: sections,
|
|
275
275
|
} = page;
|
|
276
|
-
for (const issue of page.diagnostics.filter((candidate) =>
|
|
276
|
+
for (const issue of page.diagnostics.filter((candidate) => ['heading-inside-details', 'invalid-markdown-image'].includes(candidate.code))) {
|
|
277
277
|
addContentIssue(contentFile, issue);
|
|
278
278
|
}
|
|
279
279
|
for (const issue of page.noteDiagnostics) {
|
|
@@ -424,17 +424,20 @@ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map
|
|
|
424
424
|
return {
|
|
425
425
|
node: {
|
|
426
426
|
...node,
|
|
427
|
-
navigation: {
|
|
428
|
-
listed: node.kind === 'category'
|
|
429
|
-
? true
|
|
430
|
-
: context?.frontmatterData.navigation?.listed ?? true,
|
|
431
|
-
},
|
|
427
|
+
navigation: { listed: context?.frontmatterData.navigation?.listed ?? true },
|
|
432
428
|
},
|
|
433
429
|
};
|
|
434
430
|
}));
|
|
435
431
|
|
|
436
432
|
for (const context of contentFileContexts) {
|
|
437
433
|
const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
|
|
434
|
+
if (context.frontmatterData.page?.listChildren && childPages.length === 0) {
|
|
435
|
+
addContentIssue(context.contentFile, {
|
|
436
|
+
severity: 'warning',
|
|
437
|
+
message: 'page.listChildren has no listed direct child pages to display.',
|
|
438
|
+
fix: 'Add a listed direct child page, or remove page.listChildren while this page has no children.',
|
|
439
|
+
});
|
|
440
|
+
}
|
|
438
441
|
const warnedChildren = new Set();
|
|
439
442
|
|
|
440
443
|
for (const section of context.sections) {
|
|
@@ -444,7 +447,7 @@ for (const context of contentFileContexts) {
|
|
|
444
447
|
if (childPages.length === 0) {
|
|
445
448
|
addSectionIssue(context.contentFile, section, {
|
|
446
449
|
severity: 'error',
|
|
447
|
-
message: `page-list on line ${block.line} has no listed direct child pages to display
|
|
450
|
+
message: `page-list on line ${block.line} has no listed direct child pages to display.`,
|
|
448
451
|
fix: 'Add a listed direct child page or remove the block.',
|
|
449
452
|
});
|
|
450
453
|
continue;
|
|
@@ -466,12 +469,17 @@ for (const context of contentFileContexts) {
|
|
|
466
469
|
}
|
|
467
470
|
|
|
468
471
|
const siteLinkGraph = createSiteLinkGraph({
|
|
472
|
+
siteUrl: projectConfig.site.url,
|
|
473
|
+
attachments: await getSiteAttachments(siteStructure),
|
|
469
474
|
pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
|
|
470
475
|
contentFile,
|
|
471
476
|
data: frontmatterData,
|
|
472
477
|
document: page,
|
|
473
478
|
})),
|
|
474
|
-
publicFiles: await getSitePublicFiles()
|
|
479
|
+
publicFiles: await getSitePublicFiles(undefined, projectConfig).catch(error => {
|
|
480
|
+
addIssue({ severity: 'error', message: error.message, fix: 'Use the supported public-file locations and verificationFiles settings.' });
|
|
481
|
+
return [];
|
|
482
|
+
}),
|
|
475
483
|
siteStructure,
|
|
476
484
|
generatedRoutes: getGeneratedSiteRoutes({ searchEnabled: projectConfig.search.enabled }),
|
|
477
485
|
});
|
|
@@ -529,14 +537,6 @@ for (const context of contentFileContexts) {
|
|
|
529
537
|
for (const section of sections) {
|
|
530
538
|
if (!validSections.has(section)) continue;
|
|
531
539
|
|
|
532
|
-
for (const reference of section.markdownImages) {
|
|
533
|
-
addSectionIssue(contentFile, section, {
|
|
534
|
-
severity: 'warning',
|
|
535
|
-
message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
|
|
536
|
-
fix: 'Use an image-stack, image-carousel, or card-list block for site images that should be validated, processed and synced.',
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
|
|
540
540
|
const { blocks, errors: blockErrors } = blockResultsBySection.get(section) ?? { blocks: [], errors: [] };
|
|
541
541
|
for (const error of blockErrors) {
|
|
542
542
|
addSectionIssue(contentFile, section, {
|