@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
|
@@ -151,7 +151,7 @@ const addSitewideLogoHelp = (jsonSchema) => {
|
|
|
151
151
|
const addConfigHelp = (jsonSchema) => {
|
|
152
152
|
jsonSchema.markdownDescription = [
|
|
153
153
|
yamlExample('url: https://example.com/'),
|
|
154
|
-
'`config.yaml` contains the few technical settings shared by the complete site.',
|
|
154
|
+
'`site-config/settings.yaml` contains the few technical settings shared by the complete site.',
|
|
155
155
|
documentationLink('Configuration reference', 'configuration.md'),
|
|
156
156
|
].join('\n\n');
|
|
157
157
|
addHelp(jsonSchema, 'url', [
|
|
@@ -283,7 +283,7 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
283
283
|
], presentationPaletteNames);
|
|
284
284
|
addHelp(jsonSchema, 'appearance', [
|
|
285
285
|
yamlExample('appearance:\n default: system'),
|
|
286
|
-
'Selects the initial Appearance. The Appearance choice is always available in the site-wide Display panel. Omit this setting to
|
|
286
|
+
'Selects the initial Appearance. The Appearance choice is always available in the site-wide Display panel. Omit this setting to keep the inherited or preset value.',
|
|
287
287
|
documentationLink('Appearance', 'theme.md', 'appearance'),
|
|
288
288
|
]);
|
|
289
289
|
addFieldHelp(jsonSchema, 'appearance.default', 'appearance:\n default: system', 'theme.md', 'appearance', ['system', 'light', 'dark']);
|
|
@@ -463,49 +463,10 @@ const addThemeHelp = (jsonSchema) => {
|
|
|
463
463
|
})]);
|
|
464
464
|
};
|
|
465
465
|
|
|
466
|
-
const addPageThemeHelp = (jsonSchema) => {
|
|
467
|
-
jsonSchema.markdownDescription = [
|
|
468
|
-
yamlExample('layout:\n textWidth: narrow\n contentSpacing: compact'),
|
|
469
|
-
'A limited theme in a page or category directory may adjust only page layout, managed-image sizing and the section background pattern. Descendant pages inherit these values. Tree navigation requires a uniform section background. Site colors, corners, typography, content-block defaults and navigation stay consistent.',
|
|
470
|
-
documentationLink('Page theme reference', 'theme.md', 'page-themes'),
|
|
471
|
-
].join('\n\n');
|
|
472
|
-
addHelp(jsonSchema, 'layout', [
|
|
473
|
-
yamlExample('layout:\n textWidth: narrow\n contentSpacing: compact'),
|
|
474
|
-
'Adjusts body-text line length and vertical content spacing for pages below this directory.',
|
|
475
|
-
documentationLink('Page theme reference', 'theme.md', 'page-themes'),
|
|
476
|
-
]);
|
|
477
|
-
addFieldHelp(jsonSchema, 'layout.textWidth', 'layout:\n textWidth: narrow', 'theme.md', 'page-themes', ['narrow', 'normal', 'wide']);
|
|
478
|
-
addFieldHelp(jsonSchema, 'layout.contentSpacing', 'layout:\n contentSpacing: compact', 'theme.md', 'page-themes', ['compact', 'normal', 'spacious']);
|
|
479
|
-
addHelp(jsonSchema, 'images', [
|
|
480
|
-
yamlExample('images:\n presentation: prose-aligned\n width: 900px'),
|
|
481
|
-
'Adjusts managed-image presentation and sizing inside the content area for pages below this directory. Persistent navigation remains outside this area.',
|
|
482
|
-
documentationLink('Image sizing reference', 'theme.md', 'image-sizing'),
|
|
483
|
-
]);
|
|
484
|
-
const imageFields = [
|
|
485
|
-
['images.presentation', 'images:\n presentation: prose-aligned'],
|
|
486
|
-
['images.width', 'images:\n width: 900px'],
|
|
487
|
-
['images.maxAvailableWidthPercent', 'images:\n maxAvailableWidthPercent:\n desktop: 100\n mobile: 100'],
|
|
488
|
-
['images.maxAvailableWidthPercent.desktop', 'images:\n maxAvailableWidthPercent:\n desktop: 100'],
|
|
489
|
-
['images.maxAvailableWidthPercent.mobile', 'images:\n maxAvailableWidthPercent:\n mobile: 100'],
|
|
490
|
-
['images.maxAvailableHeightPercent', 'images:\n maxAvailableHeightPercent:\n desktop: 74\n mobile: 68'],
|
|
491
|
-
['images.maxAvailableHeightPercent.desktop', 'images:\n maxAvailableHeightPercent:\n desktop: 74'],
|
|
492
|
-
['images.maxAvailableHeightPercent.mobile', 'images:\n maxAvailableHeightPercent:\n mobile: 68'],
|
|
493
|
-
];
|
|
494
|
-
for (const [propertyPath, example] of imageFields) {
|
|
495
|
-
addFieldHelp(jsonSchema, propertyPath, example, 'theme.md', 'image-sizing');
|
|
496
|
-
}
|
|
497
|
-
addHelp(jsonSchema, 'sections', [
|
|
498
|
-
yamlExample('sections:\n backgroundPattern: alternating'),
|
|
499
|
-
'Adjusts the H2 section background pattern for pages below this directory. Alternating and accented create full-width bands but are invalid when navigation resolves to tree.',
|
|
500
|
-
documentationLink('Page theme reference', 'theme.md', 'page-themes'),
|
|
501
|
-
]);
|
|
502
|
-
addFieldHelp(jsonSchema, 'sections.backgroundPattern', 'sections:\n backgroundPattern: alternating', 'theme.md', 'page-themes', ['uniform', 'alternating', 'accented']);
|
|
503
|
-
};
|
|
504
|
-
|
|
505
466
|
const addSitewideHelp = (jsonSchema) => {
|
|
506
467
|
jsonSchema.markdownDescription = [
|
|
507
468
|
yamlExample('footer:\n copyrightMessage: Copyright Example Owner.'),
|
|
508
|
-
'`
|
|
469
|
+
'`site-config/shared-content.yaml` optionally defines banners, footer content, and logo display settings shared by every page.',
|
|
509
470
|
documentationLink('Site-wide content reference', 'sitewide-content.md'),
|
|
510
471
|
].join('\n\n');
|
|
511
472
|
addSitewideLogoHelp(jsonSchema);
|
|
@@ -583,9 +544,10 @@ const addContentHelp = (jsonSchema) => {
|
|
|
583
544
|
]);
|
|
584
545
|
addHelp(jsonSchema, 'page.description', [
|
|
585
546
|
yamlExample('page:\n description: About this project.'),
|
|
586
|
-
'
|
|
547
|
+
'A page-specific description used in metadata and social previews. Generated child-page lists can show it beside the page title; it is not inserted into the page body.',
|
|
587
548
|
documentationLink('Page title and frontmatter reference', 'content.md', 'page-title-and-frontmatter'),
|
|
588
549
|
], ['About this project.']);
|
|
550
|
+
addFieldHelp(jsonSchema, 'page.listChildren', 'page:\n listChildren: true', 'pages.md', 'list-child-pages', [true, false]);
|
|
589
551
|
addHelp(jsonSchema, 'page.aliases', [
|
|
590
552
|
yamlExample('page:\n aliases:\n - /old-about/'),
|
|
591
553
|
'Lists previous site-relative URLs that permanently identify this page. The page containing the list is always the target. Paths omit the configured base path and are excluded from the sitemap.',
|
|
@@ -604,28 +566,19 @@ const addContentHelp = (jsonSchema) => {
|
|
|
604
566
|
addFieldHelp(jsonSchema, 'navigation.listed', 'navigation:\n listed: false', 'pages.md', 'navigation', [true, false]);
|
|
605
567
|
};
|
|
606
568
|
|
|
607
|
-
const addCategoryHelp = (jsonSchema) => {
|
|
608
|
-
jsonSchema.markdownDescription = [
|
|
609
|
-
yamlExample('label: Guides'),
|
|
610
|
-
'`category.yaml` groups child pages without editorial content. Its URL opens the first listed direct page, or a generated child list when the first child is a category.',
|
|
611
|
-
documentationLink('Navigation category reference', 'pages.md', 'navigation-categories'),
|
|
612
|
-
].join('\n\n');
|
|
613
|
-
addHelp(jsonSchema, 'label', [
|
|
614
|
-
yamlExample('label: Guides'),
|
|
615
|
-
'The category label shown in global navigation, tree navigation, mobile navigation, and breadcrumbs.',
|
|
616
|
-
documentationLink('Navigation category reference', 'pages.md', 'navigation-categories'),
|
|
617
|
-
]);
|
|
618
|
-
};
|
|
619
|
-
|
|
620
569
|
export const applySchemaEditorMetadata = (filename, jsonSchema) => {
|
|
621
570
|
addValueDescriptions(jsonSchema);
|
|
622
|
-
if (filename === 'category.schema.json') addCategoryHelp(jsonSchema);
|
|
623
571
|
if (filename === 'config.schema.json') {
|
|
624
572
|
addLanguageSuggestions(jsonSchema);
|
|
625
573
|
addConfigHelp(jsonSchema);
|
|
626
574
|
}
|
|
627
|
-
if (
|
|
628
|
-
|
|
575
|
+
if (['root-theme.schema.json', 'theme.schema.json', 'page-theme.schema.json'].includes(filename)) {
|
|
576
|
+
addThemeHelp(jsonSchema);
|
|
577
|
+
jsonSchema.markdownDescription = [yamlExample(filename === 'root-theme.schema.json' ? 'preset: documentation' : 'layout:\n textWidth: wide'), jsonSchema.description, documentationLink('Theme inheritance', 'theme.md', 'page-themes')].join('\n\n');
|
|
578
|
+
addHelp(jsonSchema, 'preset', [yamlExample('preset: documentation'),
|
|
579
|
+
'Selects a new base and discards inherited theme values, even when the preset name is unchanged. Required in root/tree-theme.yaml. Omit in other theme files to modify inherited values.',
|
|
580
|
+
documentationLink('Theme inheritance', 'theme.md', 'page-themes'), documentationLink('Preset reference', 'theme.md', 'theme-presets')]);
|
|
581
|
+
}
|
|
629
582
|
if (filename === 'sitewide-content.schema.json') addSitewideHelp(jsonSchema);
|
|
630
583
|
if (filename === 'content-frontmatter.schema.json') addContentHelp(jsonSchema);
|
|
631
584
|
return jsonSchema;
|
|
@@ -26,7 +26,7 @@ export const schemaValueDefinitions = Object.freeze([
|
|
|
26
26
|
automatic: option('Automatic', 'Choose one site-wide navigation presentation from the listed page hierarchy. This is the default.'),
|
|
27
27
|
sections: option('Sections', 'Keep a one-page site title and its H2 sections in sticky page navigation.'),
|
|
28
28
|
top: option('Top', 'Keep top-level pages in the global row and give the current page section navigation when needed.'),
|
|
29
|
-
tree: option('Tree', '
|
|
29
|
+
tree: option('Tree', 'Group destinations in menus and show the selected group’s pages and H2 links in a left tree.'),
|
|
30
30
|
}),
|
|
31
31
|
definition(['vscode'], {
|
|
32
32
|
vscode: option('Visual Studio Code', 'Open the current page source in the locally installed Visual Studio Code application.'),
|
|
@@ -22,9 +22,6 @@ const frontmatterDelimiterRegex = /^---\s*$/;
|
|
|
22
22
|
const knownConfigTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.config);
|
|
23
23
|
const knownContentTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.content);
|
|
24
24
|
const knownThemeTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.theme);
|
|
25
|
-
const knownPageThemeTopLevelFrontmatterKeys = new Set(
|
|
26
|
-
schemaTopLevelKeys.pageTheme,
|
|
27
|
-
);
|
|
28
25
|
const knownSitewideTopLevelFrontmatterKeys = new Set(schemaTopLevelKeys.sitewide);
|
|
29
26
|
const knownNestedFrontmatterKeys = new Set([
|
|
30
27
|
'align',
|
|
@@ -57,6 +54,7 @@ const knownNestedFrontmatterKeys = new Set([
|
|
|
57
54
|
'listed',
|
|
58
55
|
'logo',
|
|
59
56
|
'lineHeight',
|
|
57
|
+
'listChildren',
|
|
60
58
|
'maxAvailableHeightPercent',
|
|
61
59
|
'maxAvailableWidthPercent',
|
|
62
60
|
'mobile',
|
|
@@ -154,28 +152,19 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
|
154
152
|
} else if (fileKind === 'content' && key === 'images') {
|
|
155
153
|
fix = 'Put local image references in image-stack or image-carousel blocks in the Markdown body.';
|
|
156
154
|
} else if (fileKind === 'content' && knownThemeTopLevelFrontmatterKeys.has(key)) {
|
|
157
|
-
fix = `Move "${key}:" to theme.yaml. Visual settings do not belong in content frontmatter.`;
|
|
155
|
+
fix = `Move "${key}:" to site-config/site-theme.yaml. Visual settings do not belong in content frontmatter.`;
|
|
158
156
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'navigation') {
|
|
159
|
-
message = `Frontmatter line ${lineNumber}: navigation is technical, site-wide configuration and does not belong in ${fileKind}. Set it in config.yaml.`;
|
|
160
|
-
fix = 'Set the navigation mode under "navigation:" in config.yaml.';
|
|
157
|
+
message = `Frontmatter line ${lineNumber}: navigation is technical, site-wide configuration and does not belong in ${fileKind}. Set it in site-config/settings.yaml.`;
|
|
158
|
+
fix = 'Set the navigation mode under "navigation:" in site-config/settings.yaml.';
|
|
161
159
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'shape') {
|
|
162
|
-
message =
|
|
163
|
-
|
|
164
|
-
: `Frontmatter line ${lineNumber}: "shape" was replaced by "corners". Use "square" or replace the old "soft" value with "rounded".`;
|
|
165
|
-
fix = fileKind === 'page theme'
|
|
166
|
-
? 'Remove "shape:" from this page theme. Set "corners: square" or "corners: rounded" in the root theme.yaml when an override is needed.'
|
|
167
|
-
: 'Replace "shape:" with "corners:". Use "square" or replace the old "soft" value with "rounded".';
|
|
160
|
+
message = `Frontmatter line ${lineNumber}: "shape" was replaced by "corners".`;
|
|
161
|
+
fix = 'Use "corners: square" or "corners: rounded"; replace the old "soft" value with "rounded".';
|
|
168
162
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'colorMode') {
|
|
169
163
|
message = `Frontmatter line ${lineNumber}: Theme setting "colorMode" was replaced by "appearance".`;
|
|
170
|
-
fix =
|
|
171
|
-
? 'Remove "colorMode:" from this page theme. Set "appearance:" in the root theme.yaml when an override is needed.'
|
|
172
|
-
: 'Replace "colorMode:" with "appearance:".';
|
|
164
|
+
fix = 'Replace "colorMode:" with "appearance:".';
|
|
173
165
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'readerControls') {
|
|
174
166
|
message = `Frontmatter line ${lineNumber}: Theme setting "readerControls" was removed. Appearance and reading width are always available; Focus reading follows tree navigation.`;
|
|
175
167
|
fix = 'Remove "readerControls:". Use "appearance.default" for the initial Appearance; reading width is always available and tree navigation provides Focus reading.';
|
|
176
|
-
} else if (fileKind === 'page theme' && ['preset', 'appearance', 'corners', 'palette', 'typography'].includes(key)) {
|
|
177
|
-
message = `Frontmatter line ${lineNumber}: page themes may not define site-wide visual identity through "${key}".`;
|
|
178
|
-
fix = `Move "${key}:" to the root theme.yaml. Page themes may set only layout.textWidth, layout.contentSpacing, images, and sections.backgroundPattern.`;
|
|
179
168
|
} else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
|
|
180
169
|
message = `Frontmatter line ${lineNumber}: ${fileKind} may not define navigation logo settings. Optional logo display settings belong under "logo:" in ${sitewideContentLabel}.`;
|
|
181
170
|
fix = `Move only the logo display settings under "logo:" in ${sitewideContentLabel}; the homepage Markdown H1 supplies navigation text and logo alternative text.`;
|
|
@@ -223,12 +212,6 @@ export const validateThemeYamlStructure = (frontmatter, addIssue) =>
|
|
|
223
212
|
fileKind: 'theme',
|
|
224
213
|
});
|
|
225
214
|
|
|
226
|
-
export const validatePageThemeYamlStructure = (frontmatter, addIssue) =>
|
|
227
|
-
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
228
|
-
knownTopLevelFrontmatterKeys: knownPageThemeTopLevelFrontmatterKeys,
|
|
229
|
-
fileKind: 'page theme',
|
|
230
|
-
});
|
|
231
|
-
|
|
232
215
|
export const validateSitewideYamlStructure = (frontmatter, addIssue) =>
|
|
233
216
|
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
234
217
|
knownTopLevelFrontmatterKeys: knownSitewideTopLevelFrontmatterKeys,
|
|
@@ -1 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
// Logical identities stay independent of the physical root/ directory.
|
|
4
|
+
export const homePageDirectory = '.';
|
|
5
|
+
export const legacyHomePageDirectory = '000-home';
|
|
6
|
+
|
|
7
|
+
export const getSiteSourcePaths = (siteRoot) => {
|
|
8
|
+
const root = path.join(siteRoot, 'root');
|
|
9
|
+
return { root, pages: path.join(root, 'pages'), content: path.join(root, 'content.md'),
|
|
10
|
+
images: path.join(root, 'images'), theme: path.join(root, 'tree-theme.yaml') };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getPageSourceDirectory = (pageDirectory) => (
|
|
14
|
+
pageDirectory === homePageDirectory ? 'root' : `root/pages/${pageDirectory}`
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const getPageImageSourceKey = (pageDirectory, image) => (
|
|
18
|
+
[getPageSourceDirectory(pageDirectory), 'images', image].filter(Boolean).join('/')
|
|
19
|
+
);
|
|
@@ -30,7 +30,6 @@ export const getListedSiteNavigationTree = (entries) => {
|
|
|
30
30
|
const filterListed = (nodes) => nodes.flatMap((node) => {
|
|
31
31
|
if (!node.node.isHome && !node.node.navigation.listed) return [];
|
|
32
32
|
const children = filterListed(node.children);
|
|
33
|
-
if (node.node.kind === 'category' && children.length === 0) return [];
|
|
34
33
|
return [{ ...node, children }];
|
|
35
34
|
});
|
|
36
35
|
|
|
@@ -48,9 +47,7 @@ export const getSequentialPageNavigation = (nodes, currentPagePath) => {
|
|
|
48
47
|
));
|
|
49
48
|
if (!activeRoot) return { previous: null, next: null };
|
|
50
49
|
|
|
51
|
-
const pages = flattenSiteNavigationTree([activeRoot])
|
|
52
|
-
.filter(({ node }) => node.kind === 'page')
|
|
53
|
-
.map(({ node }) => node);
|
|
50
|
+
const pages = flattenSiteNavigationTree([activeRoot]).map(({ node }) => node);
|
|
54
51
|
const currentIndex = pages.findIndex(({ pagePath }) => pagePath === currentPagePath);
|
|
55
52
|
if (currentIndex < 0) return { previous: null, next: null };
|
|
56
53
|
|
|
@@ -68,7 +65,5 @@ export const getDirectChildPages = (nodes, currentPagePath) => {
|
|
|
68
65
|
? nodes.filter(({ node }) => !node.isHome)
|
|
69
66
|
: current.children;
|
|
70
67
|
|
|
71
|
-
return children
|
|
72
|
-
.filter(({ node }) => node.kind === 'page')
|
|
73
|
-
.map(({ node }) => node);
|
|
68
|
+
return children.map(({ node }) => node);
|
|
74
69
|
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
2
|
+
import { mkdir, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { dump as dumpYaml } from 'js-yaml';
|
|
5
|
+
import { slugifyAsciiIdentifier } from './heading-ids.mjs';
|
|
6
|
+
import { pageDirectoryPattern } from './page-model.mjs';
|
|
7
|
+
import { getSiteStructure } from './site-structure.mjs';
|
|
8
|
+
import { siteSchema } from './schema-definitions.mjs';
|
|
9
|
+
import { readEditorLinkState } from './editor-site-links.mjs';
|
|
10
|
+
import { createPageAliasModel, assertPageAliasModel } from './page-aliases.mjs';
|
|
11
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
12
|
+
|
|
13
|
+
const parseOrder = (value) => {
|
|
14
|
+
if (!/^\d{1,3}$/.test(value)) {
|
|
15
|
+
throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
|
|
16
|
+
}
|
|
17
|
+
const order = Number.parseInt(value, 10);
|
|
18
|
+
if (order < 1 || order > 999) {
|
|
19
|
+
throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.`);
|
|
20
|
+
}
|
|
21
|
+
return order;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const normalizeParentPath = (value) => {
|
|
25
|
+
if (value === '/') return '';
|
|
26
|
+
if (value.includes('\\') || value.includes('?') || value.includes('#')) {
|
|
27
|
+
throw new Error(`Invalid --parent "${value}". Use a logical path such as /guides/installation/.`);
|
|
28
|
+
}
|
|
29
|
+
const normalized = value.replace(/^\/+|\/+$/g, '');
|
|
30
|
+
if (!normalized || normalized.split('/').some((segment) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment))) {
|
|
31
|
+
throw new Error(`Invalid --parent "${value}". Use / for the top level or a logical path such as /guides/installation/.`);
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const getNextOrder = (siblings) => {
|
|
37
|
+
const highestOrder = siblings.reduce((highest, node) => Math.max(highest, node.pageOrder), 0);
|
|
38
|
+
const nextOrder = Math.floor(highestOrder / 10) * 10 + 10;
|
|
39
|
+
if (nextOrder > 999) {
|
|
40
|
+
throw new Error('No automatic page order remains below 1000. Reorder the sibling nodes before adding another one.');
|
|
41
|
+
}
|
|
42
|
+
return nextOrder;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
|
|
46
|
+
|
|
47
|
+
const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
|
|
48
|
+
if (error?.code === 'ENOENT') return path.resolve(directory);
|
|
49
|
+
throw error;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const resolveParent = async ({ nodes, parent, siteRoot, invocationDirectory }) => {
|
|
53
|
+
const sitePagesDir = getSiteSourcePaths(siteRoot).pages;
|
|
54
|
+
if (parent !== null) {
|
|
55
|
+
const parentPath = normalizeParentPath(parent);
|
|
56
|
+
if (!parentPath) {
|
|
57
|
+
return { collectionDir: sitePagesDir, node: null, pagePath: '' };
|
|
58
|
+
}
|
|
59
|
+
const node = nodes.find((candidate) => candidate.pagePath === parentPath);
|
|
60
|
+
if (!node) {
|
|
61
|
+
throw new Error(`Cannot find parent "${parent}". Use an existing page path, or / for the top level.`);
|
|
62
|
+
}
|
|
63
|
+
return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const currentDirectory = await canonicalDirectory(invocationDirectory);
|
|
67
|
+
if (currentDirectory === await canonicalDirectory(sitePagesDir) || currentDirectory === await canonicalDirectory(siteRoot)) {
|
|
68
|
+
return { collectionDir: sitePagesDir, node: null, pagePath: '' };
|
|
69
|
+
}
|
|
70
|
+
let node = null;
|
|
71
|
+
for (const candidate of nodes) {
|
|
72
|
+
if (await canonicalDirectory(candidate.nodeDir) === currentDirectory) {
|
|
73
|
+
node = candidate;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!node) {
|
|
78
|
+
throw new Error([
|
|
79
|
+
'Cannot infer where to add the node from the current directory.',
|
|
80
|
+
`Run the command from ${sitePagesDir} for a top-level node, from an existing page directory for a child, or pass --parent.`,
|
|
81
|
+
].join('\n'));
|
|
82
|
+
}
|
|
83
|
+
return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Planning never writes. Callers must present this same destination before applying.
|
|
87
|
+
export const planSiteNodeCreation = async ({
|
|
88
|
+
siteRoot, kind, title, slug = null, order = null, parentPath = '/',
|
|
89
|
+
invocationDirectory = siteRoot, metadata, sources = new Map(),
|
|
90
|
+
}) => {
|
|
91
|
+
if (kind !== 'page') throw new Error('Choose a page. Navigation categories are no longer supported; use page.listChildren: true for an overview.');
|
|
92
|
+
siteRoot = path.resolve(siteRoot);
|
|
93
|
+
title = String(title ?? '').trim();
|
|
94
|
+
if (!title || /[\r\n]/.test(title)) throw new Error('A one-line page title is required.');
|
|
95
|
+
const generatedSlug = slug === null;
|
|
96
|
+
slug ??= slugifyAsciiIdentifier(title);
|
|
97
|
+
if (!slug || !pageDirectoryPattern.test(`010-${slug}`)) {
|
|
98
|
+
throw new Error(`Invalid ${generatedSlug ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const structure = await getSiteStructure({ siteRoot });
|
|
102
|
+
const parent = await resolveParent({ nodes: structure.nodes, parent: parentPath, siteRoot, invocationDirectory });
|
|
103
|
+
const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null));
|
|
104
|
+
if (siblings.some((node) => node.pageId === slug)) {
|
|
105
|
+
throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
|
|
106
|
+
}
|
|
107
|
+
order = order === null ? getNextOrder(siblings) : parseOrder(String(order));
|
|
108
|
+
if (siblings.some((node) => node.pageOrder === order)) {
|
|
109
|
+
throw new Error(`Cannot use order ${String(order).padStart(3, '0')} below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling already uses it.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const directoryName = `${String(order).padStart(3, '0')}-${slug}`;
|
|
113
|
+
const destination = path.join(parent.collectionDir, directoryName);
|
|
114
|
+
const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
|
|
115
|
+
const state = await readEditorLinkState({ siteRoot, sources });
|
|
116
|
+
if (state.incomplete.length) throw new Error(`Page addresses could not be checked. ${state.incomplete.join('\n')}`);
|
|
117
|
+
const address = `/${pagePath}/`;
|
|
118
|
+
const owner = state.graph.aliasModel.identitiesByPathname.get(address);
|
|
119
|
+
if (owner?.kind === 'page-alias') {
|
|
120
|
+
const page = owner.source.page;
|
|
121
|
+
throw new Error(`${address} is a previous address for “${page.title}”, now at ${owner.source.targetPathname}. Choose another URL segment, or remove this previous address from “${page.title}” through Addresses and links… → Additional addresses….`);
|
|
122
|
+
}
|
|
123
|
+
if (owner) throw new Error(`Address ${address} is already in use by ${owner.label}. Choose another URL segment.`);
|
|
124
|
+
if (metadata !== undefined) {
|
|
125
|
+
metadata = parseYamlConfig(dumpYaml(metadata), 'Page information', { schema: siteSchema });
|
|
126
|
+
assertPageAliasModel(createPageAliasModel({ pages: [...state.graph.pages,
|
|
127
|
+
{ pathname: `/${pagePath}/`, contentLabel: destination, aliases: metadata.page?.aliases ?? [] }],
|
|
128
|
+
categories: [], publicFiles: state.publicFiles, generatedRoutes: state.generatedRoutes }));
|
|
129
|
+
}
|
|
130
|
+
return { siteRoot, kind, title, slug, order, parentPath: parent.pagePath ? `/${parent.pagePath}/` : '/',
|
|
131
|
+
collectionDir: parent.collectionDir, destination, pagePath, url: `/${pagePath}/`, ...(metadata === undefined ? {} : { metadata }) };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const createSiteNode = async (plan, { sources = new Map() } = {}) => {
|
|
135
|
+
// Revalidate after the author has inspected the preview. Never change its placement.
|
|
136
|
+
const current = await planSiteNodeCreation({ ...plan, sources });
|
|
137
|
+
if (current.destination !== plan.destination) throw new Error('The site changed. Preview the new location before creating the node.');
|
|
138
|
+
let createdCollection = false;
|
|
139
|
+
try {
|
|
140
|
+
const info = await stat(current.collectionDir);
|
|
141
|
+
if (!info.isDirectory()) throw new Error(`${current.collectionDir} exists but is not a directory.`);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error.code !== 'ENOENT') throw error;
|
|
144
|
+
await mkdir(current.collectionDir);
|
|
145
|
+
createdCollection = true;
|
|
146
|
+
}
|
|
147
|
+
const created = [];
|
|
148
|
+
try {
|
|
149
|
+
// Exclusive reservation also protects an empty destination created after planning.
|
|
150
|
+
await mkdir(current.destination);
|
|
151
|
+
created.push({ file: current.destination, directory: true });
|
|
152
|
+
const filename = path.join(current.destination, 'content.md');
|
|
153
|
+
const source = `${current.metadata && Object.keys(current.metadata).length ? `---\n${dumpYaml(current.metadata, { lineWidth: -1, noRefs: true })}---\n\n` : ''}# ${escapeMarkdownHeading(current.title)}\n\n## Introduction\n\nStart writing here.\n`;
|
|
154
|
+
await writeFile(filename, source, { flag: 'wx' });
|
|
155
|
+
created.push({ file: filename });
|
|
156
|
+
const childDirectory = path.join(current.destination, 'images');
|
|
157
|
+
await mkdir(childDirectory);
|
|
158
|
+
created.push({ file: childDirectory, directory: true });
|
|
159
|
+
return { ...current, sourcePath: filename };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// Remove only entries this operation created; never recursively remove an
|
|
162
|
+
// unexpected file written concurrently by another process.
|
|
163
|
+
for (const entry of created.reverse()) {
|
|
164
|
+
await (entry.directory ? rmdir(entry.file) : unlink(entry.file)).catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
if (createdCollection) await rmdir(current.collectionDir).catch(() => {});
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { homePageDirectory } from './site-conventions.mjs';
|
|
4
|
+
import { getSiteSourcePaths, homePageDirectory, legacyHomePageDirectory } from './site-conventions.mjs';
|
|
5
5
|
|
|
6
6
|
const siteDirectoryEnvName = 'NORNA_SITE_DIR';
|
|
7
7
|
const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
|
|
@@ -32,9 +32,10 @@ if (hasStateDirectoryEnv && !configuredStateDirectory) {
|
|
|
32
32
|
|
|
33
33
|
const hasSiteFilesInDirectory = (siteDir) => {
|
|
34
34
|
return (
|
|
35
|
-
existsSync(path.join(siteDir, 'config.yaml'))
|
|
35
|
+
(existsSync(path.join(siteDir, 'site-config', 'settings.yaml')) || existsSync(path.join(siteDir, 'config.yaml')))
|
|
36
36
|
&& (
|
|
37
|
-
existsSync(path.join(siteDir, 'pages',
|
|
37
|
+
existsSync(path.join(siteDir, 'pages', legacyHomePageDirectory, 'content.md'))
|
|
38
|
+
|| existsSync(getSiteSourcePaths(siteDir).content)
|
|
38
39
|
|| existsSync(path.join(siteDir, 'content.md'))
|
|
39
40
|
)
|
|
40
41
|
);
|
|
@@ -63,6 +64,16 @@ const findSiteProjectRoot = (startDirectory, siteDirectory) => {
|
|
|
63
64
|
}
|
|
64
65
|
};
|
|
65
66
|
|
|
67
|
+
const findContainingSite = (startDirectory) => {
|
|
68
|
+
let current = path.resolve(startDirectory);
|
|
69
|
+
while (true) {
|
|
70
|
+
if (hasSiteFilesInDirectory(current)) return current;
|
|
71
|
+
const parent = path.dirname(current);
|
|
72
|
+
if (parent === current) return null;
|
|
73
|
+
current = parent;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
66
77
|
const hasSiteDirectoryEnv = Object.hasOwn(process.env, siteDirectoryEnvName);
|
|
67
78
|
const configuredSiteDirectory = normalizeSiteDirectory(process.env[siteDirectoryEnvName]);
|
|
68
79
|
const hasConfiguredSiteDirectory = hasSiteDirectoryEnv && configuredSiteDirectory !== '';
|
|
@@ -84,8 +95,9 @@ const resolveSitePaths = () => {
|
|
|
84
95
|
};
|
|
85
96
|
}
|
|
86
97
|
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
const containingSite = !hasConfiguredSiteDirectory && findContainingSite(invocationRoot);
|
|
99
|
+
if (containingSite) {
|
|
100
|
+
const siteDir = containingSite;
|
|
89
101
|
const siteProjectRoot = path.dirname(siteDir);
|
|
90
102
|
|
|
91
103
|
return {
|
|
@@ -126,11 +138,11 @@ const getPathLabel = (filePath) => {
|
|
|
126
138
|
};
|
|
127
139
|
|
|
128
140
|
export const siteDir = resolvedSitePaths.siteDir;
|
|
129
|
-
export const siteConfigPath = path.join(siteDir, 'config.yaml');
|
|
130
|
-
export const siteThemePath =
|
|
131
|
-
export const sitewideContentPath = path.join(siteDir, '
|
|
132
|
-
export const sitePagesDir =
|
|
133
|
-
export const siteHomePageDir =
|
|
141
|
+
export const siteConfigPath = path.join(siteDir, 'site-config', 'settings.yaml');
|
|
142
|
+
export const siteThemePath = getSiteSourcePaths(siteDir).theme;
|
|
143
|
+
export const sitewideContentPath = path.join(siteDir, 'site-config', 'shared-content.yaml');
|
|
144
|
+
export const sitePagesDir = getSiteSourcePaths(siteDir).pages;
|
|
145
|
+
export const siteHomePageDir = getSiteSourcePaths(siteDir).root;
|
|
134
146
|
export const siteContentPath = path.join(siteHomePageDir, 'content.md');
|
|
135
147
|
export const siteImagesDir = path.join(siteHomePageDir, 'images');
|
|
136
148
|
export const sitePublicDir = path.join(siteDir, 'public');
|