diff --git a/src/content/asset-url.ts b/src/content/asset-url.ts new file mode 100644 index 0000000..b2ce602 --- /dev/null +++ b/src/content/asset-url.ts @@ -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)}`; +}; diff --git a/src/content/load-production-inputs.ts b/src/content/load-production-inputs.ts new file mode 100644 index 0000000..1650ad6 --- /dev/null +++ b/src/content/load-production-inputs.ts @@ -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 => { + 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(); + 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 => { + 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 => { + 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(); + 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(); + 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 = (value: T, seen = new WeakSet()): 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> => { + 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); +}; diff --git a/src/content/schemas.ts b/src/content/schemas.ts new file mode 100644 index 0000000..08c5168 --- /dev/null +++ b/src/content/schemas.ts @@ -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 }; +}; diff --git a/src/content/types.ts b/src/content/types.ts new file mode 100644 index 0000000..c197f50 --- /dev/null +++ b/src/content/types.ts @@ -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; +} diff --git a/tests/fixtures/asset-manifest.valid.json b/tests/fixtures/asset-manifest.valid.json new file mode 100644 index 0000000..4630be9 --- /dev/null +++ b/tests/fixtures/asset-manifest.valid.json @@ -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" } + ] +} diff --git a/tests/fixtures/portfolio-content.valid.json b/tests/fixtures/portfolio-content.valid.json new file mode 100644 index 0000000..d6c30a3 --- /dev/null +++ b/tests/fixtures/portfolio-content.valid.json @@ -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." +} diff --git a/tests/fixtures/prepared-assets.extra/research-panel.webp b/tests/fixtures/prepared-assets.extra/research-panel.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.extra/research-panel.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.extra/selected-build-one.webp b/tests/fixtures/prepared-assets.extra/selected-build-one.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.extra/selected-build-one.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.extra/selected-build-two.webp b/tests/fixtures/prepared-assets.extra/selected-build-two.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.extra/selected-build-two.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.extra/social-preview.webp b/tests/fixtures/prepared-assets.extra/social-preview.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.extra/social-preview.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.extra/toolkit-linux.webp b/tests/fixtures/prepared-assets.extra/toolkit-linux.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.extra/toolkit-linux.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.extra/unmanifested-extra.webp b/tests/fixtures/prepared-assets.extra/unmanifested-extra.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.extra/unmanifested-extra.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.valid/research-panel.webp b/tests/fixtures/prepared-assets.valid/research-panel.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.valid/research-panel.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.valid/selected-build-one.webp b/tests/fixtures/prepared-assets.valid/selected-build-one.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.valid/selected-build-one.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.valid/selected-build-two.webp b/tests/fixtures/prepared-assets.valid/selected-build-two.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.valid/selected-build-two.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.valid/social-preview.webp b/tests/fixtures/prepared-assets.valid/social-preview.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.valid/social-preview.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/prepared-assets.valid/toolkit-linux.webp b/tests/fixtures/prepared-assets.valid/toolkit-linux.webp new file mode 100644 index 0000000..2403cfa --- /dev/null +++ b/tests/fixtures/prepared-assets.valid/toolkit-linux.webp @@ -0,0 +1 @@ +synthetic-public-asset diff --git a/tests/fixtures/site-config.preview.json b/tests/fixtures/site-config.preview.json new file mode 100644 index 0000000..2f6e809 --- /dev/null +++ b/tests/fixtures/site-config.preview.json @@ -0,0 +1,5 @@ +{ + "environment": "preview", + "canonicalUrl": "https://portfolio.example.test", + "buildId": "preview-1" +} diff --git a/tests/unit/content-schemas.test.ts b/tests/unit/content-schemas.test.ts new file mode 100644 index 0000000..2da26e2 --- /dev/null +++ b/tests/unit/content-schemas.test.ts @@ -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); + }); +}); diff --git a/tests/unit/production-inputs.test.ts b/tests/unit/production-inputs.test.ts new file mode 100644 index 0000000..f351312 --- /dev/null +++ b/tests/unit/production-inputs.test.ts @@ -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); + }); +});