feat: define closed portfolio input contract
This commit is contained in:
12
src/content/asset-url.ts
Normal file
12
src/content/asset-url.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { PublicAssetManifest } from './types.ts';
|
||||||
|
|
||||||
|
export const assetUrl = (manifest: PublicAssetManifest, assetId: string, base = '/assets/'): string => {
|
||||||
|
if (!base.startsWith('/') || base.startsWith('//') || /[?#]/u.test(base)) {
|
||||||
|
throw new Error('Asset URL base must be an absolute path without a query or fragment.');
|
||||||
|
}
|
||||||
|
const asset = manifest.assets.find((candidate) => candidate.id === assetId);
|
||||||
|
if (asset === undefined) {
|
||||||
|
throw new Error(`Unknown asset ID: ${assetId}`);
|
||||||
|
}
|
||||||
|
return `${base.endsWith('/') ? base : `${base}/`}${encodeURIComponent(asset.fileName)}`;
|
||||||
|
};
|
||||||
170
src/content/load-production-inputs.ts
Normal file
170
src/content/load-production-inputs.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { lstat, readFile, readdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { validateProductionInputs } from './schemas.ts';
|
||||||
|
import type { ProductionInputs } from './types.ts';
|
||||||
|
|
||||||
|
export interface ProductionInputLocations {
|
||||||
|
contentJson: URL;
|
||||||
|
assetManifestJson: URL;
|
||||||
|
preparedAssetRoot: URL;
|
||||||
|
siteConfigJson: URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertPlainFileUrl = (url: URL, label: string): string => {
|
||||||
|
if (url.protocol !== 'file:' || url.search || url.hash) {
|
||||||
|
throw new Error(`${label} must be a plain file URL.`);
|
||||||
|
}
|
||||||
|
return fileURLToPath(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertRegularFile = async (path: string, label: string): Promise<void> => {
|
||||||
|
const stats = await lstat(path);
|
||||||
|
if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) {
|
||||||
|
throw new Error(`${label} must be one regular, non-linked file.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipWhitespace = (text: string, index: number): number => {
|
||||||
|
let next = index;
|
||||||
|
while (/\s/u.test(text[next] ?? '')) next += 1;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readJsonString = (text: string, index: number): { value: string; next: number } => {
|
||||||
|
if (text[index] !== '"') throw new Error('Invalid JSON string.');
|
||||||
|
let cursor = index + 1;
|
||||||
|
while (cursor < text.length) {
|
||||||
|
if (text[cursor] === '\\') {
|
||||||
|
cursor += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text[cursor] === '"') {
|
||||||
|
const source = text.slice(index, cursor + 1);
|
||||||
|
return { value: JSON.parse(source) as string, next: cursor + 1 };
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
throw new Error('Unterminated JSON string.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const findJsonValueEnd = (text: string, index: number): number => {
|
||||||
|
const cursor = skipWhitespace(text, index);
|
||||||
|
const character = text[cursor];
|
||||||
|
if (character === '"') return readJsonString(text, cursor).next;
|
||||||
|
if (character === '{') {
|
||||||
|
let next = skipWhitespace(text, cursor + 1);
|
||||||
|
const keys = new Set<string>();
|
||||||
|
if (text[next] === '}') return next + 1;
|
||||||
|
while (next < text.length) {
|
||||||
|
const key = readJsonString(text, next);
|
||||||
|
if (keys.has(key.value)) throw new Error(`Duplicate JSON key: ${key.value}`);
|
||||||
|
keys.add(key.value);
|
||||||
|
next = skipWhitespace(text, key.next);
|
||||||
|
if (text[next] !== ':') throw new Error('Invalid JSON object.');
|
||||||
|
next = skipWhitespace(text, findJsonValueEnd(text, next + 1));
|
||||||
|
if (text[next] === '}') return next + 1;
|
||||||
|
if (text[next] !== ',') throw new Error('Invalid JSON object.');
|
||||||
|
next = skipWhitespace(text, next + 1);
|
||||||
|
}
|
||||||
|
throw new Error('Unterminated JSON object.');
|
||||||
|
}
|
||||||
|
if (character === '[') {
|
||||||
|
let next = skipWhitespace(text, cursor + 1);
|
||||||
|
if (text[next] === ']') return next + 1;
|
||||||
|
while (next < text.length) {
|
||||||
|
next = skipWhitespace(text, findJsonValueEnd(text, next));
|
||||||
|
if (text[next] === ']') return next + 1;
|
||||||
|
if (text[next] !== ',') throw new Error('Invalid JSON array.');
|
||||||
|
next = skipWhitespace(text, next + 1);
|
||||||
|
}
|
||||||
|
throw new Error('Unterminated JSON array.');
|
||||||
|
}
|
||||||
|
let next = cursor;
|
||||||
|
while (next < text.length && !/[\s,}\]]/u.test(text[next] ?? '')) next += 1;
|
||||||
|
if (next === cursor) throw new Error('Invalid JSON value.');
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJsonWithoutDuplicateKeys = (source: string): unknown => {
|
||||||
|
const end = skipWhitespace(source, findJsonValueEnd(source, 0));
|
||||||
|
if (end !== source.length) throw new Error('Invalid JSON input.');
|
||||||
|
return JSON.parse(source) as unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readExplicitJson = async (url: URL, label: string): Promise<unknown> => {
|
||||||
|
const path = assertPlainFileUrl(url, label);
|
||||||
|
await assertRegularFile(path, label);
|
||||||
|
return parseJsonWithoutDuplicateKeys(await readFile(path, 'utf8'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
|
||||||
|
|
||||||
|
const validatePreparedAssetSet = async (rootUrl: URL, inputs: ProductionInputs): Promise<void> => {
|
||||||
|
const rootPath = assertPlainFileUrl(rootUrl, 'Prepared asset root');
|
||||||
|
const rootStats = await lstat(rootPath);
|
||||||
|
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
||||||
|
throw new Error('Prepared asset root must be a non-symlink directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestByFileName = new Map(inputs.manifest.assets.map((asset) => [asset.fileName, asset]));
|
||||||
|
const expectedCaseFolded = new Set<string>();
|
||||||
|
for (const asset of inputs.manifest.assets) {
|
||||||
|
const folded = asset.fileName.toLocaleLowerCase('en-US');
|
||||||
|
if (expectedCaseFolded.has(folded)) throw new Error('Manifest has a case-folding collision.');
|
||||||
|
expectedCaseFolded.add(folded);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedNames = new Set<string>();
|
||||||
|
for (const entry of await readdir(rootPath, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) throw new Error(`Prepared asset set may not contain directories: ${entry.name}`);
|
||||||
|
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`Prepared asset is a symlink or special file: ${entry.name}`);
|
||||||
|
const filePath = join(rootPath, entry.name);
|
||||||
|
const stats = await lstat(filePath);
|
||||||
|
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) {
|
||||||
|
throw new Error(`Prepared asset is a symlink, hard link, or special file: ${entry.name}`);
|
||||||
|
}
|
||||||
|
const foldedName = entry.name.toLocaleLowerCase('en-US');
|
||||||
|
if (preparedNames.has(foldedName)) throw new Error(`Prepared asset has a case-fold collision: ${entry.name}`);
|
||||||
|
preparedNames.add(foldedName);
|
||||||
|
const manifestAsset = manifestByFileName.get(entry.name);
|
||||||
|
if (manifestAsset === undefined) throw new Error(`Extra prepared asset: ${entry.name}`);
|
||||||
|
if (sha256(await readFile(filePath)) !== manifestAsset.sha256) {
|
||||||
|
throw new Error(`Prepared asset hash does not match manifest: ${entry.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const asset of inputs.manifest.assets) {
|
||||||
|
if (!preparedNames.has(asset.fileName.toLocaleLowerCase('en-US'))) {
|
||||||
|
throw new Error(`Missing prepared asset: ${asset.fileName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deepFreeze = <T>(value: T, seen = new WeakSet<object>()): T => {
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
const object = value as object;
|
||||||
|
if (!seen.has(object)) {
|
||||||
|
seen.add(object);
|
||||||
|
for (const key of Reflect.ownKeys(object)) {
|
||||||
|
deepFreeze(Reflect.get(object, key), seen);
|
||||||
|
}
|
||||||
|
Object.freeze(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadProductionInputs = async (
|
||||||
|
locations: ProductionInputLocations,
|
||||||
|
): Promise<Readonly<ProductionInputs>> => {
|
||||||
|
const [content, manifest, site] = await Promise.all([
|
||||||
|
readExplicitJson(locations.contentJson, 'Portfolio content'),
|
||||||
|
readExplicitJson(locations.assetManifestJson, 'Public asset manifest'),
|
||||||
|
readExplicitJson(locations.siteConfigJson, 'Site config'),
|
||||||
|
]);
|
||||||
|
const inputs = validateProductionInputs(content, manifest, site, locations.preparedAssetRoot);
|
||||||
|
await validatePreparedAssetSet(locations.preparedAssetRoot, inputs);
|
||||||
|
return deepFreeze(inputs);
|
||||||
|
};
|
||||||
224
src/content/schemas.ts
Normal file
224
src/content/schemas.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { PortfolioContent, PublicAssetManifest, ProductionInputs, SiteConfig } from './types.ts';
|
||||||
|
|
||||||
|
const canonicalNavigationIds = [
|
||||||
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
|
] as const;
|
||||||
|
const socialHosts = {
|
||||||
|
GitHub: 'github.com',
|
||||||
|
GitLab: 'gitlab.com',
|
||||||
|
LinkedIn: 'linkedin.com',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const hasVisibleCopy = (value: string): boolean =>
|
||||||
|
value.trim().length > 0 && value === value.normalize('NFC') && !/[\u0000-\u001f\u007f]/u.test(value);
|
||||||
|
const visibleString = z.string().refine(hasVisibleCopy, 'Visible copy must be non-empty, NFC-normalized, and control-character free.');
|
||||||
|
const opaqueId = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, 'IDs must be generated safe identifiers.');
|
||||||
|
const httpUrl = z.string().refine((value) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, 'URL must be an absolute HTTPS URL without credentials, query, or fragment.');
|
||||||
|
|
||||||
|
const navigationItemSchema = z.object({
|
||||||
|
id: z.enum(canonicalNavigationIds),
|
||||||
|
label: visibleString,
|
||||||
|
}).strict();
|
||||||
|
const projectSchema = z.object({
|
||||||
|
id: opaqueId,
|
||||||
|
title: visibleString,
|
||||||
|
visibility: z.enum(['public', 'private']),
|
||||||
|
maturity: z.enum(['learning', 'experimental', 'prototype', 'work-in-progress', 'active']),
|
||||||
|
summary: visibleString,
|
||||||
|
problem: visibleString,
|
||||||
|
contribution: visibleString,
|
||||||
|
technicalDecisions: z.array(visibleString).min(1),
|
||||||
|
outcome: visibleString,
|
||||||
|
url: httpUrl.optional(),
|
||||||
|
assetIds: z.array(opaqueId),
|
||||||
|
}).strict().superRefine((project, context) => {
|
||||||
|
if (project.visibility === 'private' && project.url !== undefined) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'A private project may not include a url.', path: ['url'] });
|
||||||
|
}
|
||||||
|
if (project.visibility === 'private' && project.title !== 'Private project') {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Private project title must be generic.', path: ['title'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const portfolioContentSchema = z.object({
|
||||||
|
identity: z.object({
|
||||||
|
name: z.literal('Luca Pîslaru'),
|
||||||
|
role: z.literal('System Administrator & Full-Stack Developer'),
|
||||||
|
valueProposition: visibleString,
|
||||||
|
}).strict(),
|
||||||
|
navigation: z.array(navigationItemSchema).length(8),
|
||||||
|
profile: z.object({
|
||||||
|
introduction: visibleString,
|
||||||
|
screeningFacts: z.array(z.object({ label: visibleString, value: visibleString }).strict()),
|
||||||
|
}).strict(),
|
||||||
|
experience: z.array(z.object({
|
||||||
|
id: opaqueId,
|
||||||
|
role: visibleString,
|
||||||
|
organization: visibleString,
|
||||||
|
dates: visibleString,
|
||||||
|
location: visibleString.optional(),
|
||||||
|
highlights: z.array(visibleString).min(2).max(4),
|
||||||
|
}).strict()).min(1),
|
||||||
|
systemsOperations: z.object({
|
||||||
|
id: opaqueId,
|
||||||
|
title: z.literal('Professional systems operations'),
|
||||||
|
summary: visibleString,
|
||||||
|
evidence: z.array(visibleString).min(1),
|
||||||
|
}).strict(),
|
||||||
|
infrastructure: z.object({
|
||||||
|
id: opaqueId,
|
||||||
|
title: z.literal('Secure self-hosted platform and lifecycle'),
|
||||||
|
summary: visibleString,
|
||||||
|
bands: z.array(z.object({ id: opaqueId, title: visibleString, summary: visibleString }).strict()).length(4),
|
||||||
|
}).strict(),
|
||||||
|
projects: z.object({
|
||||||
|
selected: z.array(projectSchema).length(3),
|
||||||
|
additional: z.array(projectSchema).min(1),
|
||||||
|
}).strict(),
|
||||||
|
toolkit: z.array(z.object({
|
||||||
|
title: visibleString,
|
||||||
|
technologies: z.array(z.object({ name: visibleString, assetId: opaqueId }).strict()).min(1),
|
||||||
|
}).strict()).min(1),
|
||||||
|
contact: z.object({
|
||||||
|
email: z.email(),
|
||||||
|
social: z.array(z.object({
|
||||||
|
service: z.enum(['GitHub', 'GitLab', 'LinkedIn']),
|
||||||
|
url: httpUrl,
|
||||||
|
}).strict()).superRefine((links, context) => {
|
||||||
|
for (const [index, link] of links.entries()) {
|
||||||
|
const host = new URL(link.url).hostname;
|
||||||
|
if (host !== socialHosts[link.service]) {
|
||||||
|
context.addIssue({ code: 'custom', message: `Social URL must use the approved ${link.service} host.`, path: [index, 'url'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
cvMessage: visibleString,
|
||||||
|
}).strict(),
|
||||||
|
metadata: z.object({
|
||||||
|
title: visibleString,
|
||||||
|
description: visibleString,
|
||||||
|
socialPreviewAssetId: opaqueId,
|
||||||
|
}).strict(),
|
||||||
|
sceneDescription: visibleString,
|
||||||
|
}).strict().superRefine((content, context) => {
|
||||||
|
const navigationIds = content.navigation.map((item) => item.id);
|
||||||
|
if (new Set(navigationIds).size !== canonicalNavigationIds.length ||
|
||||||
|
canonicalNavigationIds.some((id) => !navigationIds.includes(id))) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Navigation must contain each canonical section ID exactly once.', path: ['navigation'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = [...content.projects.selected, ...content.projects.additional];
|
||||||
|
const projectIds = projects.map((project) => project.id);
|
||||||
|
if (new Set(projectIds).size !== projectIds.length) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Project IDs must be unique.', path: ['projects'] });
|
||||||
|
}
|
||||||
|
if (content.projects.selected.filter((project) => project.maturity === 'experimental').length !== 1) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Selected builds must include exactly one supporting experimental research panel.', path: ['projects', 'selected'] });
|
||||||
|
}
|
||||||
|
const experienceIds = content.experience.map((record) => record.id);
|
||||||
|
if (new Set(experienceIds).size !== experienceIds.length) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Experience IDs must be unique.', path: ['experience'] });
|
||||||
|
}
|
||||||
|
const bandIds = content.infrastructure.bands.map((band) => band.id);
|
||||||
|
if (new Set(bandIds).size !== bandIds.length) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Infrastructure band IDs must be unique.', path: ['infrastructure', 'bands'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaTypes = ['image/avif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp', 'video/mp4', 'video/webm'] as const;
|
||||||
|
const fileExtensionByMediaType: Record<(typeof mediaTypes)[number], string> = {
|
||||||
|
'image/avif': 'avif',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/svg+xml': 'svg',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
};
|
||||||
|
const publicAssetSchema = z.object({
|
||||||
|
id: opaqueId,
|
||||||
|
fileName: z.string().regex(/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/, 'Asset filenames must be generated safe filenames.'),
|
||||||
|
sha256: z.string().regex(/^[a-f0-9]{64}$/, 'Asset sha256 must be lowercase hexadecimal.'),
|
||||||
|
mediaType: z.enum(mediaTypes),
|
||||||
|
width: z.number().int().positive().nullable(),
|
||||||
|
height: z.number().int().positive().nullable(),
|
||||||
|
durationMs: z.number().int().positive().nullable(),
|
||||||
|
rightsHolder: visibleString,
|
||||||
|
license: visibleString,
|
||||||
|
attribution: visibleString,
|
||||||
|
}).strict().superRefine((asset, context) => {
|
||||||
|
if (!asset.fileName.endsWith(`.${fileExtensionByMediaType[asset.mediaType]}`)) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Asset filename extension must match media type.', path: ['fileName'] });
|
||||||
|
}
|
||||||
|
const image = asset.mediaType.startsWith('image/');
|
||||||
|
if (image && (asset.width === null || asset.height === null || asset.durationMs !== null)) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Image assets require dimensions and no duration.', path: ['width'] });
|
||||||
|
}
|
||||||
|
if (!image && (asset.width === null || asset.height === null || asset.durationMs === null)) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Video assets require dimensions and duration.', path: ['durationMs'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const publicAssetManifestSchema = z.object({ assets: z.array(publicAssetSchema).min(1) }).strict().superRefine((manifest, context) => {
|
||||||
|
const ids = manifest.assets.map((asset) => asset.id);
|
||||||
|
if (new Set(ids).size !== ids.length) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Duplicate asset ID in manifest.', path: ['assets'] });
|
||||||
|
}
|
||||||
|
const filenames = manifest.assets.map((asset) => asset.fileName);
|
||||||
|
if (new Set(filenames).size !== filenames.length || new Set(filenames.map((name) => name.toLocaleLowerCase('en-US'))).size !== filenames.length) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Asset filenames must be unique without case-fold collisions.', path: ['assets'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteConfigSchema = z.object({
|
||||||
|
environment: z.enum(['preview', 'production']),
|
||||||
|
canonicalUrl: httpUrl,
|
||||||
|
buildId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,127}$/, 'Build ID must be a safe immutable identifier.'),
|
||||||
|
}).strict().superRefine((site, context) => {
|
||||||
|
const url = new URL(site.canonicalUrl);
|
||||||
|
if (url.pathname !== '/') {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Canonical URL must not contain a path.', path: ['canonicalUrl'] });
|
||||||
|
}
|
||||||
|
const previewHost = url.hostname === 'localhost' || url.hostname.endsWith('.localhost') || url.hostname.endsWith('.test');
|
||||||
|
if (site.environment === 'preview' && !previewHost) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Preview canonical URL must use a localhost or .test host.', path: ['canonicalUrl'] });
|
||||||
|
}
|
||||||
|
if (site.environment === 'production' && previewHost) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Production canonical URL may not use a preview host.', path: ['canonicalUrl'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const parsePortfolioContent = (input: unknown): PortfolioContent => portfolioContentSchema.parse(input) as PortfolioContent;
|
||||||
|
export const parsePublicAssetManifest = (input: unknown): PublicAssetManifest => publicAssetManifestSchema.parse(input) as PublicAssetManifest;
|
||||||
|
export const parseSiteConfig = (input: unknown): SiteConfig => siteConfigSchema.parse(input) as SiteConfig;
|
||||||
|
|
||||||
|
export const validateProductionInputs = (
|
||||||
|
contentInput: unknown,
|
||||||
|
manifestInput: unknown,
|
||||||
|
siteInput: unknown,
|
||||||
|
_preparedAssetRoot: URL,
|
||||||
|
): ProductionInputs => {
|
||||||
|
const content = parsePortfolioContent(contentInput);
|
||||||
|
const manifest = parsePublicAssetManifest(manifestInput);
|
||||||
|
const site = parseSiteConfig(siteInput);
|
||||||
|
const manifestIds = new Set(manifest.assets.map((asset) => asset.id));
|
||||||
|
const referencedAssetIds = [
|
||||||
|
...content.projects.selected.flatMap((project) => project.assetIds),
|
||||||
|
...content.projects.additional.flatMap((project) => project.assetIds),
|
||||||
|
...content.toolkit.flatMap((group) => group.technologies.map((technology) => technology.assetId)),
|
||||||
|
content.metadata.socialPreviewAssetId,
|
||||||
|
];
|
||||||
|
for (const assetId of referencedAssetIds) {
|
||||||
|
if (!manifestIds.has(assetId)) {
|
||||||
|
throw new Error(`Unmanifested asset reference: ${assetId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { content, manifest, site };
|
||||||
|
};
|
||||||
138
src/content/types.ts
Normal file
138
src/content/types.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
export type SectionId =
|
||||||
|
| 'start'
|
||||||
|
| 'profile'
|
||||||
|
| 'experience'
|
||||||
|
| 'infrastructure'
|
||||||
|
| 'selected-builds'
|
||||||
|
| 'additional-work'
|
||||||
|
| 'toolkit'
|
||||||
|
| 'contact';
|
||||||
|
|
||||||
|
export type ProjectVisibility = 'public' | 'private';
|
||||||
|
export type ProjectMaturity = 'learning' | 'experimental' | 'prototype' | 'work-in-progress' | 'active';
|
||||||
|
|
||||||
|
export interface ExperienceRecord {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
organization: string;
|
||||||
|
dates: string;
|
||||||
|
location?: string;
|
||||||
|
highlights: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemsCaseStudy {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
evidence: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfrastructureEvidenceBand {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfrastructureCaseStudy {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
bands: readonly InfrastructureEvidenceBand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicProject {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
visibility: ProjectVisibility;
|
||||||
|
maturity: ProjectMaturity;
|
||||||
|
summary: string;
|
||||||
|
problem: string;
|
||||||
|
contribution: string;
|
||||||
|
technicalDecisions: readonly string[];
|
||||||
|
outcome: string;
|
||||||
|
url?: string;
|
||||||
|
assetIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolkitTechnology {
|
||||||
|
name: string;
|
||||||
|
assetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolkitGroup {
|
||||||
|
title: string;
|
||||||
|
technologies: readonly ToolkitTechnology[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSocialLink {
|
||||||
|
service: 'GitHub' | 'GitLab' | 'LinkedIn';
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactContent {
|
||||||
|
email: string;
|
||||||
|
social: readonly ContactSocialLink[];
|
||||||
|
cvMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataContent {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
socialPreviewAssetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioContent {
|
||||||
|
identity: {
|
||||||
|
name: 'Luca Pîslaru';
|
||||||
|
role: 'System Administrator & Full-Stack Developer';
|
||||||
|
valueProposition: string;
|
||||||
|
};
|
||||||
|
navigation: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||||
|
profile: { introduction: string; screeningFacts: ReadonlyArray<{ label: string; value: string }> };
|
||||||
|
experience: readonly ExperienceRecord[];
|
||||||
|
systemsOperations: SystemsCaseStudy;
|
||||||
|
infrastructure: InfrastructureCaseStudy;
|
||||||
|
projects: { selected: readonly PublicProject[]; additional: readonly PublicProject[] };
|
||||||
|
toolkit: readonly ToolkitGroup[];
|
||||||
|
contact: ContactContent;
|
||||||
|
metadata: MetadataContent;
|
||||||
|
sceneDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicAssetMediaType =
|
||||||
|
| 'image/avif'
|
||||||
|
| 'image/jpeg'
|
||||||
|
| 'image/png'
|
||||||
|
| 'image/svg+xml'
|
||||||
|
| 'image/webp'
|
||||||
|
| 'video/mp4'
|
||||||
|
| 'video/webm';
|
||||||
|
|
||||||
|
export interface PublicAsset {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
sha256: string;
|
||||||
|
mediaType: PublicAssetMediaType;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
rightsHolder: string;
|
||||||
|
license: string;
|
||||||
|
attribution: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicAssetManifest {
|
||||||
|
assets: readonly PublicAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteConfig {
|
||||||
|
environment: 'preview' | 'production';
|
||||||
|
canonicalUrl: string;
|
||||||
|
buildId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionInputs {
|
||||||
|
content: PortfolioContent;
|
||||||
|
manifest: PublicAssetManifest;
|
||||||
|
site: SiteConfig;
|
||||||
|
}
|
||||||
9
tests/fixtures/asset-manifest.valid.json
vendored
Normal file
9
tests/fixtures/asset-manifest.valid.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
{ "id": "selected-build-one", "fileName": "selected-build-one.webp", "sha256": "623e4a6498ce0604d88667669b732afdc42bec62b50929d73380e7cc717fede4", "mediaType": "image/webp", "width": 1, "height": 1, "durationMs": null, "rightsHolder": "Example Rights Holder", "license": "CC0-1.0", "attribution": "Example Rights Holder" },
|
||||||
|
{ "id": "selected-build-two", "fileName": "selected-build-two.webp", "sha256": "623e4a6498ce0604d88667669b732afdc42bec62b50929d73380e7cc717fede4", "mediaType": "image/webp", "width": 1, "height": 1, "durationMs": null, "rightsHolder": "Example Rights Holder", "license": "CC0-1.0", "attribution": "Example Rights Holder" },
|
||||||
|
{ "id": "research-panel", "fileName": "research-panel.webp", "sha256": "623e4a6498ce0604d88667669b732afdc42bec62b50929d73380e7cc717fede4", "mediaType": "image/webp", "width": 1, "height": 1, "durationMs": null, "rightsHolder": "Example Rights Holder", "license": "CC0-1.0", "attribution": "Example Rights Holder" },
|
||||||
|
{ "id": "toolkit-linux", "fileName": "toolkit-linux.webp", "sha256": "623e4a6498ce0604d88667669b732afdc42bec62b50929d73380e7cc717fede4", "mediaType": "image/webp", "width": 1, "height": 1, "durationMs": null, "rightsHolder": "Example Rights Holder", "license": "CC0-1.0", "attribution": "Example Rights Holder" },
|
||||||
|
{ "id": "social-preview", "fileName": "social-preview.webp", "sha256": "623e4a6498ce0604d88667669b732afdc42bec62b50929d73380e7cc717fede4", "mediaType": "image/webp", "width": 1, "height": 1, "durationMs": null, "rightsHolder": "Example Rights Holder", "license": "CC0-1.0", "attribution": "Example Rights Holder" }
|
||||||
|
]
|
||||||
|
}
|
||||||
114
tests/fixtures/portfolio-content.valid.json
vendored
Normal file
114
tests/fixtures/portfolio-content.valid.json
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"identity": {
|
||||||
|
"name": "Luca Pîslaru",
|
||||||
|
"role": "System Administrator & Full-Stack Developer",
|
||||||
|
"valueProposition": "Building and operating reliable systems and software."
|
||||||
|
},
|
||||||
|
"navigation": [
|
||||||
|
{ "id": "start", "label": "Start" },
|
||||||
|
{ "id": "profile", "label": "Profile" },
|
||||||
|
{ "id": "experience", "label": "Experience" },
|
||||||
|
{ "id": "infrastructure", "label": "Infrastructure" },
|
||||||
|
{ "id": "selected-builds", "label": "Selected builds" },
|
||||||
|
{ "id": "additional-work", "label": "Additional work" },
|
||||||
|
{ "id": "toolkit", "label": "Toolkit" },
|
||||||
|
{ "id": "contact", "label": "Contact" }
|
||||||
|
],
|
||||||
|
"profile": {
|
||||||
|
"introduction": "A systems and software practitioner who learns by building.",
|
||||||
|
"screeningFacts": [{ "label": "Work preference", "value": "Remote" }]
|
||||||
|
},
|
||||||
|
"experience": [{
|
||||||
|
"id": "experience-operations",
|
||||||
|
"role": "Systems Administrator",
|
||||||
|
"organization": "Example Systems",
|
||||||
|
"dates": "Jan 2024 – Present",
|
||||||
|
"location": "Remote",
|
||||||
|
"highlights": ["Operated Linux services.", "Maintained deployment workflows."]
|
||||||
|
}],
|
||||||
|
"systemsOperations": {
|
||||||
|
"id": "systems-operations",
|
||||||
|
"title": "Professional systems operations",
|
||||||
|
"summary": "Verified operational work across service delivery.",
|
||||||
|
"evidence": ["Deployment lifecycle and troubleshooting."]
|
||||||
|
},
|
||||||
|
"infrastructure": {
|
||||||
|
"id": "infrastructure-case-study",
|
||||||
|
"title": "Secure self-hosted platform and lifecycle",
|
||||||
|
"summary": "A compact, privacy-respecting infrastructure case study.",
|
||||||
|
"bands": [
|
||||||
|
{ "id": "ingress-tls", "title": "Ingress and TLS", "summary": "Managed encrypted ingress." },
|
||||||
|
{ "id": "identity-access", "title": "Identity and access", "summary": "Protected service access." },
|
||||||
|
{ "id": "service-lifecycle", "title": "Service lifecycle", "summary": "Maintained representative services." },
|
||||||
|
{ "id": "operations", "title": "Operations", "summary": "Resolved routine service issues." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"selected": [
|
||||||
|
{
|
||||||
|
"id": "private-build-one",
|
||||||
|
"title": "Private project",
|
||||||
|
"visibility": "private",
|
||||||
|
"maturity": "active",
|
||||||
|
"summary": "A private multi-device application.",
|
||||||
|
"problem": "A recurring workflow needed reliable tracking.",
|
||||||
|
"contribution": "Designed and maintained the application.",
|
||||||
|
"technicalDecisions": ["Used a synchronised client and server design."],
|
||||||
|
"outcome": "The work remains private.",
|
||||||
|
"assetIds": ["selected-build-one"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "public-build-two",
|
||||||
|
"title": "Footy Tracker",
|
||||||
|
"visibility": "public",
|
||||||
|
"maturity": "work-in-progress",
|
||||||
|
"summary": "A computer-vision pipeline for football footage.",
|
||||||
|
"problem": "Footage needs repeatable player tracking.",
|
||||||
|
"contribution": "Built the detection and tracking pipeline.",
|
||||||
|
"technicalDecisions": ["Separated detection from jersey-recognition experiments."],
|
||||||
|
"outcome": "Jersey recognition remains ongoing work.",
|
||||||
|
"url": "https://github.com/example/footy-tracker",
|
||||||
|
"assetIds": ["selected-build-two"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "research-panel",
|
||||||
|
"title": "Opinion Dynamics Simulation",
|
||||||
|
"visibility": "public",
|
||||||
|
"maturity": "experimental",
|
||||||
|
"summary": "An experimental agent-based simulation.",
|
||||||
|
"problem": "Explore changing group opinions.",
|
||||||
|
"contribution": "Built the experimental model and charts.",
|
||||||
|
"technicalDecisions": ["Used deterministic simulation runs."],
|
||||||
|
"outcome": "Results are presented as research evidence.",
|
||||||
|
"assetIds": ["research-panel"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additional": [{
|
||||||
|
"id": "additional-public-work",
|
||||||
|
"title": "Currency Converter",
|
||||||
|
"visibility": "public",
|
||||||
|
"maturity": "learning",
|
||||||
|
"summary": "A modest desktop learning project.",
|
||||||
|
"problem": "Practice currency conversion workflows.",
|
||||||
|
"contribution": "Implemented the application.",
|
||||||
|
"technicalDecisions": ["Used a typed desktop UI."],
|
||||||
|
"outcome": "A focused learning exercise.",
|
||||||
|
"assetIds": []
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"toolkit": [{
|
||||||
|
"title": "Systems & delivery",
|
||||||
|
"technologies": [{ "name": "Linux", "assetId": "toolkit-linux" }]
|
||||||
|
}],
|
||||||
|
"contact": {
|
||||||
|
"email": "hello@example.test",
|
||||||
|
"social": [{ "service": "GitHub", "url": "https://github.com/example" }],
|
||||||
|
"cvMessage": "For a current CV or role-specific information, contact me directly."
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"title": "Luca Pîslaru — System Administrator & Full-Stack Developer",
|
||||||
|
"description": "Portfolio of Luca Pîslaru.",
|
||||||
|
"socialPreviewAssetId": "social-preview"
|
||||||
|
},
|
||||||
|
"sceneDescription": "A retro-futuristic night drive that supports the reading journey."
|
||||||
|
}
|
||||||
1
tests/fixtures/prepared-assets.extra/research-panel.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.extra/research-panel.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.extra/selected-build-one.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.extra/selected-build-one.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.extra/selected-build-two.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.extra/selected-build-two.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.extra/social-preview.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.extra/social-preview.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.extra/toolkit-linux.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.extra/toolkit-linux.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.extra/unmanifested-extra.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.extra/unmanifested-extra.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.valid/research-panel.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.valid/research-panel.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.valid/selected-build-one.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.valid/selected-build-one.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.valid/selected-build-two.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.valid/selected-build-two.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.valid/social-preview.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.valid/social-preview.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
1
tests/fixtures/prepared-assets.valid/toolkit-linux.webp
vendored
Normal file
1
tests/fixtures/prepared-assets.valid/toolkit-linux.webp
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synthetic-public-asset
|
||||||
5
tests/fixtures/site-config.preview.json
vendored
Normal file
5
tests/fixtures/site-config.preview.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"environment": "preview",
|
||||||
|
"canonicalUrl": "https://portfolio.example.test",
|
||||||
|
"buildId": "preview-1"
|
||||||
|
}
|
||||||
69
tests/unit/content-schemas.test.ts
Normal file
69
tests/unit/content-schemas.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
parsePortfolioContent,
|
||||||
|
parsePublicAssetManifest,
|
||||||
|
parseSiteConfig,
|
||||||
|
validateProductionInputs,
|
||||||
|
} from '@/content/schemas';
|
||||||
|
|
||||||
|
const fixture = (name: string): unknown =>
|
||||||
|
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
||||||
|
|
||||||
|
const validContent = fixture('portfolio-content.valid.json');
|
||||||
|
const validManifest = fixture('asset-manifest.valid.json');
|
||||||
|
const validSite = fixture('site-config.preview.json');
|
||||||
|
type MutableContentFixture = {
|
||||||
|
navigation: Array<{ id: string }>;
|
||||||
|
projects: {
|
||||||
|
selected: Array<{ visibility: string; url?: string; assetIds: string[]; title: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneContent = (): MutableContentFixture => structuredClone(validContent) as MutableContentFixture;
|
||||||
|
|
||||||
|
describe('content schemas', () => {
|
||||||
|
it('accepts the complete synthetic public input boundary', () => {
|
||||||
|
expect(parsePortfolioContent(validContent).identity.name).toBe('Luca Pîslaru');
|
||||||
|
expect(parsePublicAssetManifest(validManifest).assets).toHaveLength(5);
|
||||||
|
expect(parseSiteConfig(validSite).environment).toBe('preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a public link on a private project', () => {
|
||||||
|
const input = cloneContent();
|
||||||
|
input.projects.selected[0]!.visibility = 'private';
|
||||||
|
input.projects.selected[0]!.url = 'https://example.test/private';
|
||||||
|
expect(() => parsePortfolioContent(input)).toThrow(/private project.*url/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown site configuration keys', () => {
|
||||||
|
expect(() => parseSiteConfig({
|
||||||
|
environment: 'preview',
|
||||||
|
canonicalUrl: 'https://portfolio.example.test',
|
||||||
|
buildId: 'preview-1',
|
||||||
|
contactEmail: 'forbidden@example.test',
|
||||||
|
})).toThrow(/unrecognized/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an asset reference absent from the manifest', () => {
|
||||||
|
const content = cloneContent();
|
||||||
|
content.projects.selected[0]!.assetIds = ['missing-asset'];
|
||||||
|
expect(() => validateProductionInputs(content, validManifest, validSite, new URL('file:///prepared/')))
|
||||||
|
.toThrow(/unmanifested asset/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate manifest asset IDs and unsafe generated filenames', () => {
|
||||||
|
const manifest = structuredClone(validManifest) as { assets: Array<{ id: string; fileName: string }> };
|
||||||
|
manifest.assets[1]!.id = manifest.assets[0]!.id;
|
||||||
|
manifest.assets[2]!.fileName = '../original.webp';
|
||||||
|
expect(() => parsePublicAssetManifest(manifest)).toThrow(/duplicate asset id|generated safe filename/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires canonical navigation and generic private titles', () => {
|
||||||
|
const content = cloneContent();
|
||||||
|
content.navigation[7]!.id = 'profile';
|
||||||
|
content.projects.selected[0]!.title = 'Private medical tracker';
|
||||||
|
expect(() => parsePortfolioContent(content)).toThrow(/navigation|private project title/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
tests/unit/production-inputs.test.ts
Normal file
67
tests/unit/production-inputs.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { cp, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { assetUrl } from '@/content/asset-url';
|
||||||
|
import { loadProductionInputs } from '@/content/load-production-inputs';
|
||||||
|
import { parsePublicAssetManifest } from '@/content/schemas';
|
||||||
|
|
||||||
|
const fixtureUrl = (name: string) => pathToFileURL(resolve(process.cwd(), 'tests/fixtures', name));
|
||||||
|
const fixtureUrls = {
|
||||||
|
valid: {
|
||||||
|
contentJson: fixtureUrl('portfolio-content.valid.json'),
|
||||||
|
assetManifestJson: fixtureUrl('asset-manifest.valid.json'),
|
||||||
|
preparedAssetRoot: fixtureUrl('prepared-assets.valid/'),
|
||||||
|
siteConfigJson: fixtureUrl('site-config.preview.json'),
|
||||||
|
},
|
||||||
|
extraPreparedFile: {
|
||||||
|
contentJson: fixtureUrl('portfolio-content.valid.json'),
|
||||||
|
assetManifestJson: fixtureUrl('asset-manifest.valid.json'),
|
||||||
|
preparedAssetRoot: fixtureUrl('prepared-assets.extra/'),
|
||||||
|
siteConfigJson: fixtureUrl('site-config.preview.json'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('production inputs', () => {
|
||||||
|
it('rejects an extra prepared file', async () => {
|
||||||
|
await expect(loadProductionInputs(fixtureUrls.extraPreparedFile)).rejects.toThrow(/extra prepared asset/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns deeply frozen inputs', async () => {
|
||||||
|
const inputs = await loadProductionInputs(fixtureUrls.valid);
|
||||||
|
expect(Object.isFrozen(inputs.content.projects.selected)).toBe(true);
|
||||||
|
expect(() => { (inputs.site as { buildId: string }).buildId = 'mutated'; }).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a symlinked prepared member', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'portfolio-inputs-'));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
await cp(fixtureUrls.valid.preparedAssetRoot, directory, { recursive: true });
|
||||||
|
await rm(join(directory, 'selected-build-one.webp'));
|
||||||
|
await symlink(join(directory, 'selected-build-two.webp'), join(directory, 'selected-build-one.webp'));
|
||||||
|
await expect(loadProductionInputs({ ...fixtureUrls.valid, preparedAssetRoot: new URL(`file://${directory}/`) }))
|
||||||
|
.rejects.toThrow(/symlink|special file/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate JSON keys before schema validation', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'portfolio-inputs-'));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
const contentPath = join(directory, 'content.json');
|
||||||
|
await writeFile(contentPath, '{"identity":{},"identity":{}}');
|
||||||
|
await expect(loadProductionInputs({ ...fixtureUrls.valid, contentJson: new URL(`file://${contentPath}`) }))
|
||||||
|
.rejects.toThrow(/duplicate JSON key/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves only an exact manifest asset ID to an encoded deterministic URL', () => {
|
||||||
|
const manifest = parsePublicAssetManifest(JSON.parse(readFileSync(fixtureUrl('asset-manifest.valid.json'), 'utf8')));
|
||||||
|
expect(assetUrl(manifest, 'selected-build-one')).toBe('/assets/selected-build-one.webp');
|
||||||
|
expect(() => assetUrl(manifest, 'missing')).toThrow(/unknown asset/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user