@janga/norna 0.7.6 → 0.7.8
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 +8 -7
- package/astro.config.mjs +76 -0
- package/bin/norna-cli.mjs +2 -2
- package/docs/README.md +3 -3
- package/docs/commands.md +7 -6
- package/docs/configuration.md +3 -2
- package/docs/content.md +202 -91
- package/docs/design/site-examples-structure.md +1 -1
- package/docs/engine-development.md +3 -3
- package/docs/getting-started.md +50 -8
- package/docs/images-and-metadata.md +117 -20
- package/docs/routes.md +20 -12
- package/docs/site-structure.md +9 -5
- package/docs/theme.md +12 -8
- package/docs/typography.md +9 -10
- package/examples/dog-gallery/site/content.md +38 -77
- package/examples/dog-gallery/site/routes/010-dog-care/route-content.md +0 -10
- package/fixtures/basic/site/content.md +2 -4
- package/fixtures/content-model-v2/.astro/collections/site.schema.json +776 -0
- package/fixtures/content-model-v2/.astro/collections/theme.schema.json +668 -0
- package/fixtures/content-model-v2/.astro/content-assets.mjs +4 -0
- package/fixtures/content-model-v2/.astro/content-modules.mjs +1 -0
- package/fixtures/content-model-v2/.astro/content.d.ts +175 -0
- package/fixtures/content-model-v2/.astro/data-store.json +1 -0
- package/fixtures/content-model-v2/.astro/dev-local.json +8 -0
- package/fixtures/content-model-v2/.astro/dev.json +13 -0
- package/fixtures/content-model-v2/.astro/dev.log +231 -0
- package/fixtures/content-model-v2/.astro/settings.json +5 -0
- package/fixtures/content-model-v2/.astro/types.d.ts +2 -0
- package/fixtures/content-model-v2/dist/_astro/GalleryGrid.astro_astro_type_script_index_0_lang.B0kxDyGz.js +1 -0
- package/fixtures/content-model-v2/dist/_astro/SitePage.BbfZub4g.css +1 -0
- package/fixtures/content-model-v2/dist/guide/index.html +3 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/detail-27930b14-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/duplicate-49c98f6c-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/intro/hero-6d588a83-971.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/plain/image-3770d763-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/routes/010-guide/images/intro/duplicate-5cf614d2-960.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-one-3770d763-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-three-5bdec65e-768.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1080.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1440.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-1600.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-480.webp +0 -0
- package/fixtures/content-model-v2/dist/images/generated/timed/slide-two-26c08aee-768.webp +0 -0
- package/fixtures/content-model-v2/dist/index.html +8 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/_metadata.json +80 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js +434 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro-D_GIpJIE.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js +2702 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js +1534 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/audit-DM9Hkl5B.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js +1389 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/embla-carousel.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js +48 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/highlight-DnZ1ULOL.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js +42 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/icons-RyebKi45.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/package.json +3 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/rolldown-runtime-BvCyGRYZ.js +4 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js +190 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/settings-DaU-gNnK.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js +458 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/toolbar-63PA619H.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js +1016 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/ui-library--bkcNSYz.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js +32 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/window-jPkpKVcy.js.map +1 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js +111 -0
- package/fixtures/content-model-v2/node_modules/.vite/deps/xray-BXCHpEJP.js.map +1 -0
- package/fixtures/content-model-v2/site/.norna/generated-images.json +210 -0
- package/fixtures/content-model-v2/site/config.mjs +11 -0
- package/fixtures/content-model-v2/site/content.md +84 -0
- package/fixtures/content-model-v2/site/images/card-section/adopt.svg +9 -0
- package/fixtures/content-model-v2/site/images/card-section/foster.svg +12 -0
- package/fixtures/content-model-v2/site/images/intro/detail.jpg +0 -0
- package/fixtures/content-model-v2/site/images/intro/duplicate.jpg +0 -0
- package/fixtures/content-model-v2/site/images/intro/hero.jpg +0 -0
- package/fixtures/content-model-v2/site/images/plain/image.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-one.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-three.jpg +0 -0
- package/fixtures/content-model-v2/site/images/timed/slide-two.jpg +0 -0
- package/fixtures/content-model-v2/site/routes/010-guide/images/intro/duplicate.jpg +0 -0
- package/fixtures/content-model-v2/site/routes/010-guide/route-content.md +17 -0
- package/fixtures/content-model-v2/site/theme.md +9 -0
- package/package.json +3 -2
- package/scripts/deploy-site.mjs +11 -4
- package/scripts/dev-local.mjs +6 -0
- package/scripts/generate-images.mjs +132 -56
- package/scripts/lib/image-dimensions.mjs +26 -0
- package/scripts/lib/norna-markdown-blocks.mjs +643 -0
- package/scripts/lib/site-content.mjs +31 -29
- package/scripts/show-typography.mjs +17 -9
- package/scripts/sync-content-sections.mjs +414 -412
- package/scripts/test-content-check.mjs +59 -45
- package/scripts/test-content-model-v2.mjs +1230 -0
- package/scripts/test-engine-commands.mjs +1 -1
- package/scripts/test-package-check.mjs +36 -8
- package/scripts/test-temporary-visibility.mjs +3 -6
- package/src/components/CardList.astro +77 -0
- package/src/components/GalleryGrid.astro +22 -12
- package/src/components/SiteNavigation.astro +5 -4
- package/src/components/SitePage.astro +9 -2
- package/src/components/SiteSection.astro +35 -13
- package/src/content.config.ts +4 -17
- package/src/lib/generatedImages.ts +50 -10
- package/src/lib/sectionContent.ts +130 -35
- package/src/styles/global.css +187 -0
- package/starters/basic/README.md +4 -2
- package/starters/basic/site/content.md +9 -11
- package/starters/project/README.md +7 -3
- package/starters/project/site/content.md +0 -9
- package/starters/project/site/routes/010-guide/route-content.md +0 -5
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, rename, stat } from 'node:fs/promises';
|
|
2
3
|
import { createInterface } from 'node:readline/promises';
|
|
3
4
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import {
|
|
8
|
+
extractMarkdownImageReferences,
|
|
9
|
+
extractNornaMarkdownBlockDiagnostics,
|
|
10
|
+
getNornaBlockImageReferences,
|
|
11
|
+
} from './lib/norna-markdown-blocks.mjs';
|
|
5
12
|
import {
|
|
6
13
|
getBodySections,
|
|
7
14
|
getContentFiles,
|
|
8
|
-
getFrontmatterSections,
|
|
9
15
|
getFrontmatterInlineStyleNames,
|
|
10
|
-
|
|
16
|
+
getFrontmatterSections,
|
|
17
|
+
getImageCandidatesByName,
|
|
11
18
|
getInlineStyleReferences,
|
|
12
19
|
readSiteFile,
|
|
13
20
|
readThemeFile,
|
|
@@ -18,96 +25,52 @@ import {
|
|
|
18
25
|
} from './lib/site-content.mjs';
|
|
19
26
|
import { readImageDimensions } from './lib/image-dimensions.mjs';
|
|
20
27
|
import {
|
|
21
|
-
siteContentLabel,
|
|
22
|
-
siteContentPath,
|
|
23
|
-
siteImagesDir,
|
|
24
|
-
siteImagesLabel,
|
|
25
28
|
siteThemeLabel,
|
|
26
29
|
siteThemePath,
|
|
30
|
+
siteProjectRoot,
|
|
27
31
|
} from './lib/site-paths.mjs';
|
|
28
32
|
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
34
|
+
|
|
29
35
|
const args = new Set(process.argv.slice(2));
|
|
30
36
|
const shouldWrite = args.has('--write');
|
|
31
37
|
const shouldCheck = args.has('--check') || !shouldWrite;
|
|
32
38
|
const skipPrompt = args.has('--yes');
|
|
33
39
|
const issues = [];
|
|
40
|
+
const imageMoves = [];
|
|
41
|
+
const referencedImagePaths = new Set();
|
|
34
42
|
|
|
35
43
|
const addIssue = ({ severity, message, fix, sectionId, sectionLabel }) => {
|
|
36
44
|
issues.push({ severity, message, fix, sectionId, sectionLabel });
|
|
37
45
|
};
|
|
38
46
|
|
|
39
|
-
const
|
|
40
|
-
addIssue({ severity: 'error', message });
|
|
41
|
-
};
|
|
47
|
+
const hasErrors = () => issues.some((issue) => issue.severity === 'error');
|
|
42
48
|
|
|
43
|
-
const
|
|
44
|
-
addIssue({ severity: 'warning', message });
|
|
45
|
-
};
|
|
49
|
+
const formatSectionLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id ?? section.heading}]`;
|
|
46
50
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
};
|
|
51
|
+
const addContentIssue = (contentFile, issue) => addIssue({
|
|
52
|
+
...issue,
|
|
53
|
+
sectionId: issue.sectionId ?? contentFile.contentLabel,
|
|
54
|
+
sectionLabel: issue.sectionLabel ?? contentFile.contentLabel,
|
|
55
|
+
});
|
|
54
56
|
|
|
55
|
-
const
|
|
57
|
+
const addSectionIssue = (contentFile, section, issue) => addIssue({
|
|
58
|
+
...issue,
|
|
59
|
+
sectionId: issue.sectionId ?? `${contentFile.contentLabel}:${section.id ?? section.heading}`,
|
|
60
|
+
sectionLabel: issue.sectionLabel ?? formatSectionLabel(contentFile, section),
|
|
61
|
+
});
|
|
56
62
|
|
|
57
63
|
const promptForWrite = async () => {
|
|
58
64
|
if (skipPrompt) return true;
|
|
59
65
|
if (!process.stdin.isTTY) return false;
|
|
60
66
|
|
|
61
67
|
const rl = createInterface({ input, output });
|
|
62
|
-
const answer = await rl.question(
|
|
68
|
+
const answer = await rl.question('This will move image files into the page or route image roots where Norna references them. Continue? [y/N] ');
|
|
63
69
|
rl.close();
|
|
64
70
|
|
|
65
71
|
return answer.trim().toLowerCase() === 'y';
|
|
66
72
|
};
|
|
67
73
|
|
|
68
|
-
const { frontmatter, body } = await readSiteFile(siteContentPath);
|
|
69
|
-
validateFrontmatterIndentation(frontmatter, addIssue);
|
|
70
|
-
validateContentFrontmatterStructure(frontmatter, addIssue);
|
|
71
|
-
let themeFrontmatter = null;
|
|
72
|
-
try {
|
|
73
|
-
themeFrontmatter = (await readThemeFile(siteThemePath)).frontmatter;
|
|
74
|
-
validateFrontmatterIndentation(themeFrontmatter, addIssue);
|
|
75
|
-
validateThemeFrontmatterStructure(themeFrontmatter, addIssue);
|
|
76
|
-
} catch (error) {
|
|
77
|
-
if (error?.code === 'ENOENT') {
|
|
78
|
-
themeFrontmatter = null;
|
|
79
|
-
} else {
|
|
80
|
-
throw error;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
const frontmatterSections = getFrontmatterSections(frontmatter);
|
|
84
|
-
const themeInlineStyleNames = themeFrontmatter
|
|
85
|
-
? getFrontmatterInlineStyleNames(themeFrontmatter)
|
|
86
|
-
: new Set();
|
|
87
|
-
const frontmatterIds = frontmatterSections.map((section) => section.id);
|
|
88
|
-
const { prelude, sections } = getBodySections(body);
|
|
89
|
-
const sectionsById = new Map();
|
|
90
|
-
const extraSections = [];
|
|
91
|
-
const imageIndex = await getImageIndex(siteImagesDir, fail);
|
|
92
|
-
const imageMoves = [];
|
|
93
|
-
const referencedImages = new Map();
|
|
94
|
-
|
|
95
|
-
const getUnreferencedImages = () => Array.from(imageIndex.entries())
|
|
96
|
-
.filter(([imageName]) => !referencedImages.has(imageName))
|
|
97
|
-
.map(([, imagePath]) => `${siteImagesLabel}/${toPosixPath(path.relative(siteImagesDir, imagePath))}`)
|
|
98
|
-
.sort((left, right) => left.localeCompare(right, 'sv'));
|
|
99
|
-
|
|
100
|
-
const getSectionReportOrder = () => {
|
|
101
|
-
const order = new Map();
|
|
102
|
-
frontmatterIds.forEach((id, index) => order.set(id, index));
|
|
103
|
-
extraSections.forEach((section, index) => {
|
|
104
|
-
if (!order.has(section.id)) {
|
|
105
|
-
order.set(section.id, frontmatterIds.length + index);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
return order;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
74
|
const groupIssuesBySeverity = (groupedIssues) => [
|
|
112
75
|
['error', 'Errors'],
|
|
113
76
|
['warning', 'Warnings'],
|
|
@@ -116,239 +79,17 @@ const groupIssuesBySeverity = (groupedIssues) => [
|
|
|
116
79
|
issues: groupedIssues.filter((issue) => issue.severity === severity),
|
|
117
80
|
})).filter((group) => group.issues.length > 0);
|
|
118
81
|
|
|
119
|
-
const getGreatestCommonDivisor = (left, right) => {
|
|
120
|
-
let a = Math.abs(left);
|
|
121
|
-
let b = Math.abs(right);
|
|
122
|
-
|
|
123
|
-
while (b !== 0) {
|
|
124
|
-
const next = a % b;
|
|
125
|
-
a = b;
|
|
126
|
-
b = next;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return a || 1;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const getAspectRatioLabel = ({ width, height }) => {
|
|
133
|
-
const divisor = getGreatestCommonDivisor(width, height);
|
|
134
|
-
return `${width / divisor}:${height / divisor}`;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const warnAboutCarouselAspectRatios = async () => {
|
|
138
|
-
for (const section of frontmatterSections) {
|
|
139
|
-
for (const carousel of section.carousels ?? []) {
|
|
140
|
-
const imagesWithRatios = [];
|
|
141
|
-
|
|
142
|
-
for (const { image } of carousel.imageReferences ?? []) {
|
|
143
|
-
const imagePath = imageIndex.get(image);
|
|
144
|
-
if (!imagePath) continue;
|
|
145
|
-
|
|
146
|
-
const dimensions = await readImageDimensions(imagePath).catch(() => null);
|
|
147
|
-
if (!dimensions) continue;
|
|
148
|
-
|
|
149
|
-
imagesWithRatios.push({
|
|
150
|
-
image,
|
|
151
|
-
ratio: getAspectRatioLabel(dimensions),
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const ratios = new Set(imagesWithRatios.map(({ ratio }) => ratio));
|
|
156
|
-
if (ratios.size <= 1) continue;
|
|
157
|
-
|
|
158
|
-
addSectionIssue(section.id, {
|
|
159
|
-
severity: 'warning',
|
|
160
|
-
message: `Carousel on line ${carousel.line} uses images with different aspect ratios: ${imagesWithRatios.map(({ image, ratio }) => `${image} (${ratio})`).join(', ')}.`,
|
|
161
|
-
fix: 'Use images with exactly matching proportions in the same carousel to avoid uneven layout and motion.',
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const validateRouteContentFiles = async () => {
|
|
168
|
-
const routeContentFiles = (await getContentFiles()).filter((contentFile) => !contentFile.isHome);
|
|
169
|
-
|
|
170
|
-
for (const contentFile of routeContentFiles) {
|
|
171
|
-
const { frontmatter: routeFrontmatter, body: routeBody } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
172
|
-
validateFrontmatterIndentation(routeFrontmatter, addIssue);
|
|
173
|
-
validateContentFrontmatterStructure(routeFrontmatter, addIssue);
|
|
174
|
-
|
|
175
|
-
const routeFrontmatterSections = getFrontmatterSections(routeFrontmatter);
|
|
176
|
-
const routeFrontmatterIds = routeFrontmatterSections.map((section) => section.id);
|
|
177
|
-
const routeBodySections = getBodySections(routeBody).sections;
|
|
178
|
-
const routeSectionsById = new Map();
|
|
179
|
-
const routeImageIndex = await getImageIndex(contentFile.imagesDir, fail);
|
|
180
|
-
|
|
181
|
-
for (const section of routeBodySections) {
|
|
182
|
-
const sectionLabel = `${contentFile.contentLabel} [${section.id ?? section.heading}]`;
|
|
183
|
-
|
|
184
|
-
if (!section.id) {
|
|
185
|
-
addIssue({
|
|
186
|
-
severity: 'warning',
|
|
187
|
-
sectionLabel,
|
|
188
|
-
message: 'Markdown section is missing an explicit heading id.',
|
|
189
|
-
fix: `Write the heading with an explicit id, for example "${section.heading} {#section-id}".`,
|
|
190
|
-
});
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (routeSectionsById.has(section.id)) {
|
|
195
|
-
addIssue({
|
|
196
|
-
severity: 'error',
|
|
197
|
-
sectionId: `${contentFile.contentLabel}:${section.id}`,
|
|
198
|
-
sectionLabel,
|
|
199
|
-
message: `Duplicate Markdown section heading id "${section.id}".`,
|
|
200
|
-
fix: 'Each Markdown level 2 section heading must use a unique explicit id.',
|
|
201
|
-
});
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
routeSectionsById.set(section.id, section);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
for (const id of routeFrontmatterIds) {
|
|
209
|
-
if (!routeSectionsById.has(id)) {
|
|
210
|
-
addIssue({
|
|
211
|
-
severity: 'error',
|
|
212
|
-
sectionId: `${contentFile.contentLabel}:${id}`,
|
|
213
|
-
sectionLabel: `${contentFile.contentLabel} [${id}]`,
|
|
214
|
-
message: `Cannot find a Markdown heading for section "${id}".`,
|
|
215
|
-
fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}".`,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
for (const section of routeBodySections) {
|
|
221
|
-
if (section.id && !routeFrontmatterIds.includes(section.id)) {
|
|
222
|
-
addIssue({
|
|
223
|
-
severity: 'warning',
|
|
224
|
-
sectionId: `${contentFile.contentLabel}:${section.id}`,
|
|
225
|
-
sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
|
|
226
|
-
message: `Markdown section "${section.id}" exists but is not used in frontmatter.`,
|
|
227
|
-
fix: 'Add it to frontmatter sections or remove the unused Markdown section.',
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const currentOrder = routeBodySections.map((section) => section.id).filter(Boolean);
|
|
233
|
-
const expectedOrder = routeFrontmatterIds.filter((id) => routeSectionsById.has(id));
|
|
234
|
-
if (
|
|
235
|
-
currentOrder.length !== expectedOrder.length ||
|
|
236
|
-
currentOrder.some((id, index) => id !== expectedOrder[index])
|
|
237
|
-
) {
|
|
238
|
-
addIssue({
|
|
239
|
-
severity: 'warning',
|
|
240
|
-
message: `${contentFile.contentLabel} Markdown section order differs from frontmatter.`,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
for (const section of routeFrontmatterSections) {
|
|
245
|
-
for (const imageName of section.images) {
|
|
246
|
-
if (imageName.includes('/') || imageName.includes('\\')) {
|
|
247
|
-
addIssue({
|
|
248
|
-
severity: 'error',
|
|
249
|
-
sectionId: `${contentFile.contentLabel}:${section.id}`,
|
|
250
|
-
sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
|
|
251
|
-
message: `Image reference "${imageName}" must be a filename without a directory.`,
|
|
252
|
-
fix: `Use only the filename in ${contentFile.contentLabel} and keep the file in the matching ${contentFile.imagesLabel}/<section-id>/ directory.`,
|
|
253
|
-
});
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (referencedImages.has(imageName)) {
|
|
258
|
-
const previousSectionId = referencedImages.get(imageName);
|
|
259
|
-
const message = `Image "${imageName}" is referenced more than once, in "${previousSectionId}" and "${contentFile.contentLabel}:${section.id}".`;
|
|
260
|
-
const fix = 'Each image filename can be referenced by only one image row.';
|
|
261
|
-
|
|
262
|
-
addIssue({ severity: 'error', sectionId: previousSectionId, message, fix });
|
|
263
|
-
addIssue({
|
|
264
|
-
severity: 'error',
|
|
265
|
-
sectionId: `${contentFile.contentLabel}:${section.id}`,
|
|
266
|
-
sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
|
|
267
|
-
message,
|
|
268
|
-
fix,
|
|
269
|
-
});
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
referencedImages.set(imageName, `${contentFile.contentLabel}:${section.id}`);
|
|
274
|
-
|
|
275
|
-
const imagePath = routeImageIndex.get(imageName);
|
|
276
|
-
if (!imagePath) {
|
|
277
|
-
addIssue({
|
|
278
|
-
severity: 'error',
|
|
279
|
-
sectionId: `${contentFile.contentLabel}:${section.id}`,
|
|
280
|
-
sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
|
|
281
|
-
message: `Image "${imageName}" does not exist anywhere under ${contentFile.imagesLabel}/.`,
|
|
282
|
-
fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the image row.`,
|
|
283
|
-
});
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const currentDirectory = path.basename(path.dirname(imagePath));
|
|
288
|
-
if (currentDirectory !== section.id) {
|
|
289
|
-
addIssue({
|
|
290
|
-
severity: 'error',
|
|
291
|
-
sectionId: `${contentFile.contentLabel}:${section.id}`,
|
|
292
|
-
sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
|
|
293
|
-
message: `Image "${imageName}" is used here but is located in ${contentFile.imagesLabel}/${currentDirectory}/.`,
|
|
294
|
-
fix: 'Move it to the matching section image directory.',
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
for (const carousel of section.carousels ?? []) {
|
|
300
|
-
const imagesWithRatios = [];
|
|
301
|
-
|
|
302
|
-
for (const { image } of carousel.imageReferences ?? []) {
|
|
303
|
-
const imagePath = routeImageIndex.get(image);
|
|
304
|
-
if (!imagePath) continue;
|
|
305
|
-
|
|
306
|
-
const dimensions = await readImageDimensions(imagePath).catch(() => null);
|
|
307
|
-
if (!dimensions) continue;
|
|
308
|
-
|
|
309
|
-
imagesWithRatios.push({
|
|
310
|
-
image,
|
|
311
|
-
ratio: getAspectRatioLabel(dimensions),
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const ratios = new Set(imagesWithRatios.map(({ ratio }) => ratio));
|
|
316
|
-
if (ratios.size <= 1) continue;
|
|
317
|
-
|
|
318
|
-
addIssue({
|
|
319
|
-
severity: 'warning',
|
|
320
|
-
sectionId: `${contentFile.contentLabel}:${section.id}`,
|
|
321
|
-
sectionLabel: `${contentFile.contentLabel} [${section.id}]`,
|
|
322
|
-
message: `Carousel on line ${carousel.line} uses images with different aspect ratios: ${imagesWithRatios.map(({ image, ratio }) => `${image} (${ratio})`).join(', ')}.`,
|
|
323
|
-
fix: 'Use images with exactly matching proportions in the same carousel to avoid uneven layout and motion.',
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
for (const styleName of new Set(getInlineStyleReferences(routeBody))) {
|
|
329
|
-
if (!themeInlineStyleNames.has(styleName)) {
|
|
330
|
-
addIssue({
|
|
331
|
-
severity: 'error',
|
|
332
|
-
message: `Inline style ".${styleName}" is used in ${contentFile.contentLabel} but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
|
|
333
|
-
fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
|
|
340
82
|
const formatIssue = (issue) => [
|
|
341
83
|
`- ${issue.message}`,
|
|
342
84
|
issue.fix ? ` Fix: ${issue.fix}` : null,
|
|
343
85
|
].filter(Boolean);
|
|
344
86
|
|
|
345
|
-
const getReportLines = (title) => {
|
|
87
|
+
const getReportLines = (title, unreferencedImages) => {
|
|
346
88
|
const lines = [title];
|
|
347
89
|
const sectionIssues = issues.filter((issue) => issue.sectionId || issue.sectionLabel);
|
|
348
90
|
const globalIssues = issues.filter((issue) => !issue.sectionId && !issue.sectionLabel);
|
|
349
91
|
|
|
350
92
|
if (sectionIssues.length > 0) {
|
|
351
|
-
const sectionOrder = getSectionReportOrder();
|
|
352
93
|
const sectionGroups = new Map();
|
|
353
94
|
|
|
354
95
|
for (const issue of sectionIssues) {
|
|
@@ -362,15 +103,9 @@ const getReportLines = (title) => {
|
|
|
362
103
|
sectionGroups.get(key).issues.push(issue);
|
|
363
104
|
}
|
|
364
105
|
|
|
365
|
-
|
|
366
|
-
(sectionOrder.get(leftKey) ?? Number.MAX_SAFE_INTEGER) -
|
|
367
|
-
(sectionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER) ||
|
|
368
|
-
leftKey.localeCompare(rightKey, 'sv')
|
|
369
|
-
));
|
|
370
|
-
|
|
371
|
-
lines.push('', 'Section And Image Issues');
|
|
106
|
+
lines.push('', 'Content Issues');
|
|
372
107
|
|
|
373
|
-
for (const [, group] of
|
|
108
|
+
for (const [, group] of Array.from(sectionGroups.entries()).sort(([left], [right]) => left.localeCompare(right, 'sv'))) {
|
|
374
109
|
lines.push('', `[${group.label}]`);
|
|
375
110
|
|
|
376
111
|
for (const severityGroup of groupIssuesBySeverity(group.issues)) {
|
|
@@ -397,13 +132,11 @@ const getReportLines = (title) => {
|
|
|
397
132
|
}
|
|
398
133
|
}
|
|
399
134
|
|
|
400
|
-
const unreferencedImages = getUnreferencedImages();
|
|
401
|
-
|
|
402
135
|
if (unreferencedImages.length > 0) {
|
|
403
136
|
lines.push(
|
|
404
137
|
'',
|
|
405
138
|
'Unreferenced Images',
|
|
406
|
-
|
|
139
|
+
'These files are kept under page or route image roots but are not referenced by Norna-managed image references:',
|
|
407
140
|
);
|
|
408
141
|
|
|
409
142
|
for (const imagePath of unreferencedImages) {
|
|
@@ -414,185 +147,454 @@ const getReportLines = (title) => {
|
|
|
414
147
|
return lines;
|
|
415
148
|
};
|
|
416
149
|
|
|
417
|
-
const printReport = (title) => {
|
|
418
|
-
const lines = getReportLines(title);
|
|
419
|
-
const
|
|
420
|
-
|
|
150
|
+
const printReport = (title, unreferencedImages) => {
|
|
151
|
+
const lines = getReportLines(title, unreferencedImages);
|
|
152
|
+
const target = hasErrors() ? console.error : console.log;
|
|
153
|
+
target(lines.join('\n'));
|
|
421
154
|
};
|
|
422
155
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
156
|
+
const getGreatestCommonDivisor = (left, right) => {
|
|
157
|
+
let a = Math.abs(left);
|
|
158
|
+
let b = Math.abs(right);
|
|
159
|
+
|
|
160
|
+
while (b !== 0) {
|
|
161
|
+
const next = a % b;
|
|
162
|
+
a = b;
|
|
163
|
+
b = next;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return a || 1;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const getAspectRatioLabel = ({ width, height }) => {
|
|
170
|
+
const divisor = getGreatestCommonDivisor(width, height);
|
|
171
|
+
return `${width / divisor}:${height / divisor}`;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const getExpectedImagePath = (contentFile, sectionId, imageName) =>
|
|
175
|
+
path.join(contentFile.imagesDir, sectionId, imageName);
|
|
176
|
+
|
|
177
|
+
const getExpectedImageLabel = (contentFile, sectionId, imageName) =>
|
|
178
|
+
`${contentFile.imagesLabel}/${sectionId}/${imageName}`;
|
|
179
|
+
|
|
180
|
+
const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
|
|
181
|
+
(globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
|
|
182
|
+
|
|
183
|
+
const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id}]`;
|
|
184
|
+
|
|
185
|
+
const getRootLabel = (contentFile) => contentFile.isHome
|
|
186
|
+
? contentFile.imagesLabel
|
|
187
|
+
: contentFile.imagesLabel.replace(/\/images$/, '');
|
|
188
|
+
|
|
189
|
+
const getCandidateLabel = ({ contentFile, imagePath }) =>
|
|
190
|
+
`${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
|
|
191
|
+
|
|
192
|
+
const addExpectedReference = (expectedReferencesByPath, expectedPath, expectedReference) => {
|
|
193
|
+
if (!expectedReferencesByPath.has(expectedPath)) {
|
|
194
|
+
expectedReferencesByPath.set(expectedPath, []);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
expectedReferencesByPath.get(expectedPath).push(expectedReference);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const addGlobalImageCandidate = (globalImageCandidatesByName, imageName, candidate) => {
|
|
201
|
+
if (!globalImageCandidatesByName.has(imageName)) {
|
|
202
|
+
globalImageCandidatesByName.set(imageName, []);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
globalImageCandidatesByName.get(imageName).push(candidate);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const assertCleanGitForCrossRootSync = async (moves) => {
|
|
209
|
+
if (!moves.some((move) => move.crossRoot)) return;
|
|
210
|
+
|
|
211
|
+
let stdout = '';
|
|
212
|
+
try {
|
|
213
|
+
({ stdout } = await execFileAsync('git', ['status', '--short'], {
|
|
214
|
+
cwd: siteProjectRoot,
|
|
215
|
+
maxBuffer: 1024 * 1024,
|
|
216
|
+
}));
|
|
217
|
+
} catch (error) {
|
|
218
|
+
addIssue({
|
|
219
|
+
severity: 'error',
|
|
220
|
+
message: 'Cross-route content sync requires a clean git working tree, but git status could not be checked.',
|
|
221
|
+
fix: 'Run content:sync inside a Git worktree, or move cross-route image files manually.',
|
|
430
222
|
});
|
|
431
|
-
|
|
223
|
+
return;
|
|
432
224
|
}
|
|
433
225
|
|
|
434
|
-
if (
|
|
435
|
-
|
|
226
|
+
if (!stdout.trim()) return;
|
|
227
|
+
|
|
228
|
+
addIssue({
|
|
229
|
+
severity: 'error',
|
|
230
|
+
message: 'Cross-route content sync requires a clean git working tree before moving files between page or route image roots.',
|
|
231
|
+
fix: `Commit or stash your current changes, then run content:sync again. Current changes:\n${stdout.trim()}`,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const addConflictingMoveIssues = () => {
|
|
236
|
+
const movesBySource = new Map();
|
|
237
|
+
const movesByDestination = new Map();
|
|
238
|
+
|
|
239
|
+
for (const move of imageMoves) {
|
|
240
|
+
const sourceKey = move.from;
|
|
241
|
+
const destinationKey = move.to;
|
|
242
|
+
|
|
243
|
+
if (!movesBySource.has(sourceKey)) {
|
|
244
|
+
movesBySource.set(sourceKey, []);
|
|
245
|
+
}
|
|
246
|
+
movesBySource.get(sourceKey).push(move);
|
|
247
|
+
|
|
248
|
+
if (!movesByDestination.has(destinationKey)) {
|
|
249
|
+
movesByDestination.set(destinationKey, []);
|
|
250
|
+
}
|
|
251
|
+
movesByDestination.get(destinationKey).push(move);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const moves of movesBySource.values()) {
|
|
255
|
+
const destinations = new Set(moves.map((move) => move.to));
|
|
256
|
+
if (destinations.size <= 1) continue;
|
|
257
|
+
|
|
258
|
+
const firstMove = moves[0];
|
|
259
|
+
addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
|
|
436
260
|
severity: 'error',
|
|
437
|
-
message: `
|
|
438
|
-
fix: '
|
|
261
|
+
message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.sectionId, move.imageName)).join(', ')}.`,
|
|
262
|
+
fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
|
|
439
263
|
});
|
|
440
|
-
continue;
|
|
441
264
|
}
|
|
442
265
|
|
|
443
|
-
|
|
444
|
-
|
|
266
|
+
for (const moves of movesByDestination.values()) {
|
|
267
|
+
const sources = new Set(moves.map((move) => move.from));
|
|
268
|
+
if (sources.size <= 1) continue;
|
|
445
269
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
addSectionIssue(id, {
|
|
270
|
+
const firstMove = moves[0];
|
|
271
|
+
addSectionIssue(firstMove.contentFile, { id: firstMove.sectionId, heading: firstMove.sectionId }, {
|
|
449
272
|
severity: 'error',
|
|
450
|
-
message: `Cannot
|
|
451
|
-
fix:
|
|
273
|
+
message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.sectionId, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
|
|
274
|
+
fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
|
|
452
275
|
});
|
|
453
276
|
}
|
|
454
|
-
}
|
|
277
|
+
};
|
|
455
278
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
279
|
+
const validateImageReference = async (
|
|
280
|
+
contentFile,
|
|
281
|
+
section,
|
|
282
|
+
reference,
|
|
283
|
+
globalImageCandidatesByName,
|
|
284
|
+
expectedReferencesByPath,
|
|
285
|
+
) => {
|
|
286
|
+
const imageName = reference.image;
|
|
287
|
+
const expectedPath = getExpectedImagePath(contentFile, section.id, imageName);
|
|
288
|
+
const expectedLabel = getExpectedImageLabel(contentFile, section.id, imageName);
|
|
289
|
+
const expectedExists = await stat(expectedPath).then((entry) => entry.isFile()).catch(() => false);
|
|
290
|
+
|
|
291
|
+
if (expectedExists) {
|
|
292
|
+
referencedImagePaths.add(expectedPath);
|
|
293
|
+
return expectedPath;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const candidates = getGlobalImageCandidates(globalImageCandidatesByName, imageName, expectedPath);
|
|
297
|
+
if (candidates.length === 0) {
|
|
298
|
+
addSectionIssue(contentFile, section, {
|
|
299
|
+
severity: 'error',
|
|
300
|
+
message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page or route image root.`,
|
|
301
|
+
fix: `Add the source image to ${contentFile.imagesLabel}/${section.id}/ or remove the Norna-managed image reference.`,
|
|
302
|
+
});
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (candidates.length > 1) {
|
|
307
|
+
addSectionIssue(contentFile, section, {
|
|
308
|
+
severity: 'error',
|
|
309
|
+
message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getCandidateLabel).join(', ')}.`,
|
|
310
|
+
fix: 'Move the intended file manually or rename files so the move is unambiguous.',
|
|
311
|
+
});
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sourceCandidate = candidates[0];
|
|
316
|
+
const sourcePath = sourceCandidate.imagePath;
|
|
317
|
+
const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
|
|
318
|
+
.filter((expectedReference) => expectedReference.contentFile !== contentFile || expectedReference.section.id !== section.id);
|
|
319
|
+
|
|
320
|
+
if (referencesAtCurrentLocation.length > 0) {
|
|
321
|
+
addSectionIssue(contentFile, section, {
|
|
322
|
+
severity: 'error',
|
|
323
|
+
message: `Cannot relocate "${imageName}" from ${getCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
|
|
324
|
+
fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
|
|
325
|
+
});
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
imageMoves.push({
|
|
330
|
+
imageName,
|
|
331
|
+
from: sourcePath,
|
|
332
|
+
to: expectedPath,
|
|
333
|
+
contentFile,
|
|
334
|
+
sourceContentFile: sourceCandidate.contentFile,
|
|
335
|
+
sectionId: section.id,
|
|
336
|
+
crossRoot: sourceCandidate.contentFile !== contentFile,
|
|
337
|
+
});
|
|
338
|
+
referencedImagePaths.add(sourcePath);
|
|
339
|
+
|
|
340
|
+
if (!shouldWrite) {
|
|
341
|
+
addSectionIssue(contentFile, section, {
|
|
342
|
+
severity: 'error',
|
|
343
|
+
message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
|
|
344
|
+
fix: sourceCandidate.contentFile === contentFile
|
|
345
|
+
? 'Run norna content:sync to move it inside the current page or route image root.'
|
|
346
|
+
: `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-route sync requires a clean git working tree when files are moved.`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return sourcePath;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const warnAboutCarouselAspectRatios = async (contentFile, section, block, sourcePathsByImage) => {
|
|
354
|
+
if (block.type !== 'image-carousel') return;
|
|
355
|
+
|
|
356
|
+
const imagesWithRatios = [];
|
|
357
|
+
const imagesWithoutRatios = [];
|
|
358
|
+
for (const image of block.images) {
|
|
359
|
+
const imagePath = sourcePathsByImage.get(image.image);
|
|
360
|
+
if (!imagePath) continue;
|
|
361
|
+
|
|
362
|
+
const dimensions = await readImageDimensions(imagePath).catch(() => null);
|
|
363
|
+
if (!dimensions) {
|
|
364
|
+
imagesWithoutRatios.push(image.image);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
imagesWithRatios.push({
|
|
369
|
+
image: image.image,
|
|
370
|
+
ratio: getAspectRatioLabel(dimensions),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (imagesWithoutRatios.length > 0) {
|
|
375
|
+
addSectionIssue(contentFile, section, {
|
|
459
376
|
severity: 'warning',
|
|
460
|
-
message: `
|
|
461
|
-
fix: '
|
|
377
|
+
message: `Carousel on line ${block.line} uses images without an intrinsic aspect ratio: ${imagesWithoutRatios.join(', ')}.`,
|
|
378
|
+
fix: 'For SVG images, add a viewBox or numeric width and height when stable carousel sizing matters.',
|
|
462
379
|
});
|
|
463
|
-
extraSections.push(section);
|
|
464
380
|
}
|
|
465
|
-
}
|
|
466
381
|
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
382
|
+
const ratios = new Set(imagesWithRatios.map(({ ratio }) => ratio));
|
|
383
|
+
if (ratios.size <= 1) return;
|
|
384
|
+
|
|
385
|
+
addSectionIssue(contentFile, section, {
|
|
386
|
+
severity: 'warning',
|
|
387
|
+
message: `Carousel on line ${block.line} uses images with different aspect ratios: ${imagesWithRatios.map(({ image, ratio }) => `${image} (${ratio})`).join(', ')}.`,
|
|
388
|
+
fix: 'Use images with exactly matching proportions in the same carousel to avoid uneven layout and motion.',
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
let themeFrontmatter = null;
|
|
393
|
+
try {
|
|
394
|
+
themeFrontmatter = (await readThemeFile(siteThemePath)).frontmatter;
|
|
395
|
+
validateFrontmatterIndentation(themeFrontmatter, addIssue);
|
|
396
|
+
validateThemeFrontmatterStructure(themeFrontmatter, addIssue);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error?.code !== 'ENOENT') {
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
479
401
|
}
|
|
480
402
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
403
|
+
const themeInlineStyleNames = themeFrontmatter
|
|
404
|
+
? getFrontmatterInlineStyleNames(themeFrontmatter)
|
|
405
|
+
: new Set();
|
|
406
|
+
|
|
407
|
+
const contentFiles = await getContentFiles();
|
|
408
|
+
const allImageFiles = [];
|
|
409
|
+
const contentFileContexts = [];
|
|
410
|
+
const globalImageCandidatesByName = new Map();
|
|
411
|
+
const globalExpectedReferencesByPath = new Map();
|
|
412
|
+
|
|
413
|
+
for (const contentFile of contentFiles) {
|
|
414
|
+
const { frontmatter, body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
415
|
+
const bodyLineOffset = frontmatter.split(/\r?\n/).length - 1;
|
|
416
|
+
validateFrontmatterIndentation(frontmatter, addIssue);
|
|
417
|
+
validateContentFrontmatterStructure(frontmatter, addIssue);
|
|
418
|
+
|
|
419
|
+
const frontmatterSections = getFrontmatterSections(frontmatter);
|
|
420
|
+
const frontmatterIds = new Set(frontmatterSections.map((section) => section.id));
|
|
421
|
+
const { sections } = getBodySections(body);
|
|
422
|
+
const sectionsById = new Map();
|
|
423
|
+
const blockResultsBySectionId = new Map();
|
|
424
|
+
const imageCandidatesByName = await getImageCandidatesByName(contentFile.imagesDir);
|
|
425
|
+
|
|
426
|
+
for (const [imageName, candidates] of imageCandidatesByName) {
|
|
427
|
+
for (const imagePath of candidates) {
|
|
428
|
+
const candidate = { contentFile, imagePath };
|
|
429
|
+
allImageFiles.push(candidate);
|
|
430
|
+
addGlobalImageCandidate(globalImageCandidatesByName, imageName, candidate);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const section of sections) {
|
|
435
|
+
if (!section.id) {
|
|
436
|
+
addSectionIssue(contentFile, section, {
|
|
485
437
|
severity: 'error',
|
|
486
|
-
message: `
|
|
487
|
-
fix: `
|
|
438
|
+
message: `Section heading "${section.heading.replace(/^##\s+/, '')}" is missing an explicit id.`,
|
|
439
|
+
fix: `Write it as: ${section.heading} {#section-id}`,
|
|
488
440
|
});
|
|
489
441
|
continue;
|
|
490
442
|
}
|
|
491
443
|
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
:
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
addSectionIssue(previousSectionId, { severity: 'error', message, fix });
|
|
500
|
-
if (previousSectionId !== section.id) {
|
|
501
|
-
addSectionIssue(section.id, { severity: 'error', message, fix });
|
|
502
|
-
}
|
|
444
|
+
if (sectionsById.has(section.id)) {
|
|
445
|
+
addSectionIssue(contentFile, section, {
|
|
446
|
+
severity: 'error',
|
|
447
|
+
message: `Duplicate Markdown section heading id "${section.id}".`,
|
|
448
|
+
fix: 'Each Markdown level 2 section heading must use a unique explicit id within the page.',
|
|
449
|
+
});
|
|
503
450
|
continue;
|
|
504
451
|
}
|
|
505
452
|
|
|
506
|
-
|
|
453
|
+
sectionsById.set(section.id, section);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const section of sections) {
|
|
457
|
+
if (!section.id || !sectionsById.has(section.id)) continue;
|
|
458
|
+
|
|
459
|
+
const blockResults = extractNornaMarkdownBlockDiagnostics(section.text, {
|
|
460
|
+
label: contentFile.contentLabel,
|
|
461
|
+
lineOffset: bodyLineOffset + section.line - 1,
|
|
462
|
+
});
|
|
463
|
+
blockResultsBySectionId.set(section.id, blockResults);
|
|
464
|
+
|
|
465
|
+
for (const reference of getNornaBlockImageReferences(blockResults.blocks)) {
|
|
466
|
+
const expectedPath = getExpectedImagePath(contentFile, section.id, reference.image);
|
|
467
|
+
addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
contentFileContexts.push({
|
|
472
|
+
contentFile,
|
|
473
|
+
frontmatterIds,
|
|
474
|
+
sections,
|
|
475
|
+
sectionsById,
|
|
476
|
+
blockResultsBySectionId,
|
|
477
|
+
body,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
507
480
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
481
|
+
for (const context of contentFileContexts) {
|
|
482
|
+
const {
|
|
483
|
+
contentFile,
|
|
484
|
+
frontmatterIds,
|
|
485
|
+
sections,
|
|
486
|
+
sectionsById,
|
|
487
|
+
blockResultsBySectionId,
|
|
488
|
+
body,
|
|
489
|
+
} = context;
|
|
490
|
+
|
|
491
|
+
for (const id of frontmatterIds) {
|
|
492
|
+
if (!sectionsById.has(id)) {
|
|
493
|
+
addContentIssue(contentFile, {
|
|
511
494
|
severity: 'error',
|
|
512
|
-
message: `
|
|
513
|
-
fix: `Add
|
|
495
|
+
message: `Section metadata "${id}" does not match any Markdown section.`,
|
|
496
|
+
fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}", or remove sections.${id}.`,
|
|
514
497
|
});
|
|
515
|
-
continue;
|
|
516
498
|
}
|
|
499
|
+
}
|
|
517
500
|
|
|
518
|
-
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
501
|
+
for (const styleName of new Set(getInlineStyleReferences(body))) {
|
|
502
|
+
if (!themeInlineStyleNames.has(styleName)) {
|
|
503
|
+
addContentIssue(contentFile, {
|
|
504
|
+
severity: 'error',
|
|
505
|
+
message: `Inline style ".${styleName}" is used in ${contentFile.contentLabel} but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
|
|
506
|
+
fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
522
510
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
severity: 'error',
|
|
526
|
-
message: `Cannot move image "${imageName}" to ${siteImagesLabel}/${section.id}/ because the target file already exists.`,
|
|
527
|
-
fix: 'Rename one of the files so image filenames remain globally unique.',
|
|
528
|
-
});
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
511
|
+
for (const section of sections) {
|
|
512
|
+
if (!section.id || !sectionsById.has(section.id)) continue;
|
|
531
513
|
|
|
532
|
-
|
|
514
|
+
const markdownImages = extractMarkdownImageReferences(section.text);
|
|
515
|
+
for (const reference of markdownImages) {
|
|
516
|
+
addSectionIssue(contentFile, section, {
|
|
517
|
+
severity: 'warning',
|
|
518
|
+
message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
|
|
519
|
+
fix: 'Use a norna-image-stack, norna-image-carousel, or norna-card-list block for site images that should be validated, processed and synced.',
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const { blocks, errors: blockErrors } = blockResultsBySectionId.get(section.id) ?? { blocks: [], errors: [] };
|
|
524
|
+
for (const error of blockErrors) {
|
|
525
|
+
addSectionIssue(contentFile, section, {
|
|
526
|
+
severity: 'error',
|
|
527
|
+
message: error.message,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
533
530
|
|
|
534
|
-
|
|
535
|
-
|
|
531
|
+
for (const block of blocks) {
|
|
532
|
+
if (block.type === 'image-carousel' && block.images.length < 2) {
|
|
533
|
+
addSectionIssue(contentFile, section, {
|
|
536
534
|
severity: 'error',
|
|
537
|
-
message: `
|
|
538
|
-
fix: '
|
|
535
|
+
message: `norna-image-carousel on line ${block.line} contains ${block.images.length} image. A carousel needs at least two images.`,
|
|
536
|
+
fix: 'Add another image entry, or use norna-image-stack for a single image.',
|
|
539
537
|
});
|
|
540
538
|
}
|
|
541
539
|
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
540
|
|
|
545
|
-
|
|
541
|
+
const sourcePathsByImage = new Map();
|
|
542
|
+
for (const reference of getNornaBlockImageReferences(blocks)) {
|
|
543
|
+
const sourcePath = await validateImageReference(
|
|
544
|
+
contentFile,
|
|
545
|
+
section,
|
|
546
|
+
reference,
|
|
547
|
+
globalImageCandidatesByName,
|
|
548
|
+
globalExpectedReferencesByPath,
|
|
549
|
+
);
|
|
550
|
+
if (sourcePath) {
|
|
551
|
+
sourcePathsByImage.set(reference.image, sourcePath);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
546
554
|
|
|
547
|
-
for (const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
severity: 'error',
|
|
551
|
-
message: `Inline style ".${styleName}" is used in Markdown but is not defined in ${siteThemeLabel} presentation.inlineStyles.`,
|
|
552
|
-
fix: `Add presentation.inlineStyles.${styleName} to ${siteThemeLabel} or remove "{.${styleName}}" from Markdown.`,
|
|
553
|
-
});
|
|
555
|
+
for (const block of blocks) {
|
|
556
|
+
await warnAboutCarouselAspectRatios(contentFile, section, block, sourcePathsByImage);
|
|
557
|
+
}
|
|
554
558
|
}
|
|
555
559
|
}
|
|
556
560
|
|
|
557
|
-
|
|
561
|
+
const unreferencedImages = allImageFiles
|
|
562
|
+
.filter(({ imagePath }) => !referencedImagePaths.has(imagePath))
|
|
563
|
+
.map(({ contentFile, imagePath }) => `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`)
|
|
564
|
+
.sort((left, right) => left.localeCompare(right, 'sv'));
|
|
565
|
+
|
|
566
|
+
addConflictingMoveIssues();
|
|
567
|
+
|
|
568
|
+
if (shouldWrite) {
|
|
569
|
+
await assertCleanGitForCrossRootSync(imageMoves);
|
|
570
|
+
}
|
|
558
571
|
|
|
559
572
|
if (hasErrors()) {
|
|
560
|
-
printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.');
|
|
573
|
+
printReport(shouldWrite ? 'Content sync failed.' : 'Content check failed.', unreferencedImages);
|
|
561
574
|
process.exit(1);
|
|
562
575
|
}
|
|
563
576
|
|
|
564
577
|
if (shouldCheck) {
|
|
565
|
-
const title = issues.length > 0
|
|
578
|
+
const title = issues.length > 0 || unreferencedImages.length > 0
|
|
566
579
|
? 'Content check completed with warnings.'
|
|
567
580
|
: 'Content check passed.';
|
|
568
|
-
printReport(title);
|
|
581
|
+
printReport(title, unreferencedImages);
|
|
569
582
|
process.exit(0);
|
|
570
583
|
}
|
|
571
584
|
|
|
572
|
-
if (
|
|
573
|
-
console.log(
|
|
585
|
+
if (imageMoves.length === 0) {
|
|
586
|
+
console.log('No content sync needed.');
|
|
574
587
|
process.exit(0);
|
|
575
588
|
}
|
|
576
589
|
|
|
577
|
-
if (issues.length > 0) {
|
|
578
|
-
printReport('Content sync completed with warnings.');
|
|
579
|
-
}
|
|
580
|
-
|
|
581
590
|
const canWrite = await promptForWrite();
|
|
582
|
-
|
|
583
591
|
if (!canWrite) {
|
|
584
592
|
console.log('Aborted. No file was changed.');
|
|
585
593
|
process.exit(1);
|
|
586
594
|
}
|
|
587
595
|
|
|
588
|
-
const nextBody = `${prelude}${orderedSections.map((section) => section.text).join('\n')}\n`;
|
|
589
|
-
if (hasOrderMismatch) {
|
|
590
|
-
await writeFile(siteContentPath, `${frontmatter}${nextBody}`);
|
|
591
|
-
console.log(`${siteContentLabel}: Markdown sections were sorted according to frontmatter.`);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
596
|
for (const move of imageMoves) {
|
|
595
597
|
await mkdir(path.dirname(move.to), { recursive: true });
|
|
596
598
|
await rename(move.from, move.to);
|
|
597
|
-
console.log(`Moved image "${move.imageName}" to ${
|
|
599
|
+
console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/${move.sectionId}/.`);
|
|
598
600
|
}
|