Migrate to Vite / Vite+
Modernize building and such
This commit is contained in:
262
scripts/convert.ts
Normal file
262
scripts/convert.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const IMAGE_ROOT = "img";
|
||||
const ORIGINAL_DIR = path.join(IMAGE_ROOT, "original");
|
||||
const DATA_FILE = path.join(IMAGE_ROOT, "data.json");
|
||||
const JPG_QUALITY = 30;
|
||||
const JSON_WIDTH = 100;
|
||||
const PLACEHOLDER_LOCATION = "TODO location";
|
||||
const PLACEHOLDER_DESCRIPTION = "TODO description";
|
||||
|
||||
const IMAGE_SIZES = [2400, 1600, 1200, 800, 600, 400, 200] as const;
|
||||
const WEBP_QUALITIES = new Map<number, number>([
|
||||
[2400, 50],
|
||||
[1600, 50],
|
||||
[1200, 50],
|
||||
[800, 40],
|
||||
[600, 40],
|
||||
[400, 40],
|
||||
[200, 40],
|
||||
]);
|
||||
const AVIF_QUALITIES = new Map<number, number>([
|
||||
[2400, 70],
|
||||
[1600, 70],
|
||||
[1200, 70],
|
||||
[800, 60],
|
||||
[600, 60],
|
||||
[400, 50],
|
||||
[200, 50],
|
||||
]);
|
||||
|
||||
interface ManifestImage {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ImageSet {
|
||||
location: string;
|
||||
description: string;
|
||||
images: ManifestImage[];
|
||||
}
|
||||
|
||||
interface GalleryData {
|
||||
sets: ImageSet[];
|
||||
}
|
||||
|
||||
const runCommand = async (command: string, args: string[]): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}\n${stderr}`.trim()));
|
||||
});
|
||||
});
|
||||
|
||||
const exists = async (target: string): Promise<boolean> => {
|
||||
try {
|
||||
await stat(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const identifyImage = async (filename: string): Promise<ManifestImage> => {
|
||||
const output = await runCommand("magick", ["identify", "-format", "%f,%w,%h", filename]);
|
||||
const [src, width, height] = output.trim().split(",");
|
||||
return {
|
||||
src,
|
||||
width: Number(width),
|
||||
height: Number(height),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeImage = async (
|
||||
source: string,
|
||||
output: string,
|
||||
size: number,
|
||||
quality: number,
|
||||
): Promise<void> => {
|
||||
if (await exists(output)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
console.log(`Converting ${output}`);
|
||||
await runCommand("magick", [
|
||||
source,
|
||||
"-resize",
|
||||
`${size}x${size}`,
|
||||
"-quality",
|
||||
`${quality}`,
|
||||
output,
|
||||
]);
|
||||
};
|
||||
|
||||
const hashOriginal = async (source: string): Promise<string> => {
|
||||
const contents = await readFile(source);
|
||||
return createHash("md5").update(contents).digest("hex");
|
||||
};
|
||||
|
||||
const sourceFiles = async (): Promise<string[]> => {
|
||||
const entries = await readdir(ORIGINAL_DIR, { withFileTypes: true });
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".jpg"))
|
||||
.map((entry) => path.join(ORIGINAL_DIR, entry.name))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const processOriginal = async (source: string): Promise<ManifestImage> => {
|
||||
const hash = await hashOriginal(source);
|
||||
const hashedName = `${hash}.jpg`;
|
||||
const hashedPath = path.join(IMAGE_ROOT, hashedName);
|
||||
|
||||
if (!(await exists(hashedPath))) {
|
||||
console.log(`Copying ${hashedPath}`);
|
||||
await cp(source, hashedPath);
|
||||
}
|
||||
|
||||
const metadata = await identifyImage(hashedPath);
|
||||
|
||||
const conversions: Promise<void>[] = [];
|
||||
for (const size of IMAGE_SIZES) {
|
||||
conversions.push(
|
||||
resizeImage(hashedPath, path.join(IMAGE_ROOT, `${size}`, hashedName), size, JPG_QUALITY),
|
||||
);
|
||||
conversions.push(
|
||||
resizeImage(
|
||||
hashedPath,
|
||||
path.join(IMAGE_ROOT, `${size}`, hashedName.replace(/\.jpg$/i, ".webp")),
|
||||
size,
|
||||
WEBP_QUALITIES.get(size) ?? 40,
|
||||
),
|
||||
);
|
||||
conversions.push(
|
||||
resizeImage(
|
||||
hashedPath,
|
||||
path.join(IMAGE_ROOT, `${size}`, hashedName.replace(/\.jpg$/i, ".avif")),
|
||||
size,
|
||||
AVIF_QUALITIES.get(size) ?? 50,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(conversions);
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const readGalleryData = async (): Promise<GalleryData> => {
|
||||
const contents = await readFile(DATA_FILE, "utf8");
|
||||
return JSON.parse(contents) as GalleryData;
|
||||
};
|
||||
|
||||
const findNewImages = (existing: GalleryData, images: ManifestImage[]): ManifestImage[] => {
|
||||
const known = new Set(existing.sets.flatMap((set) => set.images.map((image) => image.src)));
|
||||
|
||||
return images.filter((image) => !known.has(image.src));
|
||||
};
|
||||
|
||||
const indent = (depth: number): string => " ".repeat(depth);
|
||||
|
||||
const formatImage = (image: ManifestImage, depth: number): string =>
|
||||
`${indent(depth)}{ "src": "${image.src}", "width": ${image.width}, "height": ${image.height} }`;
|
||||
|
||||
const formatSet = (set: ImageSet, depth: number): string => {
|
||||
const inline = `{ "location": ${JSON.stringify(set.location)}, "description": ${JSON.stringify(
|
||||
set.description,
|
||||
)}, "images": [] }`;
|
||||
if (set.images.length === 0 && indent(depth).length + inline.length <= JSON_WIDTH) {
|
||||
return `${indent(depth)}${inline}`;
|
||||
}
|
||||
|
||||
return [
|
||||
`${indent(depth)}{`,
|
||||
`${indent(depth + 1)}"location": ${JSON.stringify(set.location)},`,
|
||||
`${indent(depth + 1)}"description": ${JSON.stringify(set.description)},`,
|
||||
`${indent(depth + 1)}"images": [`,
|
||||
set.images
|
||||
.map((image, index) => {
|
||||
const suffix = index === set.images.length - 1 ? "" : ",";
|
||||
return `${formatImage(image, depth + 2)}${suffix}`;
|
||||
})
|
||||
.join("\n"),
|
||||
`${indent(depth + 1)}]`,
|
||||
`${indent(depth)}}`,
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const formatGalleryData = (data: GalleryData): string => {
|
||||
const sets = data.sets
|
||||
.map((set, index) => {
|
||||
const suffix = index === data.sets.length - 1 ? "" : ",";
|
||||
return `${formatSet(set, 2)}${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `{\n "sets": [\n${sets}\n ]\n}\n`;
|
||||
};
|
||||
|
||||
const prependPlaceholderSet = async (images: ManifestImage[]): Promise<void> => {
|
||||
const existing = await readGalleryData();
|
||||
const newImages = findNewImages(existing, images);
|
||||
|
||||
if (newImages.length === 0) {
|
||||
console.log("No new images to add to img/data.json");
|
||||
return;
|
||||
}
|
||||
|
||||
existing.sets.unshift({
|
||||
location: PLACEHOLDER_LOCATION,
|
||||
description: PLACEHOLDER_DESCRIPTION,
|
||||
images: newImages,
|
||||
});
|
||||
|
||||
await writeFile(DATA_FILE, formatGalleryData(existing), "utf8");
|
||||
console.log(`Prepended ${newImages.length} new images to ${DATA_FILE}`);
|
||||
};
|
||||
|
||||
export const convert = async (): Promise<void> => {
|
||||
const originals = await sourceFiles();
|
||||
const manifest: ManifestImage[] = [];
|
||||
|
||||
for (const original of originals) {
|
||||
manifest.push(await processOriginal(original));
|
||||
}
|
||||
|
||||
await prependPlaceholderSet(manifest);
|
||||
};
|
||||
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
|
||||
if (entrypoint === import.meta.url) {
|
||||
convert().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
246
scripts/publish.ts
Normal file
246
scripts/publish.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
CloudFrontClient,
|
||||
CreateInvalidationCommand,
|
||||
GetDistributionConfigCommand,
|
||||
UpdateDistributionCommand,
|
||||
} from "@aws-sdk/client-cloudfront";
|
||||
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { fromIni } from "@aws-sdk/credential-providers";
|
||||
import mime from "mime-types";
|
||||
|
||||
const BUCKET = "ski.aarongutierrez.com";
|
||||
const DISTRIBUTION = "ELFNI2LSHJUNK";
|
||||
const DIST_DIR = "dist";
|
||||
const IMAGE_DIR = "img";
|
||||
const AWS_PROFILE = "push";
|
||||
|
||||
const credentials = fromIni({ profile: AWS_PROFILE });
|
||||
|
||||
const s3 = new S3Client({ credentials });
|
||||
const cloudfront = new CloudFrontClient({ credentials });
|
||||
|
||||
let existingKeysPromise: Promise<Set<string>> | null = null;
|
||||
|
||||
const currentKeys = async (): Promise<Set<string>> => {
|
||||
if (existingKeysPromise) {
|
||||
return existingKeysPromise;
|
||||
}
|
||||
|
||||
existingKeysPromise = (async () => {
|
||||
console.log(`Fetching existing keys in ${BUCKET}`);
|
||||
const keys = new Set<string>();
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await s3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: BUCKET,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const content of response.Contents ?? []) {
|
||||
if (content.Key) {
|
||||
keys.add(content.Key);
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
return keys;
|
||||
})();
|
||||
|
||||
return existingKeysPromise;
|
||||
};
|
||||
|
||||
const contentTypeFor = (filename: string): string => {
|
||||
const contentType = mime.lookup(filename);
|
||||
if (!contentType) {
|
||||
throw new Error(`Unknown content type for ${filename}`);
|
||||
}
|
||||
|
||||
if (contentType.startsWith("text/html")) {
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
|
||||
if (contentType.startsWith("text/plain")) {
|
||||
return "text/plain; charset=utf-8";
|
||||
}
|
||||
|
||||
return contentType;
|
||||
};
|
||||
|
||||
const cacheControlFor = (key: string): string => {
|
||||
if (key === "index.html") {
|
||||
return "no-cache, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
if (key.startsWith("assets/")) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
if (key.startsWith("img/")) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
return "public, max-age=3600";
|
||||
};
|
||||
|
||||
const uploadFile = async (filename: string, key: string, overwrite: boolean): Promise<void> => {
|
||||
if (!overwrite) {
|
||||
const keys = await currentKeys();
|
||||
if (keys.has(key)) {
|
||||
console.log(`Skipping existing key ${key}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Uploading ${filename} to ${BUCKET}/${key}`);
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
ACL: "public-read",
|
||||
Body: createReadStream(filename),
|
||||
Bucket: BUCKET,
|
||||
CacheControl: cacheControlFor(key),
|
||||
ContentType: contentTypeFor(filename),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
console.log("\tDone.");
|
||||
};
|
||||
|
||||
const walkFiles = async (root: string): Promise<string[]> => {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name === ".DS_Store") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(root, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkFiles(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const uploadTree = async (root: string, keyPrefix: string, overwrite: boolean): Promise<void> => {
|
||||
const files = await walkFiles(root);
|
||||
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).split(path.sep).join("/");
|
||||
if (root === IMAGE_DIR && relative === "data.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = keyPrefix ? `${keyPrefix}/${relative}` : relative;
|
||||
await uploadFile(file, key, overwrite);
|
||||
}
|
||||
};
|
||||
|
||||
const runCommand = async (command: string, args: string[]): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
const buildApp = async (): Promise<void> => {
|
||||
await runCommand("vp", ["build"]);
|
||||
};
|
||||
|
||||
const ensureDefaultRoot = async (): Promise<void> => {
|
||||
const response = await cloudfront.send(new GetDistributionConfigCommand({ Id: DISTRIBUTION }));
|
||||
|
||||
if (!response.DistributionConfig || !response.ETag) {
|
||||
throw new Error("Missing CloudFront distribution config");
|
||||
}
|
||||
|
||||
if (response.DistributionConfig.DefaultRootObject === "index.html") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Setting default root object to index.html on ${DISTRIBUTION}`);
|
||||
await cloudfront.send(
|
||||
new UpdateDistributionCommand({
|
||||
DistributionConfig: {
|
||||
...response.DistributionConfig,
|
||||
DefaultRootObject: "index.html",
|
||||
},
|
||||
Id: DISTRIBUTION,
|
||||
IfMatch: response.ETag,
|
||||
}),
|
||||
);
|
||||
console.log("\tDone.");
|
||||
};
|
||||
|
||||
const invalidateRoot = async (): Promise<void> => {
|
||||
console.log(`Invalidating / and /index.html on ${DISTRIBUTION}`);
|
||||
await cloudfront.send(
|
||||
new CreateInvalidationCommand({
|
||||
DistributionId: DISTRIBUTION,
|
||||
InvalidationBatch: {
|
||||
CallerReference: `${Date.now()}`,
|
||||
Paths: {
|
||||
Items: ["/", "/index.html"],
|
||||
Quantity: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("\tDone.");
|
||||
};
|
||||
|
||||
const ensureDirectory = async (directory: string): Promise<void> => {
|
||||
const info = await stat(directory);
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`${directory} is not a directory`);
|
||||
}
|
||||
};
|
||||
|
||||
export const publish = async (): Promise<void> => {
|
||||
await buildApp();
|
||||
await ensureDirectory(DIST_DIR);
|
||||
await ensureDirectory(IMAGE_DIR);
|
||||
await uploadTree(DIST_DIR, "", true);
|
||||
await uploadTree(IMAGE_DIR, "img", false);
|
||||
await ensureDefaultRoot();
|
||||
await invalidateRoot();
|
||||
};
|
||||
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
|
||||
if (entrypoint === import.meta.url) {
|
||||
publish().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user