@janga/norna 0.7.0
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/LICENSE +674 -0
- package/README.md +109 -0
- package/astro.config.mjs +17 -0
- package/bin/norna.mjs +170 -0
- package/docs/README.md +48 -0
- package/docs/command-organization.md +402 -0
- package/docs/commands.md +152 -0
- package/docs/configuration.md +376 -0
- package/docs/content.md +384 -0
- package/docs/engine-development.md +164 -0
- package/docs/getting-started.md +96 -0
- package/docs/images-and-metadata.md +88 -0
- package/docs/local-development.md +61 -0
- package/docs/publishing.md +81 -0
- package/docs/site-examples-structure-note.md +105 -0
- package/docs/site-structure.md +81 -0
- package/fixtures/basic/site/.norna/generated-images.json +1 -0
- package/fixtures/basic/site/config.mjs +59 -0
- package/fixtures/basic/site/content.md +17 -0
- package/fixtures/basic/site/images/work/.gitkeep +1 -0
- package/fixtures/basic/site/public/robots.txt +2 -0
- package/fixtures/basic/site/theme.md +7 -0
- package/package.json +90 -0
- package/scripts/build-site.mjs +16 -0
- package/scripts/check-config.mjs +37 -0
- package/scripts/deploy-site.mjs +389 -0
- package/scripts/dev-local.mjs +313 -0
- package/scripts/doctor.mjs +38 -0
- package/scripts/engine-version.mjs +137 -0
- package/scripts/generate-images.mjs +369 -0
- package/scripts/init-site.mjs +249 -0
- package/scripts/lib/astro-command.mjs +34 -0
- package/scripts/lib/ci-lockfile.mjs +34 -0
- package/scripts/lib/image-dimensions.mjs +78 -0
- package/scripts/lib/presentation.mjs +72 -0
- package/scripts/lib/project-config.mjs +322 -0
- package/scripts/lib/run-command.mjs +21 -0
- package/scripts/lib/site-content.mjs +392 -0
- package/scripts/lib/site-paths.mjs +127 -0
- package/scripts/lib/typography.mjs +166 -0
- package/scripts/release.mjs +77 -0
- package/scripts/show-typography.mjs +210 -0
- package/scripts/sync-content-sections.mjs +610 -0
- package/scripts/sync-site-public.mjs +42 -0
- package/scripts/test-ci-lockfile.mjs +65 -0
- package/scripts/test-content-check.mjs +364 -0
- package/scripts/test-engine-commands.mjs +128 -0
- package/scripts/test-navigation-preview.mjs +108 -0
- package/scripts/test-navigation.mjs +116 -0
- package/scripts/test-package-check.mjs +394 -0
- package/scripts/test-site-public.mjs +85 -0
- package/scripts/test-temporary-visibility.mjs +99 -0
- package/scripts/update-engine.mjs +127 -0
- package/scripts/watch-pages-deploy.mjs +430 -0
- package/src/components/GalleryGrid.astro +221 -0
- package/src/components/SiteNavigation.astro +410 -0
- package/src/components/SitePage.astro +69 -0
- package/src/components/SiteSection.astro +174 -0
- package/src/content.config.ts +171 -0
- package/src/layouts/BaseLayout.astro +90 -0
- package/src/lib/generatedImages.ts +63 -0
- package/src/lib/sectionContent.ts +125 -0
- package/src/lib/sitePages.ts +80 -0
- package/src/lib/sitePublicAssets.ts +39 -0
- package/src/lib/visibility.ts +35 -0
- package/src/pages/[slug].astro +31 -0
- package/src/pages/index.astro +16 -0
- package/src/styles/global.css +872 -0
- package/starters/basic/.github/workflows/deploy.yml +65 -0
- package/starters/basic/README.md +55 -0
- package/starters/basic/package.json +35 -0
- package/starters/basic/site/config.mjs +60 -0
- package/starters/basic/site/content.md +21 -0
- package/starters/basic/site/images/work/.gitkeep +1 -0
- package/starters/basic/site/public/robots.txt +2 -0
- package/starters/basic/site/theme.md +53 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { access, readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
siteContentLabel,
|
|
5
|
+
siteContentPath,
|
|
6
|
+
siteDir,
|
|
7
|
+
siteImagesDir,
|
|
8
|
+
siteImagesLabel,
|
|
9
|
+
siteRoutesDir,
|
|
10
|
+
siteRoutesLabel,
|
|
11
|
+
siteThemeLabel,
|
|
12
|
+
} from './site-paths.mjs';
|
|
13
|
+
|
|
14
|
+
export const supportedImageExtensions = new Set(['.jpg', '.jpeg', '.png']);
|
|
15
|
+
|
|
16
|
+
const h2Regex = /^##\s+.*$/gm;
|
|
17
|
+
const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
|
|
18
|
+
const inlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}/g;
|
|
19
|
+
const frontmatterDelimiterRegex = /^---\s*$/;
|
|
20
|
+
const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'slug', 'navigation', 'presentation', 'frame', 'sections']);
|
|
21
|
+
const knownThemeTopLevelFrontmatterKeys = new Set(['presentation', 'frame']);
|
|
22
|
+
const knownNestedFrontmatterKeys = new Set([
|
|
23
|
+
'align',
|
|
24
|
+
'alt',
|
|
25
|
+
'backgroundColor',
|
|
26
|
+
'body',
|
|
27
|
+
'caption',
|
|
28
|
+
'carousel',
|
|
29
|
+
'color',
|
|
30
|
+
'colors',
|
|
31
|
+
'desktop',
|
|
32
|
+
'from',
|
|
33
|
+
'gallery',
|
|
34
|
+
'heading',
|
|
35
|
+
'id',
|
|
36
|
+
'include',
|
|
37
|
+
'image',
|
|
38
|
+
'inlineStyles',
|
|
39
|
+
'label',
|
|
40
|
+
'lineHeight',
|
|
41
|
+
'mobile',
|
|
42
|
+
'navigation',
|
|
43
|
+
'overrides',
|
|
44
|
+
'order',
|
|
45
|
+
'paragraphSpacing',
|
|
46
|
+
'preset',
|
|
47
|
+
'presentation',
|
|
48
|
+
'sections',
|
|
49
|
+
'size',
|
|
50
|
+
'spacing',
|
|
51
|
+
'textColor',
|
|
52
|
+
'theme',
|
|
53
|
+
'typography',
|
|
54
|
+
'until',
|
|
55
|
+
'visible',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
export const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
|
|
59
|
+
|
|
60
|
+
const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
|
|
61
|
+
|
|
62
|
+
export const getContentFiles = async () => {
|
|
63
|
+
const contentFiles = [{
|
|
64
|
+
contentLabel: siteContentLabel,
|
|
65
|
+
contentPath: siteContentPath,
|
|
66
|
+
imagesDir: siteImagesDir,
|
|
67
|
+
imagesLabel: siteImagesLabel,
|
|
68
|
+
isHome: true,
|
|
69
|
+
routeFolder: null,
|
|
70
|
+
}];
|
|
71
|
+
const routeEntries = await readdir(siteRoutesDir, { withFileTypes: true }).catch((error) => {
|
|
72
|
+
if (error?.code === 'ENOENT') {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw error;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
for (const entry of routeEntries) {
|
|
80
|
+
if (!entry.isDirectory()) continue;
|
|
81
|
+
|
|
82
|
+
const routeFolder = entry.name;
|
|
83
|
+
const routeDir = path.join(siteRoutesDir, routeFolder);
|
|
84
|
+
const routeContentPath = path.join(routeDir, 'route-content.md');
|
|
85
|
+
|
|
86
|
+
if (!(await fileExists(routeContentPath))) continue;
|
|
87
|
+
|
|
88
|
+
contentFiles.push({
|
|
89
|
+
contentLabel: `${siteRoutesLabel}/${routeFolder}/route-content.md`,
|
|
90
|
+
contentPath: routeContentPath,
|
|
91
|
+
imagesDir: path.join(routeDir, 'images'),
|
|
92
|
+
imagesLabel: `${siteRoutesLabel}/${routeFolder}/images`,
|
|
93
|
+
isHome: false,
|
|
94
|
+
routeFolder,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return contentFiles;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const splitSiteFile = (source, label = siteContentLabel) => {
|
|
102
|
+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
103
|
+
|
|
104
|
+
if (!match) {
|
|
105
|
+
throw new Error(`${label} is missing frontmatter delimited by ---.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
frontmatter: match[0],
|
|
110
|
+
frontmatterBody: match[1],
|
|
111
|
+
body: source.slice(match[0].length),
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const readSiteFile = async (sitePath, label = siteContentLabel) => splitSiteFile(await readFile(sitePath, 'utf8'), label);
|
|
116
|
+
|
|
117
|
+
const getIndentInfo = (line) => {
|
|
118
|
+
const characters = Array.from(line);
|
|
119
|
+
const indentCharacters = [];
|
|
120
|
+
|
|
121
|
+
for (const character of characters) {
|
|
122
|
+
if (character === ' ' || character === '\t' || character === '\u00a0' || character === '\uFFFD' || character === '\u00c2') {
|
|
123
|
+
indentCharacters.push(character);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
indent: indentCharacters.length,
|
|
132
|
+
hasInvalidWhitespace: indentCharacters.some((character) => character !== ' '),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const getNextFrontmatterEntry = (lines, startIndex) => {
|
|
137
|
+
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
|
138
|
+
const line = lines[index];
|
|
139
|
+
if (!line.trim() || line.trim().startsWith('#') || frontmatterDelimiterRegex.test(line)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
index,
|
|
145
|
+
line,
|
|
146
|
+
...getIndentInfo(line),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const validateFrontmatterIndentation = (frontmatter, addIssue) => {
|
|
154
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
155
|
+
|
|
156
|
+
for (const [index, line] of lines.entries()) {
|
|
157
|
+
const lineNumber = index + 1;
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
|
|
160
|
+
if (!trimmed || trimmed.startsWith('#') || frontmatterDelimiterRegex.test(line)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { indent, hasInvalidWhitespace } = getIndentInfo(line);
|
|
165
|
+
|
|
166
|
+
if (hasInvalidWhitespace) {
|
|
167
|
+
addIssue({
|
|
168
|
+
severity: 'error',
|
|
169
|
+
message: `Frontmatter line ${lineNumber} uses tabs, non-breaking spaces, or invalid whitespace for indentation.`,
|
|
170
|
+
fix: 'Replace the indentation on that line with ordinary spaces.',
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (indent % 2 !== 0) {
|
|
176
|
+
addIssue({
|
|
177
|
+
severity: 'error',
|
|
178
|
+
message: `Frontmatter line ${lineNumber} is indented with ${indent} spaces.`,
|
|
179
|
+
fix: 'Use 2-space indentation levels in frontmatter.',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const keyValueMatch = line.match(/^(\s*)[A-Za-z][A-Za-z0-9-]*:\s+(.+)$/);
|
|
184
|
+
if (!keyValueMatch) continue;
|
|
185
|
+
|
|
186
|
+
const value = keyValueMatch[2].trim();
|
|
187
|
+
if (value === '|' || value === '>' || value.startsWith('|') || value.startsWith('>')) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const nextEntry = getNextFrontmatterEntry(lines, index);
|
|
192
|
+
if (!nextEntry || nextEntry.indent <= indent) continue;
|
|
193
|
+
|
|
194
|
+
addIssue({
|
|
195
|
+
severity: 'error',
|
|
196
|
+
message: `Frontmatter line ${nextEntry.index + 1} is indented under line ${lineNumber}, but line ${lineNumber} already has a value.`,
|
|
197
|
+
fix: 'Move the later line to the same indentation level as its sibling, or place it under a key that has no value.',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const validateFrontmatterStructure = (frontmatter, addIssue, {
|
|
203
|
+
knownTopLevelFrontmatterKeys = knownContentTopLevelFrontmatterKeys,
|
|
204
|
+
fileKind = 'content',
|
|
205
|
+
} = {}) => {
|
|
206
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
207
|
+
|
|
208
|
+
for (const [index, line] of lines.entries()) {
|
|
209
|
+
const lineNumber = index + 1;
|
|
210
|
+
const trimmed = line.trim();
|
|
211
|
+
|
|
212
|
+
if (!trimmed || trimmed.startsWith('#') || frontmatterDelimiterRegex.test(line)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { indent, hasInvalidWhitespace } = getIndentInfo(line);
|
|
217
|
+
if (hasInvalidWhitespace || indent !== 0) continue;
|
|
218
|
+
|
|
219
|
+
const keyMatch = line.match(/^([A-Za-z][A-Za-z0-9-]*):/);
|
|
220
|
+
if (!keyMatch) continue;
|
|
221
|
+
|
|
222
|
+
const key = keyMatch[1];
|
|
223
|
+
if (knownTopLevelFrontmatterKeys.has(key)) continue;
|
|
224
|
+
|
|
225
|
+
const fix = knownNestedFrontmatterKeys.has(key)
|
|
226
|
+
? `Indent "${key}:" under the section or object it belongs to. For gallery rows, "gallery:" normally belongs under a "sections" item.`
|
|
227
|
+
: `Move "${key}:" under the correct parent key, or remove it if it is not part of the ${fileKind} schema.`;
|
|
228
|
+
|
|
229
|
+
addIssue({
|
|
230
|
+
severity: 'error',
|
|
231
|
+
message: `Frontmatter line ${lineNumber} defines "${key}" at the top level, but it is not a valid top-level ${fileKind} field.`,
|
|
232
|
+
fix,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const validateContentFrontmatterStructure = (frontmatter, addIssue) =>
|
|
238
|
+
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
239
|
+
knownTopLevelFrontmatterKeys: knownContentTopLevelFrontmatterKeys,
|
|
240
|
+
fileKind: 'content',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
export const validateThemeFrontmatterStructure = (frontmatter, addIssue) =>
|
|
244
|
+
validateFrontmatterStructure(frontmatter, addIssue, {
|
|
245
|
+
knownTopLevelFrontmatterKeys: knownThemeTopLevelFrontmatterKeys,
|
|
246
|
+
fileKind: 'theme',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export const readThemeFile = async (sitePath) => readSiteFile(sitePath, siteThemeLabel);
|
|
250
|
+
|
|
251
|
+
export const getFrontmatterSections = (frontmatter) => {
|
|
252
|
+
const sections = [];
|
|
253
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
254
|
+
let inSections = false;
|
|
255
|
+
let currentSection = null;
|
|
256
|
+
|
|
257
|
+
for (const [index, line] of lines.entries()) {
|
|
258
|
+
if (/^sections:\s*$/.test(line)) {
|
|
259
|
+
inSections = true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!inSections) continue;
|
|
264
|
+
if (/^[a-zA-Z0-9_-]+:/.test(line)) break;
|
|
265
|
+
|
|
266
|
+
const sectionMatch = line.match(/^\s{2}-\s+id:\s*([a-z0-9-]+)\s*$/);
|
|
267
|
+
if (sectionMatch) {
|
|
268
|
+
currentSection = { id: sectionMatch[1], images: [], imageReferences: [], carousels: [] };
|
|
269
|
+
sections.push(currentSection);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const carouselMatch = line.match(/^\s{6}-\s+carousel:\s*$/);
|
|
274
|
+
if (carouselMatch && currentSection) {
|
|
275
|
+
currentSection.carousels.push({ images: [], imageReferences: [], line: index + 1 });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const imageMatch = line.match(/^\s{6,}-\s+image:\s*["']?([^"'\n]+)["']?\s*$/);
|
|
280
|
+
if (imageMatch && currentSection) {
|
|
281
|
+
const image = imageMatch[1].trim();
|
|
282
|
+
const isCarouselImage = /^\s{10}-\s+image:/.test(line);
|
|
283
|
+
const carousel = isCarouselImage ? currentSection.carousels.at(-1) : null;
|
|
284
|
+
currentSection.images.push(image);
|
|
285
|
+
currentSection.imageReferences.push({ image, line: index + 1 });
|
|
286
|
+
if (carousel) {
|
|
287
|
+
carousel.images.push(image);
|
|
288
|
+
carousel.imageReferences.push({ image, line: index + 1 });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return sections;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export const getFrontmatterInlineStyleNames = (frontmatter) => {
|
|
297
|
+
const names = new Set();
|
|
298
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
299
|
+
let inlineStylesIndent = null;
|
|
300
|
+
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
const inlineStylesMatch = line.match(/^(\s*)inlineStyles:\s*$/);
|
|
303
|
+
|
|
304
|
+
if (inlineStylesMatch) {
|
|
305
|
+
inlineStylesIndent = inlineStylesMatch[1].length;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (inlineStylesIndent === null) continue;
|
|
310
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
311
|
+
|
|
312
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
313
|
+
if (indent <= inlineStylesIndent) {
|
|
314
|
+
inlineStylesIndent = null;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const nameMatch = line.match(new RegExp(`^\\s{${inlineStylesIndent + 2}}([a-z][a-z0-9-]*):\\s*$`));
|
|
319
|
+
if (nameMatch) {
|
|
320
|
+
names.add(nameMatch[1]);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return names;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const getInlineStyleReferences = (body) => Array.from(body.matchAll(inlineStyleReferenceRegex))
|
|
328
|
+
.map((match) => match[1]);
|
|
329
|
+
|
|
330
|
+
export const getHeadingId = (heading) => heading.match(explicitHeadingIdRegex)?.[1];
|
|
331
|
+
|
|
332
|
+
export const getBodySections = (body) => {
|
|
333
|
+
const matches = Array.from(body.matchAll(h2Regex));
|
|
334
|
+
const prelude = matches.length > 0 ? body.slice(0, matches[0].index) : body;
|
|
335
|
+
const sections = [];
|
|
336
|
+
|
|
337
|
+
for (let index = 0; index < matches.length; index += 1) {
|
|
338
|
+
const match = matches[index];
|
|
339
|
+
const start = match.index ?? 0;
|
|
340
|
+
const next = matches[index + 1];
|
|
341
|
+
const end = next?.index ?? body.length;
|
|
342
|
+
const text = body.slice(start, end).trimEnd();
|
|
343
|
+
const heading = match[0];
|
|
344
|
+
const id = getHeadingId(heading);
|
|
345
|
+
|
|
346
|
+
sections.push({ id, heading, text });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { prelude, sections };
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export const getImageFiles = async (directory) => {
|
|
353
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
354
|
+
if (error?.code === 'ENOENT') {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
throw error;
|
|
359
|
+
});
|
|
360
|
+
const files = [];
|
|
361
|
+
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
const entryPath = path.join(directory, entry.name);
|
|
364
|
+
|
|
365
|
+
if (entry.isDirectory()) {
|
|
366
|
+
files.push(...await getImageFiles(entryPath));
|
|
367
|
+
} else if (entry.isFile() && supportedImageExtensions.has(path.extname(entry.name).toLowerCase())) {
|
|
368
|
+
files.push(entryPath);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return files;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
export const getImageIndex = async (contentDir, fail) => {
|
|
376
|
+
const imageFiles = await getImageFiles(contentDir);
|
|
377
|
+
const imagesByName = new Map();
|
|
378
|
+
|
|
379
|
+
for (const imagePath of imageFiles) {
|
|
380
|
+
const imageName = path.basename(imagePath);
|
|
381
|
+
const existingPath = imagesByName.get(imageName);
|
|
382
|
+
|
|
383
|
+
if (existingPath) {
|
|
384
|
+
fail(`Duplicate image filename "${imageName}" found at ${siteImagesLabel}/${toPosixPath(path.relative(contentDir, existingPath))} and ${siteImagesLabel}/${toPosixPath(path.relative(contentDir, imagePath))}. Image filenames must be globally unique.`);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
imagesByName.set(imageName, imagePath);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return imagesByName;
|
|
392
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const siteDirectoryEnvName = 'NORNA_SITE_DIR';
|
|
6
|
+
const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
|
|
7
|
+
const defaultSiteDirectory = 'site';
|
|
8
|
+
|
|
9
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
10
|
+
const currentDirectory = path.dirname(currentFile);
|
|
11
|
+
|
|
12
|
+
export const engineRoot = path.resolve(currentDirectory, '..', '..');
|
|
13
|
+
export const siteDirectoryEnv = siteDirectoryEnvName;
|
|
14
|
+
|
|
15
|
+
const normalizeSiteDirectory = (value) => String(value ?? '').trim();
|
|
16
|
+
const normalizeInvocationRoot = (value) => String(value ?? '').trim();
|
|
17
|
+
|
|
18
|
+
const configuredInvocationRoot = normalizeInvocationRoot(process.env[invocationRootEnvName]);
|
|
19
|
+
export const invocationRoot = configuredInvocationRoot
|
|
20
|
+
? path.resolve(configuredInvocationRoot)
|
|
21
|
+
: process.cwd();
|
|
22
|
+
|
|
23
|
+
const hasSiteFiles = (projectRoot, siteDirectory) => {
|
|
24
|
+
const siteDir = path.resolve(projectRoot, siteDirectory);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
existsSync(path.join(siteDir, 'config.mjs'))
|
|
28
|
+
&& existsSync(path.join(siteDir, 'content.md'))
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const findSiteProjectRoot = (startDirectory, siteDirectory) => {
|
|
33
|
+
let current = path.resolve(startDirectory);
|
|
34
|
+
|
|
35
|
+
while (true) {
|
|
36
|
+
if (hasSiteFiles(current, siteDirectory)) {
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parent = path.dirname(current);
|
|
41
|
+
if (parent === current) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
current = parent;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const hasSiteDirectoryEnv = Object.hasOwn(process.env, siteDirectoryEnvName);
|
|
50
|
+
const configuredSiteDirectory = normalizeSiteDirectory(process.env[siteDirectoryEnvName]);
|
|
51
|
+
const hasConfiguredSiteDirectory = hasSiteDirectoryEnv && configuredSiteDirectory !== '';
|
|
52
|
+
const fallbackSiteDirectory = hasConfiguredSiteDirectory ? configuredSiteDirectory : defaultSiteDirectory;
|
|
53
|
+
|
|
54
|
+
if (hasSiteDirectoryEnv && !configuredSiteDirectory) {
|
|
55
|
+
throw new Error(`${siteDirectoryEnvName} must not be empty.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resolveSitePaths = () => {
|
|
59
|
+
if (path.isAbsolute(fallbackSiteDirectory)) {
|
|
60
|
+
const siteDir = path.resolve(fallbackSiteDirectory);
|
|
61
|
+
const siteProjectRoot = path.dirname(siteDir);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
siteDirectory: path.relative(siteProjectRoot, siteDir) || path.basename(siteDir),
|
|
65
|
+
siteDir,
|
|
66
|
+
siteProjectRoot,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const discoveredRoot = findSiteProjectRoot(invocationRoot, fallbackSiteDirectory);
|
|
71
|
+
const siteProjectRoot = discoveredRoot ?? invocationRoot;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
siteDirectory: fallbackSiteDirectory,
|
|
75
|
+
siteDir: path.resolve(siteProjectRoot, fallbackSiteDirectory),
|
|
76
|
+
siteProjectRoot,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const resolvedSitePaths = resolveSitePaths();
|
|
81
|
+
|
|
82
|
+
export const siteProjectRoot = resolvedSitePaths.siteProjectRoot;
|
|
83
|
+
export const root = siteProjectRoot;
|
|
84
|
+
export const siteDirectory = resolvedSitePaths.siteDirectory;
|
|
85
|
+
|
|
86
|
+
if (!siteDirectory) {
|
|
87
|
+
throw new Error(`${siteDirectoryEnvName} must not be empty.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
|
|
91
|
+
const getPathLabel = (filePath) => {
|
|
92
|
+
const relativePath = path.relative(siteProjectRoot, filePath);
|
|
93
|
+
|
|
94
|
+
if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
|
|
95
|
+
return toPosixPath(relativePath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return toPosixPath(filePath);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const siteDir = resolvedSitePaths.siteDir;
|
|
102
|
+
export const siteConfigPath = path.join(siteDir, 'config.mjs');
|
|
103
|
+
export const siteThemePath = path.join(siteDir, 'theme.md');
|
|
104
|
+
export const siteContentPath = path.join(siteDir, 'content.md');
|
|
105
|
+
export const siteImagesDir = path.join(siteDir, 'images');
|
|
106
|
+
export const siteRoutesDir = path.join(siteDir, 'routes');
|
|
107
|
+
export const sitePublicDir = path.join(siteDir, 'public');
|
|
108
|
+
export const siteStateDir = path.join(siteDir, '.norna');
|
|
109
|
+
export const astroPublicDir = path.join(siteStateDir, 'public');
|
|
110
|
+
export const astroDistDir = path.join(siteProjectRoot, 'dist');
|
|
111
|
+
export const astroCacheDir = path.join(siteProjectRoot, '.astro');
|
|
112
|
+
export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
|
|
113
|
+
export const originalImagesDir = path.join(astroPublicDir, 'images', 'original');
|
|
114
|
+
export const generatedImagesManifestPath = path.join(siteStateDir, 'generated-images.json');
|
|
115
|
+
|
|
116
|
+
export const engineRootLabel = getPathLabel(engineRoot);
|
|
117
|
+
export const invocationRootLabel = getPathLabel(invocationRoot);
|
|
118
|
+
export const siteProjectRootLabel = getPathLabel(siteProjectRoot);
|
|
119
|
+
export const siteDirLabel = getPathLabel(siteDir);
|
|
120
|
+
export const siteConfigLabel = getPathLabel(siteConfigPath);
|
|
121
|
+
export const siteThemeLabel = getPathLabel(siteThemePath);
|
|
122
|
+
export const siteContentLabel = getPathLabel(siteContentPath);
|
|
123
|
+
export const siteImagesLabel = getPathLabel(siteImagesDir);
|
|
124
|
+
export const siteRoutesLabel = getPathLabel(siteRoutesDir);
|
|
125
|
+
export const sitePublicLabel = getPathLabel(sitePublicDir);
|
|
126
|
+
export const astroPublicLabel = getPathLabel(astroPublicDir);
|
|
127
|
+
export const generatedImagesManifestLabel = getPathLabel(generatedImagesManifestPath);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export const typographyPresetNames = [
|
|
2
|
+
'quiet-gallery',
|
|
3
|
+
'compact-gallery',
|
|
4
|
+
'text-forward',
|
|
5
|
+
'statement',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export const typographyPresets = {
|
|
9
|
+
'quiet-gallery': {
|
|
10
|
+
heading: {
|
|
11
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
12
|
+
size: 'medium',
|
|
13
|
+
lineHeight: 1.08,
|
|
14
|
+
spacing: '0.65em',
|
|
15
|
+
},
|
|
16
|
+
body: {
|
|
17
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
18
|
+
size: 'medium',
|
|
19
|
+
lineHeight: 1.5,
|
|
20
|
+
paragraphSpacing: '0.85em',
|
|
21
|
+
},
|
|
22
|
+
caption: {
|
|
23
|
+
align: { desktop: 'center', mobile: 'center' },
|
|
24
|
+
size: 'small',
|
|
25
|
+
lineHeight: 1.35,
|
|
26
|
+
spacing: '0.5em',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
'compact-gallery': {
|
|
30
|
+
heading: {
|
|
31
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
32
|
+
size: 'small',
|
|
33
|
+
lineHeight: 1.08,
|
|
34
|
+
spacing: '0.45em',
|
|
35
|
+
},
|
|
36
|
+
body: {
|
|
37
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
38
|
+
size: 'small',
|
|
39
|
+
lineHeight: 1.42,
|
|
40
|
+
paragraphSpacing: '0.6em',
|
|
41
|
+
},
|
|
42
|
+
caption: {
|
|
43
|
+
align: { desktop: 'center', mobile: 'center' },
|
|
44
|
+
size: 'small',
|
|
45
|
+
lineHeight: 1.25,
|
|
46
|
+
spacing: '0.35em',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
'text-forward': {
|
|
50
|
+
heading: {
|
|
51
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
52
|
+
size: 'medium',
|
|
53
|
+
lineHeight: 1.12,
|
|
54
|
+
spacing: '0.8em',
|
|
55
|
+
},
|
|
56
|
+
body: {
|
|
57
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
58
|
+
size: 'large',
|
|
59
|
+
lineHeight: 1.62,
|
|
60
|
+
paragraphSpacing: '1em',
|
|
61
|
+
},
|
|
62
|
+
caption: {
|
|
63
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
64
|
+
size: 'small',
|
|
65
|
+
lineHeight: 1.4,
|
|
66
|
+
spacing: '0.6em',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
statement: {
|
|
70
|
+
heading: {
|
|
71
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
72
|
+
size: 'large',
|
|
73
|
+
lineHeight: 1.04,
|
|
74
|
+
spacing: '0.5em',
|
|
75
|
+
},
|
|
76
|
+
body: {
|
|
77
|
+
align: { desktop: 'left', mobile: 'left' },
|
|
78
|
+
size: 'large',
|
|
79
|
+
lineHeight: 1.42,
|
|
80
|
+
paragraphSpacing: '0.75em',
|
|
81
|
+
},
|
|
82
|
+
caption: {
|
|
83
|
+
align: { desktop: 'center', mobile: 'center' },
|
|
84
|
+
size: 'small',
|
|
85
|
+
lineHeight: 1.3,
|
|
86
|
+
spacing: '0.45em',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const defaultTypography = {
|
|
92
|
+
preset: 'quiet-gallery',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isPlainObject = (value) => (
|
|
96
|
+
value !== null &&
|
|
97
|
+
typeof value === 'object' &&
|
|
98
|
+
!Array.isArray(value)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
export const mergeDeep = (base, override) => {
|
|
102
|
+
if (!isPlainObject(override)) return structuredClone(base);
|
|
103
|
+
|
|
104
|
+
const merged = structuredClone(base);
|
|
105
|
+
|
|
106
|
+
for (const [key, value] of Object.entries(override)) {
|
|
107
|
+
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
|
108
|
+
merged[key] = mergeDeep(merged[key], value);
|
|
109
|
+
} else if (value !== undefined) {
|
|
110
|
+
merged[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return merged;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const resolveTypographyConfig = (typography = defaultTypography) => {
|
|
118
|
+
const presetName = typography?.preset ?? defaultTypography.preset;
|
|
119
|
+
const preset = typographyPresets[presetName];
|
|
120
|
+
|
|
121
|
+
if (!preset) {
|
|
122
|
+
throw new Error(`Unknown typography preset: ${presetName}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
preset: presetName,
|
|
127
|
+
values: mergeDeep(preset, typography?.overrides),
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const resolveTypographyOverride = (baseResolved, typographyConfig) => {
|
|
132
|
+
if (typographyConfig?.preset) {
|
|
133
|
+
return resolveTypographyConfig(typographyConfig);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typographyConfig?.overrides) {
|
|
137
|
+
return {
|
|
138
|
+
preset: baseResolved.preset,
|
|
139
|
+
values: mergeDeep(baseResolved.values, typographyConfig.overrides),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return baseResolved;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const resolveSectionTypography = (defaultTypographyConfig, sectionTypographyConfig) => {
|
|
147
|
+
const defaultResolved = resolveTypographyConfig(defaultTypographyConfig);
|
|
148
|
+
|
|
149
|
+
return resolveTypographyOverride(defaultResolved, sectionTypographyConfig);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const quoteString = (value) => typeof value === 'string' && !/^[a-z0-9.-]+$/i.test(value)
|
|
153
|
+
? JSON.stringify(value)
|
|
154
|
+
: value;
|
|
155
|
+
|
|
156
|
+
export const toYamlLines = (value, indent = 0) => {
|
|
157
|
+
const prefix = ' '.repeat(indent);
|
|
158
|
+
|
|
159
|
+
return Object.entries(value).flatMap(([key, entry]) => {
|
|
160
|
+
if (isPlainObject(entry)) {
|
|
161
|
+
return [`${prefix}${key}:`, ...toYamlLines(entry, indent + 2)];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return `${prefix}${key}: ${quoteString(entry)}`;
|
|
165
|
+
});
|
|
166
|
+
};
|