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