@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
|
@@ -1,16 +1,15 @@
|
|
|
1
|
+
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
1
2
|
import { access, readdir, readFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { categorySchema } from './schema-definitions.mjs';
|
|
4
4
|
import { parsePageDirectoryPath } from './page-model.mjs';
|
|
5
|
+
import { comparePageOrder, parsePageOrder } from './page-order.mjs';
|
|
6
|
+
import { assertSiteSourceFormat } from './site-source-format.mjs';
|
|
7
|
+
import { parseContentFrontmatter, splitSiteFile } from './site-content.mjs';
|
|
5
8
|
import {
|
|
6
9
|
homePageDirectory,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
siteDirLabel,
|
|
10
|
-
sitePagesDir,
|
|
11
|
-
sitePagesLabel,
|
|
10
|
+
siteDir as defaultSiteDir,
|
|
11
|
+
siteDirLabel as defaultSiteDirLabel,
|
|
12
12
|
} from './site-paths.mjs';
|
|
13
|
-
import { parseYamlConfig } from './yaml-config.mjs';
|
|
14
13
|
|
|
15
14
|
const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
|
|
16
15
|
|
|
@@ -19,30 +18,15 @@ const readDirectory = async (directory) => readdir(directory, { withFileTypes: t
|
|
|
19
18
|
throw error;
|
|
20
19
|
});
|
|
21
20
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
const assertLegacyStructureIsAbsent = async () => {
|
|
28
|
-
const legacyContentPath = path.join(siteDir, 'content.md');
|
|
29
|
-
const legacyImagesPath = path.join(siteDir, 'images');
|
|
30
|
-
const legacyPaths = [];
|
|
31
|
-
if (await fileExists(legacyContentPath)) legacyPaths.push(`${siteDirLabel}/content.md`);
|
|
32
|
-
if (await fileExists(legacyImagesPath)) legacyPaths.push(`${siteDirLabel}/images`);
|
|
33
|
-
if (legacyPaths.length > 0) {
|
|
34
|
-
throw new Error([
|
|
35
|
-
`The old root-page structure is no longer supported: ${legacyPaths.join(', ')}.`,
|
|
36
|
-
`Move the homepage content to ${sitePagesLabel}/${homePageDirectory}/content.md and its images to ${sitePagesLabel}/${homePageDirectory}/images/.`,
|
|
37
|
-
].join('\n'));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (await fileExists(path.join(siteDir, 'routes'))) {
|
|
41
|
-
throw new Error(`${siteDirLabel}/routes is no longer supported. Rename it to ${sitePagesLabel} and use NNN-page-id directory names.`);
|
|
21
|
+
const assertLegacyStructureIsAbsent = async ({ siteDir, siteDirLabel }) => {
|
|
22
|
+
for (const name of ['content.md', 'theme.yaml', 'images', 'pages', 'routes']) {
|
|
23
|
+
if (await fileExists(path.join(siteDir, name))) {
|
|
24
|
+
throw new Error(`${siteDirLabel}/${name} uses the former page layout. Put the homepage content.md, tree-theme.yaml, optional page-theme.yaml, images/ and pages/ inside ${siteDirLabel}/root/. Keep site-config/ and public/ at the site level. Stop the development server before moving files.`);
|
|
25
|
+
}
|
|
42
26
|
}
|
|
43
27
|
};
|
|
44
28
|
|
|
45
|
-
const assertUniqueSiblings = (nodes, pagesLabel) => {
|
|
29
|
+
const assertUniqueSiblings = (nodes, pagesLabel, warnings) => {
|
|
46
30
|
const byId = new Map();
|
|
47
31
|
const byOrder = new Map();
|
|
48
32
|
|
|
@@ -57,25 +41,40 @@ const assertUniqueSiblings = (nodes, pagesLabel) => {
|
|
|
57
41
|
}
|
|
58
42
|
byId.set(node.pageId, node);
|
|
59
43
|
|
|
44
|
+
if (node.pageOrder === null) continue;
|
|
60
45
|
const existingOrder = byOrder.get(node.pageOrder);
|
|
61
46
|
if (existingOrder) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`- ${existingOrder.nodeLabel}`,
|
|
65
|
-
`- ${node.nodeLabel}`,
|
|
66
|
-
].join('\n'));
|
|
47
|
+
const message = `${pagesLabel} contains duplicate sibling navigation.order ${node.pageOrder}: ${existingOrder.pageId} and ${node.pageId}. Slug determines their order. Choose different values to set an explicit order.`;
|
|
48
|
+
for (const sibling of [existingOrder, node]) warnings.push({ code: 'duplicate-page-order', message, label: sibling.contentLabel, path: sibling.contentPath });
|
|
67
49
|
}
|
|
68
50
|
byOrder.set(node.pageOrder, node);
|
|
69
51
|
}
|
|
70
52
|
};
|
|
71
53
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
54
|
+
// Editors can inspect several sites in one process and retain malformed nodes
|
|
55
|
+
// for repair. Builds and mutation plans keep the strict default contract.
|
|
56
|
+
export const getSiteStructure = async ({
|
|
57
|
+
siteRoot = defaultSiteDir,
|
|
58
|
+
tolerant = false,
|
|
59
|
+
readSource = (filePath) => readFile(filePath, 'utf8'),
|
|
60
|
+
} = {}) => {
|
|
61
|
+
const siteDir = path.resolve(siteRoot);
|
|
62
|
+
const siteDirLabel = siteDir === defaultSiteDir ? defaultSiteDirLabel : siteDir;
|
|
63
|
+
const sitePagesDir = getSiteSourcePaths(siteDir).pages;
|
|
64
|
+
const sitePagesLabel = `${siteDirLabel}/root/pages`;
|
|
65
|
+
const siteContentLabel = `${siteDirLabel}/root/content.md`;
|
|
66
|
+
const problems = [];
|
|
67
|
+
const report = (error, node = null) => {
|
|
68
|
+
if (!tolerant) throw error;
|
|
69
|
+
problems.push({ message: error.message, path: node?.contentPath ?? node?.categoryPath ?? siteDir });
|
|
70
|
+
if (node) (node.problems ??= []).push(error.message);
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
await assertLegacyStructureIsAbsent({ siteDir, siteDirLabel, sitePagesLabel });
|
|
74
|
+
await assertSiteSourceFormat(siteDir, readSource);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
report(error);
|
|
77
|
+
}
|
|
79
78
|
|
|
80
79
|
const nodes = [];
|
|
81
80
|
const warnings = [];
|
|
@@ -98,90 +97,82 @@ export const getSiteStructure = async () => {
|
|
|
98
97
|
fileExists(contentPath),
|
|
99
98
|
fileExists(categoryPath),
|
|
100
99
|
]);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
throw new Error(`${nodeLabel} ${problem}. Keep exactly one: content.md for a page, or category.yaml for a navigation category.`);
|
|
100
|
+
let metadata;
|
|
101
|
+
try {
|
|
102
|
+
metadata = parsePageDirectoryPath(pageDirectory, nodeLabel);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
report(error);
|
|
105
|
+
continue;
|
|
108
106
|
}
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
108
|
+
if (hasCategory) report(new Error(`${nodeLabel}/category.yaml is no longer supported. Create content.md with an H1 and page.listChildren: true instead.`));
|
|
109
|
+
if (!hasContent) {
|
|
110
|
+
report(new Error(`${nodeLabel} has no content.md. Every page directory needs a content.md file.`));
|
|
111
|
+
continue;
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
const node = {
|
|
116
115
|
...metadata,
|
|
117
|
-
isHome,
|
|
118
|
-
kind:
|
|
116
|
+
isHome: false,
|
|
117
|
+
kind: 'page',
|
|
119
118
|
nodeDir,
|
|
120
119
|
nodeLabel,
|
|
121
|
-
pagePath:
|
|
122
|
-
parentPagePath:
|
|
120
|
+
pagePath: metadata.pagePath,
|
|
121
|
+
parentPagePath: metadata.parentPagePath,
|
|
123
122
|
};
|
|
124
123
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
Object.assign(node, {
|
|
137
|
-
contentLabel: `${nodeLabel}/content.md`,
|
|
138
|
-
contentPath,
|
|
139
|
-
imagesDir: path.join(nodeDir, 'images'),
|
|
140
|
-
imagesLabel: `${nodeLabel}/images`,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
124
|
+
Object.assign(node, {
|
|
125
|
+
contentLabel: `${nodeLabel}/content.md`,
|
|
126
|
+
contentPath,
|
|
127
|
+
imagesDir: path.join(nodeDir, 'images'),
|
|
128
|
+
imagesLabel: `${nodeLabel}/images`,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const { frontmatterBody } = splitSiteFile(await readSource(contentPath), node.contentLabel);
|
|
133
|
+
node.pageOrder = parsePageOrder(parseContentFrontmatter(frontmatterBody, node.contentLabel).navigation?.order, `${node.contentLabel}: navigation.order`);
|
|
134
|
+
} catch (error) { report(error, node); }
|
|
144
135
|
siblingNodes.push(node);
|
|
145
136
|
}
|
|
146
137
|
|
|
147
|
-
|
|
138
|
+
try {
|
|
139
|
+
assertUniqueSiblings(siblingNodes, pagesLabel, warnings);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
report(error);
|
|
142
|
+
}
|
|
148
143
|
|
|
149
|
-
for (const node of siblingNodes.sort(
|
|
144
|
+
for (const node of siblingNodes.sort(comparePageOrder)) {
|
|
150
145
|
nodes.push(node);
|
|
151
146
|
const childPagesDir = path.join(node.nodeDir, 'pages');
|
|
152
147
|
const childDirectories = (await readDirectory(childPagesDir)).filter((entry) => entry.isDirectory());
|
|
153
148
|
|
|
154
|
-
if (
|
|
155
|
-
throw new Error([
|
|
156
|
-
`${node.nodeLabel} is the homepage and cannot contain child pages.`,
|
|
157
|
-
`Move these page directories beside ${homePageDirectory} under ${sitePagesLabel}/, or below another non-home page:`,
|
|
158
|
-
...childDirectories.map(({ name }) => `- ${node.nodeLabel}/pages/${name}`),
|
|
159
|
-
].join('\n'));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (node.kind === 'category' && childDirectories.length === 0) {
|
|
163
|
-
warnings.push({
|
|
164
|
-
code: 'empty-category',
|
|
165
|
-
label: node.categorySourceLabel,
|
|
166
|
-
message: `${node.categorySourceLabel} defines an empty navigation category. Add at least one child page under ${node.nodeLabel}/pages/.`,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (!node.isHome && childDirectories.length > 0) {
|
|
149
|
+
if (childDirectories.length > 0) {
|
|
171
150
|
await collectNodes(childPagesDir, `${node.nodeLabel}/pages`, node.pageDirectory);
|
|
172
151
|
}
|
|
173
152
|
}
|
|
174
153
|
};
|
|
175
154
|
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
155
|
+
const rootContentPath = getSiteSourcePaths(siteDir).content;
|
|
156
|
+
if (await fileExists(path.join(getSiteSourcePaths(siteDir).root, 'category.yaml'))) {
|
|
157
|
+
report(new Error(`${siteDirLabel}/root/category.yaml is invalid. The site root must be a page with content.md.`));
|
|
179
158
|
}
|
|
159
|
+
if (await fileExists(rootContentPath)) {
|
|
160
|
+
nodes.push({
|
|
161
|
+
...parsePageDirectoryPath(homePageDirectory),
|
|
162
|
+
kind: 'page', isHome: true, nodeDir: getSiteSourcePaths(siteDir).root, nodeLabel: `${siteDirLabel}/root`,
|
|
163
|
+
contentPath: rootContentPath, contentLabel: siteContentLabel,
|
|
164
|
+
imagesDir: getSiteSourcePaths(siteDir).images, imagesLabel: `${siteDirLabel}/root/images`,
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
report(new Error(`Homepage content is missing. Create ${siteContentLabel}.`));
|
|
168
|
+
}
|
|
169
|
+
await collectNodes(sitePagesDir, sitePagesLabel);
|
|
180
170
|
|
|
181
171
|
return {
|
|
182
172
|
categories: nodes.filter(({ kind }) => kind === 'category'),
|
|
183
173
|
contentFiles: nodes.filter(({ kind }) => kind === 'page'),
|
|
184
174
|
nodes,
|
|
175
|
+
...(tolerant ? { problems } : {}),
|
|
185
176
|
warnings,
|
|
186
177
|
};
|
|
187
178
|
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assertSiteSourceFormat } from './site-source-format.mjs';
|
|
2
|
+
import { lstat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readThemePackage } from './theme-packages.mjs';
|
|
5
|
+
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
6
|
+
|
|
7
|
+
const inspect = (filename) => lstat(filename).catch((error) => {
|
|
8
|
+
if (error.code === 'ENOENT') return null;
|
|
9
|
+
throw error;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Keep the existing command without claiming the former layout is current.
|
|
13
|
+
// Our maintained sites are converted directly; no general converter is needed.
|
|
14
|
+
export const planSiteUpgrade = async (siteRoot) => {
|
|
15
|
+
siteRoot = path.resolve(siteRoot);
|
|
16
|
+
const source = getSiteSourcePaths(siteRoot);
|
|
17
|
+
if (!(await inspect(siteRoot))?.isDirectory()) throw new Error(`${siteRoot} must be a regular directory, not a symbolic link.`);
|
|
18
|
+
for (const name of ['content.md', 'theme.yaml', 'images', 'pages', 'routes', 'config.yaml', 'page-theme.yaml', 'sitewide-content.yaml', 'site-config/site-theme.yaml', 'root/theme.yaml']) {
|
|
19
|
+
if (await inspect(path.join(siteRoot, name))) {
|
|
20
|
+
throw new Error(`This site uses former source locations (${name}). Automatic conversion to root/ is not provided. Stop the development server and back up the site. Keep site-config/ and public/ at the site level; put the homepage content.md, images/ and pages/ in root/. Put the required tree-theme.yaml with an explicit preset in root/; optional page-theme.yaml affects one page only. Rename descendant theme.yaml to tree-theme.yaml to preserve inherited overrides. For older formats, follow the source-layout reference before moving files. Update relative source/schema links, then run norna check and norna build. No files changed.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const directory of [source.root, path.join(siteRoot, 'site-config')]) {
|
|
24
|
+
if (!(await inspect(directory))?.isDirectory()) throw new Error(`${directory} must be a regular directory, not a symbolic link.`);
|
|
25
|
+
}
|
|
26
|
+
for (const filename of [source.content, path.join(siteRoot, 'site-config', 'settings.yaml'), source.theme]) {
|
|
27
|
+
if (!(await inspect(filename))?.isFile()) throw new Error(`${filename} is required and must be a regular file.`);
|
|
28
|
+
}
|
|
29
|
+
await assertSiteSourceFormat(siteRoot);
|
|
30
|
+
await readThemePackage({ siteRoot, filename: source.theme });
|
|
31
|
+
return { siteRoot, moves: [] };
|
|
32
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { publicFilePublication } from './publication-paths.mjs';
|
|
1
2
|
import { getPublicAssetInspection } from './logo-assets.mjs';
|
|
2
3
|
import {
|
|
3
4
|
socialImageAssetDefinitions,
|
|
@@ -13,7 +14,7 @@ export const getSocialImageAssets = () => getPublicAssetInspection().socialImage
|
|
|
13
14
|
.map((filename) => ({
|
|
14
15
|
...definitionsByFilename.get(filename),
|
|
15
16
|
filename,
|
|
16
|
-
href:
|
|
17
|
+
href: publicFilePublication(filename).pathname,
|
|
17
18
|
}));
|
|
18
19
|
|
|
19
20
|
export const getSocialImageAsset = () => {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pageDirectoryPattern, parsePageDirectoryPath } from './page-model.mjs';
|
|
3
|
+
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
4
|
+
import { configSchema, rootThemeSchema, pageThemeSchema, siteSchema, sitewideSchema, themeVisualSchema } from './schema-definitions.mjs';
|
|
5
|
+
|
|
6
|
+
// Also published in schemas/manifest.json for synchronous editor discovery.
|
|
7
|
+
export const sourceFileDefinitions = [
|
|
8
|
+
{ name: 'settings.yaml', location: 'site-config', schemaKind: 'config', required: true, input: 'url', description: 'Required public URL and technical site settings.' },
|
|
9
|
+
{ name: 'shared-content.yaml', location: 'site-config', schemaKind: 'sitewideContent', text: 'banners: []\n', description: 'Shared notices and footer content for the complete site.', removalEffect: 'Shared notices, authored footer content and logo display settings in this file will be removed from the whole site.' },
|
|
10
|
+
{ name: 'content.md', location: 'page', schemaKind: 'contentFrontmatter', required: true, input: 'title', description: 'Required page content.' },
|
|
11
|
+
{ name: 'tree-theme.yaml', location: 'page', schemaKind: 'theme', rootSchemaKind: 'rootTheme', required: 'root', rootText: 'preset: documentation\n', text: '# Modify inherited settings. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page and its descendants. Without preset, modifies inherited values; preset selects a new base. Required on the root page.', removalEffect: 'This branch resumes the ancestor tree theme. Overrides on descendants remain; a descendant preset starts an independent base.' },
|
|
12
|
+
{ name: 'page-theme.yaml', location: 'page', schemaKind: 'pageTheme', text: '# Modify this page only. Add preset only to replace the complete base.\n{}\n', description: 'Visual settings for this page only. Without preset, modifies its tree theme; preset selects a new base. Descendants keep their tree theme.', removalEffect: 'This page will use its selected tree theme again. Other pages are unchanged.' },
|
|
13
|
+
];
|
|
14
|
+
export const sourcePageDirectoryPattern = pageDirectoryPattern.source;
|
|
15
|
+
const schemas = { config: configSchema, contentFrontmatter: siteSchema, sitewideContent: sitewideSchema, theme: themeVisualSchema, rootTheme: rootThemeSchema, pageTheme: pageThemeSchema };
|
|
16
|
+
|
|
17
|
+
export const getSourcePageLocation = (siteRoot, directory) => {
|
|
18
|
+
const relative = path.relative(getSiteSourcePaths(siteRoot).root, directory);
|
|
19
|
+
if (!relative) return { isHome: true, ...parsePageDirectoryPath('.') };
|
|
20
|
+
if (!relative.startsWith(`pages${path.sep}`)) return null;
|
|
21
|
+
try { return { ...parsePageDirectoryPath(relative.slice(6).split(path.sep).join('/')), isHome: false }; }
|
|
22
|
+
catch { return null; }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const getSourceFileDefinition = (siteRoot, filename) => {
|
|
26
|
+
const relative = path.relative(siteRoot, filename).split(path.sep).join('/');
|
|
27
|
+
const location = getSourcePageLocation(siteRoot, path.dirname(filename));
|
|
28
|
+
const definition = sourceFileDefinitions.find((entry) => entry.location === 'page'
|
|
29
|
+
? location && path.basename(filename) === entry.name : relative === `${entry.location}/${entry.name}`);
|
|
30
|
+
if (!definition) return null;
|
|
31
|
+
const schemaKind = location?.isHome && definition.rootSchemaKind ? definition.rootSchemaKind : definition.schemaKind;
|
|
32
|
+
return { ...definition, schemaKind, text: location?.isHome && definition.rootText ? definition.rootText : definition.text, schema: schemaKind === 'contentFrontmatter' ? undefined : schemas[schemaKind],
|
|
33
|
+
required: definition.required === true || definition.required === 'root' && location?.isHome === true };
|
|
34
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from 'yaml';
|
|
2
2
|
|
|
3
|
+
export const managedImageFilenamePattern = '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])';
|
|
4
|
+
|
|
3
5
|
const stringSchema = (name, definition) => ({
|
|
4
6
|
type: 'string',
|
|
5
7
|
description: definition.description,
|
|
@@ -7,7 +9,7 @@ const stringSchema = (name, definition) => ({
|
|
|
7
9
|
...(definition.values ? { enum: Object.keys(definition.values) } : {}),
|
|
8
10
|
...(name === 'alt' ? {} : { minLength: 1 }),
|
|
9
11
|
pattern: name === 'image'
|
|
10
|
-
?
|
|
12
|
+
? managedImageFilenamePattern
|
|
11
13
|
: name === 'caption' || name === 'text'
|
|
12
14
|
? '^(?=[\\s\\S]*\\S)[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]*$(?![\\s\\S])'
|
|
13
15
|
: name === 'alt'
|
|
@@ -1,77 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from './
|
|
7
|
-
import {
|
|
8
|
-
sitePagesDir,
|
|
9
|
-
sitePagesLabel,
|
|
10
|
-
siteThemeLabel,
|
|
11
|
-
siteThemePath,
|
|
12
|
-
} from './site-paths.mjs';
|
|
3
|
+
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
4
|
+
import { siteDir, siteThemePath } from './site-paths.mjs';
|
|
5
|
+
import { readThemePackage } from './theme-packages.mjs';
|
|
6
|
+
import { getSourcePageLocation } from './source-files.mjs';
|
|
13
7
|
import { resolveThemePresentation } from './presentation.mjs';
|
|
14
|
-
import { resolveThemeConfig } from './theme-presets.mjs';
|
|
15
|
-
import { pageThemeSchema, themeVisualSchema } from './schema-definitions.mjs';
|
|
16
|
-
import { parseYamlConfig } from './yaml-config.mjs';
|
|
17
8
|
|
|
18
9
|
export const readThemeConfig = async () => {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
throw error;
|
|
25
|
-
});
|
|
26
|
-
const config = parseYamlConfig(themeFile, siteThemeLabel, {
|
|
27
|
-
schema: themeVisualSchema,
|
|
28
|
-
validateStructure: validateThemeYamlStructure,
|
|
29
|
-
});
|
|
30
|
-
resolveThemeConfig(config, siteThemeLabel);
|
|
31
|
-
resolveThemePresentation(config, siteThemeLabel);
|
|
32
|
-
|
|
33
|
-
return config;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const getPageThemeFiles = async (directory, relativeDirectory = '') => {
|
|
37
|
-
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
38
|
-
if (error?.code === 'ENOENT') return [];
|
|
39
|
-
throw error;
|
|
40
|
-
});
|
|
41
|
-
const files = [];
|
|
42
|
-
|
|
43
|
-
for (const entry of entries) {
|
|
44
|
-
const relativePath = path.join(relativeDirectory, entry.name);
|
|
45
|
-
const absolutePath = path.join(directory, entry.name);
|
|
46
|
-
if (entry.isDirectory()) {
|
|
47
|
-
files.push(...await getPageThemeFiles(absolutePath, relativePath));
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (entry.name === 'theme.yaml') {
|
|
52
|
-
files.push({
|
|
53
|
-
path: absolutePath,
|
|
54
|
-
label: `${sitePagesLabel}/${relativePath.split(path.sep).join('/')}`,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return files;
|
|
10
|
+
const theme = await readThemePackage({ siteRoot: siteDir, filename: siteThemePath });
|
|
11
|
+
resolveThemePresentation(theme.config, theme.label);
|
|
12
|
+
return theme.config;
|
|
60
13
|
};
|
|
61
14
|
|
|
62
15
|
export const validatePageThemeFiles = async () => {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const config = parseYamlConfig(source, file.label, {
|
|
69
|
-
schema: pageThemeSchema,
|
|
70
|
-
validateStructure: validatePageThemeYamlStructure,
|
|
16
|
+
const themes = [];
|
|
17
|
+
const visit = async (directory) => {
|
|
18
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
19
|
+
if (error.code === 'ENOENT') return [];
|
|
20
|
+
throw error;
|
|
71
21
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const filename = path.join(directory, entry.name);
|
|
24
|
+
if (entry.isDirectory()) { await visit(filename); continue; }
|
|
25
|
+
if (!['tree-theme.yaml', 'page-theme.yaml', 'theme.yaml'].includes(entry.name)) continue;
|
|
26
|
+
const location = getSourcePageLocation(siteDir, directory);
|
|
27
|
+
if (!location) continue;
|
|
28
|
+
if (entry.name === 'theme.yaml') throw new Error(`${filename} uses the former theme filename. Use page-theme.yaml for one page only or tree-theme.yaml for inherited branch settings.`);
|
|
29
|
+
if (filename === siteThemePath) continue;
|
|
30
|
+
themes.push({ ...await readThemePackage({ siteRoot: siteDir, filename }), pageDirectory: location.pageDirectory });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
await visit(getSiteSourcePaths(siteDir).root);
|
|
34
|
+
return themes;
|
|
77
35
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getPageDirectoryAncestors, parsePageDirectoryPath } from './page-model.mjs';
|
|
4
|
+
import { getPageSourceDirectory } from './site-conventions.mjs';
|
|
5
|
+
import { getSourceFileDefinition } from './source-files.mjs';
|
|
6
|
+
import { mergeThemeOverrides } from './theme-presets.mjs';
|
|
7
|
+
import { resolveThemePresentation } from './presentation.mjs';
|
|
8
|
+
import { validateThemeYamlStructure } from './site-content.mjs';
|
|
9
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
10
|
+
|
|
11
|
+
export const readThemePackage = async ({ siteRoot, filename, sources = new Map() }) => {
|
|
12
|
+
const definition = getSourceFileDefinition(siteRoot, filename);
|
|
13
|
+
if (!definition || !['rootTheme', 'theme', 'pageTheme'].includes(definition.schemaKind)) throw new Error(`Not a permitted theme location: ${filename}.`);
|
|
14
|
+
const source = sources.has(filename) ? sources.get(filename) : await readFile(filename, 'utf8').catch((error) => {
|
|
15
|
+
if (error.code !== 'ENOENT') throw error;
|
|
16
|
+
if (definition.required) throw new Error(`${filename} is required. Create the root tree theme before running Norna.`);
|
|
17
|
+
return null;
|
|
18
|
+
});
|
|
19
|
+
if (source === null) return null;
|
|
20
|
+
return { path: filename, label: filename, config: parseYamlConfig(source, filename, { schema: definition.schema, validateStructure: validateThemeYamlStructure }) };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Resolve authored overrides only; preset expansion stays in the shared visual
|
|
24
|
+
// resolvers. A page-only file is never considered while walking ancestors.
|
|
25
|
+
export const selectPageTheme = async ({ siteRoot, pageDirectory = '.', sources, includePageTheme = true }) => {
|
|
26
|
+
siteRoot = path.resolve(siteRoot);
|
|
27
|
+
parsePageDirectoryPath(pageDirectory);
|
|
28
|
+
const ancestors = pageDirectory === '.' ? ['.'] : ['.', ...getPageDirectoryAncestors(pageDirectory)];
|
|
29
|
+
let treeTheme;
|
|
30
|
+
const sourceFiles = [];
|
|
31
|
+
for (const ancestor of ancestors) {
|
|
32
|
+
const filename = path.join(siteRoot, getPageSourceDirectory(ancestor), 'tree-theme.yaml');
|
|
33
|
+
const candidate = await readThemePackage({ siteRoot, filename, sources });
|
|
34
|
+
if (candidate) {
|
|
35
|
+
treeTheme = { ...candidate, config: mergeThemeOverrides(treeTheme?.config, candidate.config) };
|
|
36
|
+
sourceFiles.push(candidate);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const pageTheme = includePageTheme
|
|
40
|
+
? await readThemePackage({ siteRoot, filename: path.join(siteRoot, getPageSourceDirectory(pageDirectory), 'page-theme.yaml'), sources })
|
|
41
|
+
: null;
|
|
42
|
+
const selected = pageTheme ? { ...pageTheme, config: mergeThemeOverrides(treeTheme.config, pageTheme.config) } : treeTheme;
|
|
43
|
+
if (pageTheme) sourceFiles.push(pageTheme);
|
|
44
|
+
resolveThemePresentation(treeTheme.config, treeTheme.label);
|
|
45
|
+
resolveThemePresentation(selected.config, selected.label);
|
|
46
|
+
return { treeTheme, pageTheme, selected, sourceFiles };
|
|
47
|
+
};
|
|
@@ -93,7 +93,7 @@ export const getThemePresetMetadata = (presetName) => {
|
|
|
93
93
|
};
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
-
export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
|
|
96
|
+
export const getThemePreset = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
|
|
97
97
|
const preset = themePresets[presetName];
|
|
98
98
|
|
|
99
99
|
if (!preset) {
|
|
@@ -103,62 +103,29 @@ export const getThemePreset = (presetName, sourceLabel = 'theme.yaml') => {
|
|
|
103
103
|
return structuredClone(preset);
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
// Keep authored fields separate from preset defaults so navigation can distinguish
|
|
107
|
+
// an explicit surface request from an adaptable preset default.
|
|
108
|
+
export const mergeThemeOverrides = (base = {}, override = {}) => {
|
|
109
|
+
const merged = override.preset !== undefined ? structuredClone(override) : mergeDeep(base, override);
|
|
110
|
+
if (override.images?.presentation === 'prose-aligned' && override.images.maxAvailableHeightPercent === undefined) {
|
|
111
|
+
delete merged.images.maxAvailableHeightPercent;
|
|
112
|
+
}
|
|
113
|
+
return merged;
|
|
114
|
+
};
|
|
109
115
|
|
|
116
|
+
export const resolveThemeConfig = (theme = {}, sourceLabel = 'root/tree-theme.yaml') => {
|
|
117
|
+
const presetName = theme?.preset;
|
|
110
118
|
const overrides = structuredClone(theme ?? {});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const resolved = {
|
|
114
|
-
preset: presetName,
|
|
119
|
+
const resolved = presetName === undefined ? overrides : {
|
|
115
120
|
...mergeDeep(getThemePreset(presetName, sourceLabel), overrides),
|
|
116
121
|
};
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
overrides.images?.presentation === 'prose-aligned'
|
|
120
|
-
&& overrides.images.maxAvailableHeightPercent === undefined
|
|
121
|
-
) {
|
|
122
|
+
if (overrides.images?.presentation === 'prose-aligned' && overrides.images.maxAvailableHeightPercent === undefined) {
|
|
122
123
|
delete resolved.images.maxAvailableHeightPercent;
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const mergePageThemePart = (base, override, keys) => Object.fromEntries(keys
|
|
129
|
-
.filter((key) => override?.[key] !== undefined || base?.[key] !== undefined)
|
|
130
|
-
.map((key) => [key, override?.[key] ?? base?.[key]]));
|
|
131
|
-
|
|
132
|
-
export const mergePageThemeConfig = (base = {}, override = {}) => {
|
|
133
|
-
const images = {
|
|
134
|
-
...(base.images ?? {}),
|
|
135
|
-
...mergePageThemePart(base.images, override.images, [
|
|
136
|
-
'presentation',
|
|
137
|
-
'width',
|
|
138
|
-
'maxAvailableWidthPercent',
|
|
139
|
-
'maxAvailableHeightPercent',
|
|
140
|
-
]),
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
override.images?.presentation === 'prose-aligned'
|
|
145
|
-
&& override.images.maxAvailableHeightPercent === undefined
|
|
146
|
-
) {
|
|
147
|
-
delete images.maxAvailableHeightPercent;
|
|
125
|
+
if (resolved.images?.presentation === 'prose-aligned' && resolved.images.maxAvailableHeightPercent !== undefined) {
|
|
126
|
+
throw new Error(`images.maxAvailableHeightPercent cannot be used with images.presentation "prose-aligned" in ${sourceLabel}. Remove images.maxAvailableHeightPercent or set images.presentation to "centered-fit".`);
|
|
148
127
|
}
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
...base,
|
|
152
|
-
layout: {
|
|
153
|
-
...(base.layout ?? {}),
|
|
154
|
-
...mergePageThemePart(base.layout, override.layout, ['contentSpacing', 'textWidth']),
|
|
155
|
-
},
|
|
156
|
-
images,
|
|
157
|
-
sections: {
|
|
158
|
-
...(base.sections ?? {}),
|
|
159
|
-
...mergePageThemePart(base.sections, override.sections, ['backgroundPattern']),
|
|
160
|
-
},
|
|
161
|
-
};
|
|
128
|
+
return resolved;
|
|
162
129
|
};
|
|
163
130
|
|
|
164
131
|
const quote = (value) => JSON.stringify(value);
|
|
@@ -171,7 +138,7 @@ const responsiveValueLines = (label, value, indent = 2) => {
|
|
|
171
138
|
];
|
|
172
139
|
};
|
|
173
140
|
|
|
174
|
-
export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
|
|
141
|
+
export const renderThemePresetReference = (presetName, sourceLabel = 'root/tree-theme.yaml') => {
|
|
175
142
|
const preset = getThemePreset(presetName, sourceLabel);
|
|
176
143
|
const metadata = getThemePresetMetadata(presetName);
|
|
177
144
|
const { appearance, corners, layout, images, blocks, typography, palette, sections } = preset;
|
|
@@ -179,8 +146,8 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
|
|
|
179
146
|
return [
|
|
180
147
|
`# Original values for Norna's "${presetName}" theme preset.`,
|
|
181
148
|
`# ${metadata.description}`,
|
|
182
|
-
'# This is a reference file. Norna
|
|
183
|
-
'# Keep the preset in theme.yaml and copy only the values you want to override.',
|
|
149
|
+
'# This is a reference file. Norna loads root/tree-theme.yaml for the shared theme.',
|
|
150
|
+
'# Keep the preset in root/tree-theme.yaml and copy only the values you want to override.',
|
|
184
151
|
`# Available theme presets: ${themePresetNames.join(', ')}.`,
|
|
185
152
|
`preset: ${presetName}`,
|
|
186
153
|
'',
|
|
@@ -152,7 +152,7 @@ const evaluateResponsiveSize = (value, viewportWidth) => {
|
|
|
152
152
|
return Math.min(parsed.maximumRem * 16, Math.max(parsed.minimumRem * 16, preferredPixels));
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
export const assertTypographyContract = (typography, sourceLabel = 'theme.yaml') => {
|
|
155
|
+
export const assertTypographyContract = (typography, sourceLabel = 'root/tree-theme.yaml') => {
|
|
156
156
|
const values = typography?.values ?? typography;
|
|
157
157
|
if (!values?.headings || !values?.body || !values?.caption) {
|
|
158
158
|
throw new Error(`Incomplete typography configuration in ${sourceLabel}.`);
|
|
@@ -427,7 +427,7 @@ export const defaultTypography = {
|
|
|
427
427
|
rhythm: 'normal',
|
|
428
428
|
};
|
|
429
429
|
|
|
430
|
-
export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'theme.yaml') => {
|
|
430
|
+
export const resolveTypographyConfig = (typography = defaultTypography, sourceLabel = 'root/tree-theme.yaml') => {
|
|
431
431
|
const profileName = typography?.profile ?? defaultTypography.profile;
|
|
432
432
|
const rhythmName = typography?.rhythm ?? defaultTypography.rhythm;
|
|
433
433
|
const profile = typographyProfiles[profileName];
|
|
@@ -60,7 +60,7 @@ export const parseYamlConfig = (source, label, {
|
|
|
60
60
|
if (issues.length > 0) {
|
|
61
61
|
throw new Error([
|
|
62
62
|
`${label} has invalid YAML structure.`,
|
|
63
|
-
...issues.map((issue) => `- ${issue.message}`),
|
|
63
|
+
...issues.map((issue) => `- ${issue.message}${issue.fix ? ` ${issue.fix}` : ''}`),
|
|
64
64
|
].join('\n'));
|
|
65
65
|
}
|
|
66
66
|
|