@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,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
astroCacheDir,
|
|
3
|
+
astroDistDir,
|
|
4
|
+
astroPublicDir,
|
|
5
|
+
engineRoot,
|
|
6
|
+
generatedImagesManifestPath,
|
|
7
|
+
invocationRoot,
|
|
8
|
+
siteConfigPath,
|
|
9
|
+
siteContentPath,
|
|
10
|
+
siteDir,
|
|
11
|
+
siteDirectory,
|
|
12
|
+
siteImagesDir,
|
|
13
|
+
siteProjectRoot,
|
|
14
|
+
sitePublicDir,
|
|
15
|
+
siteRoutesDir,
|
|
16
|
+
siteThemePath,
|
|
17
|
+
} from './lib/site-paths.mjs';
|
|
18
|
+
|
|
19
|
+
const lines = [
|
|
20
|
+
'norna doctor',
|
|
21
|
+
`Invocation root: ${invocationRoot}`,
|
|
22
|
+
`Site project root: ${siteProjectRoot}`,
|
|
23
|
+
`Site directory: ${siteDirectory}`,
|
|
24
|
+
`Site directory path: ${siteDir}`,
|
|
25
|
+
`Site config: ${siteConfigPath}`,
|
|
26
|
+
`Site theme: ${siteThemePath}`,
|
|
27
|
+
`Site content: ${siteContentPath}`,
|
|
28
|
+
`Site images: ${siteImagesDir}`,
|
|
29
|
+
`Site routes: ${siteRoutesDir}`,
|
|
30
|
+
`Site public source: ${sitePublicDir}`,
|
|
31
|
+
`Generated image manifest: ${generatedImagesManifestPath}`,
|
|
32
|
+
`Astro public output: ${astroPublicDir}`,
|
|
33
|
+
`Astro dist output: ${astroDistDir}`,
|
|
34
|
+
`Astro cache: ${astroCacheDir}`,
|
|
35
|
+
`Engine root: ${engineRoot}`,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
console.log(lines.join('\n'));
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
engineRoot,
|
|
8
|
+
engineRootLabel,
|
|
9
|
+
siteProjectRoot,
|
|
10
|
+
siteProjectRootLabel,
|
|
11
|
+
} from './lib/site-paths.mjs';
|
|
12
|
+
|
|
13
|
+
const packageName = '@janga/norna';
|
|
14
|
+
const requireFromEngine = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
const usage = `
|
|
17
|
+
Usage: norna engine:version [--latest]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--latest Also query npm for the latest published norna version
|
|
21
|
+
`.trim();
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const includeLatest = args.includes('--latest');
|
|
25
|
+
const unknownArgs = args.filter((arg) => arg !== '--latest');
|
|
26
|
+
|
|
27
|
+
if (unknownArgs.length > 0) {
|
|
28
|
+
throw new Error(`Unknown option: ${unknownArgs[0]}\n${usage}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const readJson = async (filePath) => JSON.parse(await readFile(filePath, 'utf8'));
|
|
32
|
+
|
|
33
|
+
const readOptionalJson = async (filePath) => {
|
|
34
|
+
try {
|
|
35
|
+
return await readJson(filePath);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error?.code === 'ENOENT') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getPackageDependency = (packageJson) => {
|
|
46
|
+
if (!packageJson) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const dependencyType of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
51
|
+
const dependencyVersion = packageJson[dependencyType]?.[packageName];
|
|
52
|
+
|
|
53
|
+
if (dependencyVersion) {
|
|
54
|
+
return {
|
|
55
|
+
type: dependencyType,
|
|
56
|
+
version: dependencyVersion,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const runCapture = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
65
|
+
const child = spawn(command, args, {
|
|
66
|
+
cwd: siteProjectRoot,
|
|
67
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
+
...options,
|
|
69
|
+
});
|
|
70
|
+
let stdout = '';
|
|
71
|
+
let stderr = '';
|
|
72
|
+
|
|
73
|
+
child.stdout?.on('data', (chunk) => {
|
|
74
|
+
stdout += chunk;
|
|
75
|
+
});
|
|
76
|
+
child.stderr?.on('data', (chunk) => {
|
|
77
|
+
stderr += chunk;
|
|
78
|
+
});
|
|
79
|
+
child.once('error', reject);
|
|
80
|
+
child.once('exit', (code, signal) => {
|
|
81
|
+
if (code === 0) {
|
|
82
|
+
resolve(stdout.trim());
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const commandText = [command, ...args].join(' ');
|
|
87
|
+
reject(new Error(signal
|
|
88
|
+
? `${commandText} exited with signal ${signal}.`
|
|
89
|
+
: `${commandText} exited with code ${code}.\n${stderr.trim()}`));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
94
|
+
const configuredNpmCache = process.env.npm_config_cache ?? process.env.NPM_CONFIG_CACHE;
|
|
95
|
+
const npmCachePath = path.resolve(siteProjectRoot, configuredNpmCache ?? path.join('node_modules', '.cache', 'norna-npm'));
|
|
96
|
+
const sitePackageJson = await readOptionalJson(path.join(siteProjectRoot, 'package.json'));
|
|
97
|
+
const enginePackageJson = await readJson(path.join(engineRoot, 'package.json'));
|
|
98
|
+
const astroPackageJson = await readJson(requireFromEngine.resolve('astro/package.json'));
|
|
99
|
+
const siteDependency = getPackageDependency(sitePackageJson);
|
|
100
|
+
|
|
101
|
+
console.log('norna engine:version');
|
|
102
|
+
console.log(`Site project root: ${siteProjectRootLabel}`);
|
|
103
|
+
|
|
104
|
+
if (siteDependency) {
|
|
105
|
+
console.log(`Site dependency: ${packageName} ${siteDependency.version} (${siteDependency.type})`);
|
|
106
|
+
} else if (sitePackageJson?.name === packageName) {
|
|
107
|
+
console.log('Site dependency: current engine repository');
|
|
108
|
+
} else {
|
|
109
|
+
console.log(`Site dependency: ${packageName} not declared`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(`Installed norna: ${enginePackageJson.version}`);
|
|
113
|
+
console.log(`Engine root: ${engineRootLabel}`);
|
|
114
|
+
console.log(`Astro dependency: ${enginePackageJson.dependencies?.astro ?? 'not declared'}`);
|
|
115
|
+
console.log(`Installed Astro: ${astroPackageJson.version}`);
|
|
116
|
+
|
|
117
|
+
if (includeLatest) {
|
|
118
|
+
try {
|
|
119
|
+
await mkdir(npmCachePath, { recursive: true });
|
|
120
|
+
const latestVersion = await runCapture(npmBin, [
|
|
121
|
+
'view',
|
|
122
|
+
packageName,
|
|
123
|
+
'version',
|
|
124
|
+
'--registry=https://registry.npmjs.org/',
|
|
125
|
+
'--fetch-retries=0',
|
|
126
|
+
], {
|
|
127
|
+
env: {
|
|
128
|
+
...process.env,
|
|
129
|
+
npm_config_cache: npmCachePath,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
console.log(`npm latest: ${latestVersion}`);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
+
console.log(`npm latest: unavailable (${message})`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import {
|
|
7
|
+
getFrontmatterSections,
|
|
8
|
+
getContentFiles,
|
|
9
|
+
getImageIndex,
|
|
10
|
+
readSiteFile,
|
|
11
|
+
supportedImageExtensions,
|
|
12
|
+
} from './lib/site-content.mjs';
|
|
13
|
+
import {
|
|
14
|
+
astroPublicDir,
|
|
15
|
+
generatedImagesDir,
|
|
16
|
+
generatedImagesManifestPath,
|
|
17
|
+
originalImagesDir,
|
|
18
|
+
siteImagesDir,
|
|
19
|
+
} from './lib/site-paths.mjs';
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
const imageOutputVersion = 2;
|
|
24
|
+
const sourceHashSlugLength = 8;
|
|
25
|
+
const widths = [480, 768, 1080, 1440, 1920];
|
|
26
|
+
|
|
27
|
+
const run = async (command, args) => {
|
|
28
|
+
const { stdout } = await execFileAsync(command, args, { maxBuffer: 1024 * 1024 * 10 });
|
|
29
|
+
return stdout.trim();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const canRun = async (command, args = ['-version']) => {
|
|
33
|
+
try {
|
|
34
|
+
await run(command, args);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getImageMagick = async () => {
|
|
42
|
+
if (await canRun('magick')) {
|
|
43
|
+
return {
|
|
44
|
+
identify: (sourcePath) => run('magick', ['identify', '-format', '%w %h', sourcePath]),
|
|
45
|
+
convert: (sourcePath, outputPath, width) => run('magick', [
|
|
46
|
+
sourcePath,
|
|
47
|
+
'-auto-orient',
|
|
48
|
+
'-resize',
|
|
49
|
+
`${width}x`,
|
|
50
|
+
'-strip',
|
|
51
|
+
'-quality',
|
|
52
|
+
'82',
|
|
53
|
+
outputPath,
|
|
54
|
+
]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (await canRun('identify') && await canRun('convert')) {
|
|
59
|
+
return {
|
|
60
|
+
identify: (sourcePath) => run('identify', ['-format', '%w %h', sourcePath]),
|
|
61
|
+
convert: (sourcePath, outputPath, width) => run('convert', [
|
|
62
|
+
sourcePath,
|
|
63
|
+
'-auto-orient',
|
|
64
|
+
'-resize',
|
|
65
|
+
`${width}x`,
|
|
66
|
+
'-strip',
|
|
67
|
+
'-quality',
|
|
68
|
+
'82',
|
|
69
|
+
outputPath,
|
|
70
|
+
]),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
throw new Error('ImageMagick is missing. Install either the "magick" command or both "identify" and "convert".');
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const toPublicPath = (filePath) => filePath.split(path.sep).join('/');
|
|
78
|
+
const getPublicPath = (filePath) => `/${toPublicPath(path.relative(astroPublicDir, filePath))}`;
|
|
79
|
+
const getFilePathFromPublicPath = (publicPath) => path.join(astroPublicDir, publicPath.replace(/^\//, ''));
|
|
80
|
+
|
|
81
|
+
const getSourceHashSlug = (sourceHash) => sourceHash.slice(0, sourceHashSlugLength);
|
|
82
|
+
|
|
83
|
+
const getGeneratedPath = (sourcePath, sourceHash, width) => {
|
|
84
|
+
const parsed = path.parse(path.relative(siteImagesDir, sourcePath).startsWith('..')
|
|
85
|
+
? path.relative(path.dirname(siteImagesDir), sourcePath)
|
|
86
|
+
: path.relative(siteImagesDir, sourcePath));
|
|
87
|
+
return path.join(generatedImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const fail = (message) => {
|
|
91
|
+
throw new Error(message);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const getReferencedImages = async (contentFile) => {
|
|
95
|
+
const { frontmatter } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
96
|
+
const sections = getFrontmatterSections(frontmatter);
|
|
97
|
+
const references = [];
|
|
98
|
+
|
|
99
|
+
for (const section of sections) {
|
|
100
|
+
const imageReferences = section.imageReferences ?? section.images.map((image) => ({ image }));
|
|
101
|
+
for (const { image, line } of imageReferences) {
|
|
102
|
+
references.push({
|
|
103
|
+
contentFile,
|
|
104
|
+
image,
|
|
105
|
+
line,
|
|
106
|
+
sectionId: section.id,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return references;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const getContentSourcePath = ({ image, line }, imageIndex) => {
|
|
115
|
+
if (image.includes('/') || image.includes('\\') || image.startsWith('/')) {
|
|
116
|
+
fail(`Image reference must be a filename without a directory: ${image}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const extension = path.extname(image).toLowerCase();
|
|
120
|
+
if (!supportedImageExtensions.has(extension)) {
|
|
121
|
+
fail(`Image reference uses an unsupported file type: ${image}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sourcePath = imageIndex.get(image);
|
|
125
|
+
|
|
126
|
+
if (!sourcePath) {
|
|
127
|
+
fail(`Image file "${image}" does not exist anywhere under the page images directory.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return sourcePath;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const getReferencedSources = async () => {
|
|
134
|
+
const contentFiles = await getContentFiles();
|
|
135
|
+
const seen = new Map();
|
|
136
|
+
const sources = [];
|
|
137
|
+
|
|
138
|
+
for (const contentFile of contentFiles) {
|
|
139
|
+
const references = await getReferencedImages(contentFile);
|
|
140
|
+
const imageIndex = await getImageIndex(contentFile.imagesDir, fail);
|
|
141
|
+
|
|
142
|
+
for (const reference of references) {
|
|
143
|
+
const sourcePath = getContentSourcePath(reference, imageIndex);
|
|
144
|
+
const siteImagePath = toPublicPath(path.relative(contentFile.imagesDir, sourcePath));
|
|
145
|
+
const imageName = path.basename(sourcePath);
|
|
146
|
+
|
|
147
|
+
if (seen.has(imageName)) {
|
|
148
|
+
const firstReference = seen.get(imageName);
|
|
149
|
+
fail(`Image reference "${imageName}" appears more than once, in ${firstReference.contentFile.contentLabel} line ${firstReference.line} and ${reference.contentFile.contentLabel} line ${reference.line}.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const fileStat = await stat(sourcePath).catch(() => null);
|
|
153
|
+
if (!fileStat?.isFile()) {
|
|
154
|
+
fail(`Image file does not exist: ${contentFile.imagesLabel}/${siteImagePath}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const currentDirectory = path.basename(path.dirname(sourcePath));
|
|
158
|
+
if (reference.sectionId && currentDirectory !== reference.sectionId) {
|
|
159
|
+
fail(`Image "${imageName}" is used in section "${reference.sectionId}" but is located in ${contentFile.imagesLabel}/${currentDirectory}/. Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
seen.set(imageName, reference);
|
|
163
|
+
sources.push(sourcePath);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return sources.sort();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const getOrientation = ({ width, height }) => {
|
|
171
|
+
if (width === height) return 'square';
|
|
172
|
+
return width > height ? 'landscape' : 'portrait';
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const validateCarouselOrientations = (sections, manifest) => {
|
|
176
|
+
for (const section of sections) {
|
|
177
|
+
for (const carousel of section.carousels) {
|
|
178
|
+
const orientations = new Set(
|
|
179
|
+
carousel.images
|
|
180
|
+
.map((image) => manifest[image])
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.map(getOrientation)
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
orientations.delete('square');
|
|
186
|
+
if (orientations.size > 1) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Carousel in section "${section.id}" on line ${carousel.line} mixes landscape and portrait images. Use landscape images with optional square images, or portrait images with optional square images.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const identify = async (sourcePath) => {
|
|
196
|
+
const output = await imageMagick.identify(sourcePath);
|
|
197
|
+
const [width, height] = output.split(' ').map(Number);
|
|
198
|
+
return { width, height };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const convert = async (sourcePath, outputPath, width) => {
|
|
202
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
203
|
+
await imageMagick.convert(sourcePath, outputPath, width);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const getHash = (value) => createHash('sha256').update(value).digest('hex');
|
|
207
|
+
|
|
208
|
+
const getSourceHash = async (sourcePath) => {
|
|
209
|
+
const file = await readFile(sourcePath);
|
|
210
|
+
return getHash(file);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const getVariantWidths = ({ width }) => {
|
|
214
|
+
const variantWidths = widths.filter((candidateWidth) => candidateWidth <= width);
|
|
215
|
+
|
|
216
|
+
if (!variantWidths.includes(width)) {
|
|
217
|
+
variantWidths.push(width);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return variantWidths;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const getVariants = (sourcePath, sourceHash, variantWidths) => variantWidths.map((width) => ({
|
|
224
|
+
src: getPublicPath(getGeneratedPath(sourcePath, sourceHash, width)),
|
|
225
|
+
width,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const readManifest = async () => {
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(await readFile(generatedImagesManifestPath, 'utf8'));
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (error?.code === 'ENOENT') {
|
|
233
|
+
return {};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const fileExists = async (filePath) => {
|
|
241
|
+
const fileStat = await stat(filePath).catch(() => null);
|
|
242
|
+
return fileStat?.isFile() ?? false;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const hasGeneratedVariants = async (variants) => {
|
|
246
|
+
for (const variant of variants) {
|
|
247
|
+
if (!(await fileExists(getFilePathFromPublicPath(variant.src)))) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return true;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const getReusableEntry = async (sourcePath, previousEntry, sourceHash) => {
|
|
256
|
+
if (
|
|
257
|
+
previousEntry?.sourceHash !== sourceHash
|
|
258
|
+
|| previousEntry?.outputVersion !== imageOutputVersion
|
|
259
|
+
|| !Number.isFinite(previousEntry?.width)
|
|
260
|
+
|| !Number.isFinite(previousEntry?.height)
|
|
261
|
+
) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const variants = getVariants(sourcePath, sourceHash, getVariantWidths(previousEntry));
|
|
266
|
+
|
|
267
|
+
if (!(await hasGeneratedVariants(variants))) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
outputVersion: imageOutputVersion,
|
|
273
|
+
sourceHash,
|
|
274
|
+
width: previousEntry.width,
|
|
275
|
+
height: previousEntry.height,
|
|
276
|
+
variants,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const listGeneratedFiles = async (directory) => {
|
|
281
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
282
|
+
if (error?.code === 'ENOENT') {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
throw error;
|
|
287
|
+
});
|
|
288
|
+
const files = [];
|
|
289
|
+
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
const entryPath = path.join(directory, entry.name);
|
|
292
|
+
|
|
293
|
+
if (entry.isDirectory()) {
|
|
294
|
+
files.push(...await listGeneratedFiles(entryPath));
|
|
295
|
+
} else if (entry.isFile()) {
|
|
296
|
+
files.push(entryPath);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return files;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const removeUnreferencedGeneratedFiles = async (manifest) => {
|
|
304
|
+
const expectedFiles = new Set(
|
|
305
|
+
Object.values(manifest)
|
|
306
|
+
.flatMap((entry) => entry.variants ?? [])
|
|
307
|
+
.map((variant) => getFilePathFromPublicPath(variant.src)),
|
|
308
|
+
);
|
|
309
|
+
const generatedFiles = await listGeneratedFiles(generatedImagesDir);
|
|
310
|
+
|
|
311
|
+
for (const generatedFile of generatedFiles) {
|
|
312
|
+
if (!expectedFiles.has(generatedFile)) {
|
|
313
|
+
await rm(generatedFile, { force: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
await rm(originalImagesDir, { recursive: true, force: true });
|
|
319
|
+
await mkdir(generatedImagesDir, { recursive: true });
|
|
320
|
+
|
|
321
|
+
const contentFiles = await getContentFiles();
|
|
322
|
+
const frontmatterSections = (await Promise.all(contentFiles.map(async (contentFile) => {
|
|
323
|
+
const { frontmatter } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
|
|
324
|
+
return getFrontmatterSections(frontmatter);
|
|
325
|
+
}))).flat();
|
|
326
|
+
const sources = await getReferencedSources();
|
|
327
|
+
const previousManifest = await readManifest();
|
|
328
|
+
const manifest = {};
|
|
329
|
+
|
|
330
|
+
if (sources.length === 0) {
|
|
331
|
+
await removeUnreferencedGeneratedFiles(manifest);
|
|
332
|
+
await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
|
|
333
|
+
await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const imageMagick = await getImageMagick();
|
|
338
|
+
|
|
339
|
+
for (const sourcePath of sources) {
|
|
340
|
+
const imageName = path.basename(sourcePath);
|
|
341
|
+
const sourceHash = await getSourceHash(sourcePath);
|
|
342
|
+
const reusableEntry = await getReusableEntry(sourcePath, previousManifest[imageName], sourceHash);
|
|
343
|
+
|
|
344
|
+
if (reusableEntry) {
|
|
345
|
+
manifest[imageName] = reusableEntry;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const dimensions = await identify(sourcePath);
|
|
350
|
+
const variantWidths = getVariantWidths(dimensions);
|
|
351
|
+
const variants = getVariants(sourcePath, sourceHash, variantWidths);
|
|
352
|
+
for (const width of variantWidths) {
|
|
353
|
+
const outputPath = getGeneratedPath(sourcePath, sourceHash, width);
|
|
354
|
+
await convert(sourcePath, outputPath, width);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
manifest[imageName] = {
|
|
358
|
+
outputVersion: imageOutputVersion,
|
|
359
|
+
sourceHash,
|
|
360
|
+
width: dimensions.width,
|
|
361
|
+
height: dimensions.height,
|
|
362
|
+
variants,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
validateCarouselOrientations(frontmatterSections, manifest);
|
|
367
|
+
await removeUnreferencedGeneratedFiles(manifest);
|
|
368
|
+
await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
|
|
369
|
+
await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|