mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -5,16 +5,24 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"db:reset": "tsx database/reset.ts",
|
||||
"docs:gen": "tsx semantic-css/generate-reference.ts",
|
||||
"fonts:generate": "tsx fonts/generate.ts",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reactive-resume/schema": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"pg": "^8.22.0",
|
||||
"tsx": "^4.23.1"
|
||||
"tsx": "^4.23.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { JsonSchema } from "./generate-reference";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import {
|
||||
buildGeneratedDocumentation,
|
||||
renderSchemaReference,
|
||||
replaceGeneratedBlock,
|
||||
updateGeneratedDocumentation,
|
||||
} from "./generate-reference";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const defaultDocumentationPaths = {
|
||||
jsonSchemaGuide: fileURLToPath(new URL("../../docs/guides/json-resume-schema.mdx", import.meta.url)),
|
||||
skillSchemaReference: fileURLToPath(new URL("../../skills/resume-builder/references/schema.md", import.meta.url)),
|
||||
};
|
||||
const applyingCustomStylesGuide = fileURLToPath(new URL("../../docs/applying-custom-styles.mdx", import.meta.url));
|
||||
|
||||
type SemanticCssExample = { label: string; source: string };
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
const readTargets = (paths: typeof defaultDocumentationPaths) =>
|
||||
Promise.all(Object.values(paths).map((path) => readFile(path, "utf8")));
|
||||
|
||||
function extractSemanticCssExamples(source: string): SemanticCssExample[] {
|
||||
const examples: SemanticCssExample[] = [];
|
||||
const matches = source.matchAll(/```css\r?\n([\s\S]*?)\r?\n```/g);
|
||||
|
||||
for (const [, example] of matches) {
|
||||
if (!example) throw new Error("Invalid Semantic CSS example.");
|
||||
examples.push({ label: `example ${examples.length + 1}`, source: example });
|
||||
}
|
||||
|
||||
return examples;
|
||||
}
|
||||
|
||||
async function createDocumentationPaths() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "generated-documentation-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const paths = {
|
||||
jsonSchemaGuide: join(directory, "json-resume-schema.mdx"),
|
||||
skillSchemaReference: join(directory, "schema.md"),
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
paths.jsonSchemaGuide,
|
||||
"before\n<!-- RESUME-JSON-SCHEMA:START -->\nold\n<!-- RESUME-JSON-SCHEMA:END -->\nafter\n",
|
||||
),
|
||||
writeFile(paths.skillSchemaReference, "old skill reference\n"),
|
||||
]);
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
it("rejects missing, duplicate, and out-of-order generated markers", () => {
|
||||
expect(() => replaceGeneratedBlock("plain text", "SEMANTIC-CSS-ELEMENTS", "body", "reference.mdx")).toThrow(
|
||||
/Missing generated markers/,
|
||||
);
|
||||
expect(() =>
|
||||
replaceGeneratedBlock(
|
||||
"<!-- SEMANTIC-CSS-ELEMENTS:START --><!-- SEMANTIC-CSS-ELEMENTS:END --><!-- SEMANTIC-CSS-ELEMENTS:START --><!-- SEMANTIC-CSS-ELEMENTS:END -->",
|
||||
"SEMANTIC-CSS-ELEMENTS",
|
||||
"body",
|
||||
"reference.mdx",
|
||||
),
|
||||
).toThrow(/Duplicate generated markers/);
|
||||
expect(() =>
|
||||
replaceGeneratedBlock(
|
||||
"<!-- SEMANTIC-CSS-ELEMENTS:END --><!-- SEMANTIC-CSS-ELEMENTS:START -->",
|
||||
"SEMANTIC-CSS-ELEMENTS",
|
||||
"body",
|
||||
"reference.mdx",
|
||||
),
|
||||
).toThrow(/out of order/);
|
||||
});
|
||||
|
||||
it("builds identical output twice", async () => {
|
||||
const paths = await createDocumentationPaths();
|
||||
|
||||
expect(await buildGeneratedDocumentation(paths)).toEqual(await buildGeneratedDocumentation(paths));
|
||||
});
|
||||
|
||||
it("labels union requiredness by variant and emits representative variant shapes", () => {
|
||||
const reference = renderSchemaReference({
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
company: { type: "string" },
|
||||
position: { type: "string" },
|
||||
},
|
||||
required: ["company"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
school: { type: "string" },
|
||||
degree: { type: "string" },
|
||||
},
|
||||
required: ["school"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["items"],
|
||||
});
|
||||
|
||||
expect(reference).toContain("Required fields are local to that variant");
|
||||
expect(reference).toContain("| `items[]` | variant 1 | — | `{ company }` |");
|
||||
expect(reference).toContain("| `items[]` | variant 2 | — | `{ school }` |");
|
||||
expect(reference).toContain("| `items[].company` | `string` | yes (variant 1 at items[]) |");
|
||||
expect(reference).toContain("| `items[].position` | `string` | no (variant 1 at items[]) |");
|
||||
expect(reference).toContain("| `items[].school` | `string` | yes (variant 2 at items[]) |");
|
||||
expect(reference).not.toContain("| `items[].school` | `string` | yes |");
|
||||
});
|
||||
|
||||
it("names every custom-section item shape by its type key", () => {
|
||||
const reference = renderSchemaReference(createResumeDataJsonSchema() as JsonSchema);
|
||||
const expectedSchemas = {
|
||||
summary: "summaryItemSchema",
|
||||
profiles: "profileItemSchema",
|
||||
experience: "experienceItemSchema",
|
||||
education: "educationItemSchema",
|
||||
projects: "projectItemSchema",
|
||||
skills: "skillItemSchema",
|
||||
languages: "languageItemSchema",
|
||||
interests: "interestItemSchema",
|
||||
awards: "awardItemSchema",
|
||||
certifications: "certificationItemSchema",
|
||||
publications: "publicationItemSchema",
|
||||
volunteer: "volunteerItemSchema",
|
||||
references: "referenceItemSchema",
|
||||
"cover-letter": "coverLetterItemSchema",
|
||||
};
|
||||
|
||||
for (const [type, schemaName] of Object.entries(expectedSchemas)) {
|
||||
expect(reference).toContain(`| \`customSections[]\` | \`${type}\` | \`${schemaName}\` |`);
|
||||
}
|
||||
expect(reference).toContain(
|
||||
"| `customSections[]` | `experience` | `experienceItemSchema` | `{ id, hidden, company, position, location, period, description }` |",
|
||||
);
|
||||
expect(reference).toContain(
|
||||
"| `customSections[].items[].company` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) |",
|
||||
);
|
||||
expect(reference).not.toMatch(/\| `customSections\[\]` \| variant \d+/);
|
||||
});
|
||||
|
||||
it("derives top-level required fields and renders canonical exclusive minimum constraints", () => {
|
||||
const reference = renderSchemaReference({
|
||||
type: "object",
|
||||
properties: {
|
||||
languageVersion: { type: "integer", exclusiveMinimum: 0, maximum: 1 },
|
||||
optional: { type: "string" },
|
||||
},
|
||||
required: ["languageVersion"],
|
||||
});
|
||||
const requiredSection = reference.slice(
|
||||
reference.indexOf("## Required top-level fields"),
|
||||
reference.indexOf("## Union variant shapes"),
|
||||
);
|
||||
|
||||
expect(requiredSection).toContain("`languageVersion`");
|
||||
expect(requiredSection).not.toContain("`picture`");
|
||||
expect(reference).toContain("| `languageVersion` | `integer` | yes | exclusiveMinimum: 0; maximum: 1 |");
|
||||
});
|
||||
|
||||
it("does not write any output when one source is invalid", async () => {
|
||||
const paths = await createDocumentationPaths();
|
||||
await writeFile(paths.jsonSchemaGuide, "missing markers\n");
|
||||
const before = await Promise.all(Object.values(paths).map((path) => readFile(path, "utf8")));
|
||||
|
||||
await expect(updateGeneratedDocumentation(paths)).rejects.toThrow(/Missing generated markers/);
|
||||
expect(await Promise.all(Object.values(paths).map((path) => readFile(path, "utf8")))).toEqual(before);
|
||||
});
|
||||
|
||||
it("keeps every committed generated document synchronized", async () => {
|
||||
const [jsonSchemaGuide, skillSchemaReference] = await readTargets(defaultDocumentationPaths);
|
||||
|
||||
expect(await buildGeneratedDocumentation(defaultDocumentationPaths)).toEqual({
|
||||
jsonSchemaGuide,
|
||||
skillSchemaReference,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the schema guide aligned with the canonical schema contract", async () => {
|
||||
const guide = await readFile(defaultDocumentationPaths.jsonSchemaGuide, "utf8");
|
||||
const authoredGuide = guide.slice(0, guide.indexOf("<!-- RESUME-JSON-SCHEMA:START -->"));
|
||||
const schema = createResumeDataJsonSchema();
|
||||
|
||||
expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
|
||||
expect(schema.properties).not.toHaveProperty("version");
|
||||
expect(authoredGuide).toContain("draft 2020-12");
|
||||
expect(authoredGuide).toContain("Resume documents do not include a top-level `version` property.");
|
||||
expect(authoredGuide).not.toMatch(/draft 0?7/i);
|
||||
expect(authoredGuide).not.toMatch(/"version"\s*:/);
|
||||
expect(authoredGuide).not.toMatch(/custom sections.*arbitrary content/is);
|
||||
});
|
||||
|
||||
it("compiles every Semantic CSS example in the public guide", async () => {
|
||||
const source = await readFile(applyingCustomStylesGuide, "utf8");
|
||||
const examples = extractSemanticCssExamples(source);
|
||||
expect(examples).not.toEqual([]);
|
||||
|
||||
for (const example of examples) {
|
||||
const result = compileStylesheet({ languageVersion: 1, text: example.source });
|
||||
expect(result.program, example.label).not.toBeNull();
|
||||
expect(
|
||||
result.diagnostics.filter(({ severity }) => severity === "error"),
|
||||
example.label,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createCustomSectionItemJsonSchemas,
|
||||
createResumeDataJsonSchema,
|
||||
} from "@reactive-resume/schema/resume/json-schema";
|
||||
|
||||
export type DocumentationPaths = {
|
||||
jsonSchemaGuide: string;
|
||||
skillSchemaReference: string;
|
||||
};
|
||||
|
||||
export type JsonSchema = {
|
||||
type?: string | readonly string[];
|
||||
properties?: Readonly<Record<string, JsonSchema>>;
|
||||
required?: readonly string[];
|
||||
items?: JsonSchema;
|
||||
anyOf?: readonly JsonSchema[];
|
||||
oneOf?: readonly JsonSchema[];
|
||||
enum?: readonly unknown[];
|
||||
const?: unknown;
|
||||
minimum?: number;
|
||||
exclusiveMinimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
default?: unknown;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const defaultPaths: DocumentationPaths = {
|
||||
jsonSchemaGuide: fileURLToPath(new URL("../../docs/guides/json-resume-schema.mdx", import.meta.url)),
|
||||
skillSchemaReference: fileURLToPath(new URL("../../skills/resume-builder/references/schema.md", import.meta.url)),
|
||||
};
|
||||
|
||||
const markdown = (value: unknown) => String(value).replaceAll("|", "\\|").replaceAll(/\r?\n/g, " ");
|
||||
const code = (value: unknown) => `\`${markdown(value)}\``;
|
||||
const list = (values: readonly unknown[]) => (values.length ? values.map(code).join(", ") : "—");
|
||||
const sentenceList = (values: readonly unknown[]) => {
|
||||
if (values.length < 2) return list(values);
|
||||
if (values.length === 2) return `${code(values[0])} and ${code(values[1])}`;
|
||||
return `${values.slice(0, -1).map(code).join(", ")}, and ${code(values.at(-1))}`;
|
||||
};
|
||||
const table = (headers: readonly string[], rows: readonly string[]) =>
|
||||
[`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`, ...rows].join("\n");
|
||||
|
||||
export function replaceGeneratedBlock(source: string, name: string, body: string, path: string): string {
|
||||
const start = `<!-- ${name}:START -->`;
|
||||
const end = `<!-- ${name}:END -->`;
|
||||
const startMatches = source.split(start).length - 1;
|
||||
const endMatches = source.split(end).length - 1;
|
||||
|
||||
if (startMatches === 0 || endMatches === 0) throw new Error(`Missing generated markers ${name} in ${path}.`);
|
||||
if (startMatches !== 1 || endMatches !== 1) throw new Error(`Duplicate generated markers ${name} in ${path}.`);
|
||||
|
||||
const startIndex = source.indexOf(start);
|
||||
const endIndex = source.indexOf(end);
|
||||
if (endIndex < startIndex) throw new Error(`Generated markers ${name} are out of order in ${path}.`);
|
||||
|
||||
return `${source.slice(0, startIndex)}${start}\n${body}\n${end}${source.slice(endIndex + end.length)}`;
|
||||
}
|
||||
|
||||
function schemaType(schema: JsonSchema): string {
|
||||
const union = schema.anyOf ?? schema.oneOf;
|
||||
if (union) return union.map(schemaType).join(" or ");
|
||||
return typeof schema.type === "string" ? schema.type : (schema.type?.join(" or ") ?? "any");
|
||||
}
|
||||
|
||||
function schemaConstraints(schema: JsonSchema) {
|
||||
const constraints = [
|
||||
schema.enum && `enum: ${JSON.stringify(schema.enum)}`,
|
||||
schema.minimum !== undefined && `minimum: ${schema.minimum}`,
|
||||
schema.exclusiveMinimum !== undefined && `exclusiveMinimum: ${schema.exclusiveMinimum}`,
|
||||
schema.maximum !== undefined && `maximum: ${schema.maximum}`,
|
||||
schema.minLength !== undefined && `minLength: ${schema.minLength}`,
|
||||
schema.maxLength !== undefined && `maxLength: ${schema.maxLength}`,
|
||||
schema.default !== undefined && `default: ${JSON.stringify(schema.default)}`,
|
||||
].filter((constraint): constraint is string => Boolean(constraint));
|
||||
return markdown(constraints.join("; ") || "—");
|
||||
}
|
||||
|
||||
type VariantContext = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const variantLabel = ({ label, path }: VariantContext) => `${label} at ${path}`;
|
||||
|
||||
export function renderSchemaReference(schema: JsonSchema) {
|
||||
const rows: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const variantRows: string[] = [];
|
||||
const customSectionItemSchemas = createCustomSectionItemJsonSchemas();
|
||||
|
||||
const visit = (
|
||||
node: JsonSchema,
|
||||
path: string,
|
||||
required: boolean | null,
|
||||
variants: readonly VariantContext[] = [],
|
||||
) => {
|
||||
const type = schemaType(node);
|
||||
const key = `${path}\0${type}\0${variants.map(variantLabel).join("\0")}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
const requiredness = required === null ? "—" : required ? "yes" : "no";
|
||||
const variant = variants.map(variantLabel).join("; ");
|
||||
rows.push(
|
||||
`| ${code(path)} | ${code(type)} | ${requiredness}${variant ? ` (${variant})` : ""} | ${schemaConstraints(node)} | ${markdown(node.description ?? "—")} |`,
|
||||
);
|
||||
}
|
||||
|
||||
const requiredProperties = new Set(node.required ?? []);
|
||||
for (const [name, property] of Object.entries(node.properties ?? {})) {
|
||||
visit(property, path ? `${path}.${name}` : name, requiredProperties.has(name), variants);
|
||||
}
|
||||
if (node.items) visit(node.items, `${path}[]`, null, variants);
|
||||
const union = node.anyOf ?? node.oneOf ?? [];
|
||||
if (path === "customSections[]" && union.length > 0) {
|
||||
for (const [type, item] of Object.entries(customSectionItemSchemas)) {
|
||||
const branch = union.find((candidate) => candidate.properties?.type?.const === type);
|
||||
const itemSchema = branch?.properties?.items?.items;
|
||||
if (!branch || !itemSchema) {
|
||||
throw new Error(`Missing correlated custom section schema for type: ${type}`);
|
||||
}
|
||||
const shape =
|
||||
itemSchema.type === "object" ? `{ ${(itemSchema.required ?? []).join(", ")} }` : schemaType(itemSchema);
|
||||
const label = `type ${type}, schema ${item.schemaName}`;
|
||||
variantRows.push(`| ${code(path)} | ${code(type)} | ${code(item.schemaName)} | ${code(shape)} |`);
|
||||
visit(branch, path, required, [...variants, { label, path }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [branchIndex, branch] of union.entries()) {
|
||||
const label = `variant ${branchIndex + 1}`;
|
||||
const shape = branch.type === "object" ? `{ ${(branch.required ?? []).join(", ")} }` : schemaType(branch);
|
||||
variantRows.push(`| ${code(path)} | ${label} | — | ${code(shape)} |`);
|
||||
visit(branch, path, required, [...variants, { label, path }]);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [name, property] of Object.entries(schema.properties ?? {})) {
|
||||
visit(property, name, new Set(schema.required ?? []).has(name));
|
||||
}
|
||||
|
||||
return [
|
||||
"# Reactive Resume Schema Reference",
|
||||
"",
|
||||
"Generated by `pnpm docs:gen` from `resumeDataSchema`. Do not edit this file directly.",
|
||||
"",
|
||||
"Canonical schema: https://rxresu.me/schema.json",
|
||||
"",
|
||||
"## Required top-level fields",
|
||||
"",
|
||||
sentenceList(schema.required ?? []),
|
||||
"",
|
||||
"## Union variant shapes",
|
||||
"",
|
||||
"Choose one coherent shape for each union value. Required fields are local to that variant; optional fields remain in the field catalog.",
|
||||
"",
|
||||
table(["Path", "Type/variant", "Item schema", "Representative required shape"], variantRows),
|
||||
"",
|
||||
"## Field catalog",
|
||||
"",
|
||||
table(["Path", "Type", "Required", "Constraints and default", "Description"], rows),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function buildGeneratedDocumentation(paths: Partial<DocumentationPaths> = {}) {
|
||||
const resolvedPaths = { ...defaultPaths, ...paths };
|
||||
const jsonSchemaSource = await readFile(resolvedPaths.jsonSchemaGuide, "utf8");
|
||||
const schema = createResumeDataJsonSchema() as JsonSchema;
|
||||
const fullSchemaBlock = ["```json /schema.json lines expandable", JSON.stringify(schema, null, "\t"), "```"].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
return {
|
||||
jsonSchemaGuide: replaceGeneratedBlock(
|
||||
jsonSchemaSource,
|
||||
"RESUME-JSON-SCHEMA",
|
||||
fullSchemaBlock,
|
||||
resolvedPaths.jsonSchemaGuide,
|
||||
),
|
||||
skillSchemaReference: renderSchemaReference(schema),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateGeneratedDocumentation(paths: Partial<DocumentationPaths> = {}): Promise<void> {
|
||||
const resolvedPaths = { ...defaultPaths, ...paths };
|
||||
const output = await buildGeneratedDocumentation(resolvedPaths);
|
||||
await Promise.all(
|
||||
(Object.keys(output) as (keyof DocumentationPaths)[]).map((name) => writeFile(resolvedPaths[name], output[name])),
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await updateGeneratedDocumentation();
|
||||
}
|
||||
+6
-1
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["runtime:server", "role:tooling"]
|
||||
"tags": ["runtime:server", "role:tooling"],
|
||||
"tasks": {
|
||||
"test:ci": {
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user