mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22:20 +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
@@ -0,0 +1,37 @@
|
||||
import type { StyleProgram } from "@reactive-resume/resume/stylesheet";
|
||||
import { PROPERTY_REGISTRY_V1 } from "@reactive-resume/resume/stylesheet";
|
||||
|
||||
export type SemanticCssColorToken = {
|
||||
from: number;
|
||||
to: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const colorValue =
|
||||
/^(?:#[\da-f]{3,8}|(?:rgb|rgba|hsl|hsla)\([^)]*\)|(?:aqua|black|blue|currentcolor|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|transparent|white|yellow))$/i;
|
||||
|
||||
const isColorProperty = (property: string) =>
|
||||
PROPERTY_REGISTRY_V1[property] !== undefined &&
|
||||
(PROPERTY_REGISTRY_V1[property]?.category === "color" || property.endsWith("-color"));
|
||||
|
||||
export function collectCompiledColorTokens(
|
||||
source: string,
|
||||
program: StyleProgram | null,
|
||||
): readonly SemanticCssColorToken[] {
|
||||
if (!program) return [];
|
||||
const tokens = new Map<string, SemanticCssColorToken>();
|
||||
|
||||
for (const rule of program.rules) {
|
||||
for (const declaration of rule.declarations) {
|
||||
if (!isColorProperty(declaration.property) || !colorValue.test(declaration.value)) continue;
|
||||
const declarationSource = source.slice(declaration.range.start.offset, declaration.range.end.offset);
|
||||
const valueOffset = declarationSource.indexOf(declaration.value, declarationSource.indexOf(":") + 1);
|
||||
if (valueOffset < 0) continue;
|
||||
const from = declaration.range.start.offset + valueOffset;
|
||||
const token = { from, to: from + declaration.value.length, value: declaration.value };
|
||||
tokens.set(`${token.from}:${token.to}`, token);
|
||||
}
|
||||
}
|
||||
|
||||
return [...tokens.values()].sort((left, right) => left.from - right.from);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Transaction } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { collectCompiledColorTokens } from "./color-tokens";
|
||||
import {
|
||||
compositionAwareDocumentListener,
|
||||
copySourceToClipboard,
|
||||
createSemanticCssEditorExtensions,
|
||||
getSemanticCssCompletionLabels,
|
||||
getSemanticCssHoverDocumentation,
|
||||
mapCompilerDiagnostics,
|
||||
} from "./editor-extensions";
|
||||
|
||||
const semanticTree: SemanticNode = {
|
||||
key: "resume",
|
||||
kind: "resume",
|
||||
attributes: { template: "onyx" },
|
||||
roles: [],
|
||||
children: [
|
||||
{
|
||||
key: "section",
|
||||
kind: "section",
|
||||
id: "section-experience",
|
||||
attributes: { type: "experience", placement: "main" },
|
||||
roles: [],
|
||||
children: [
|
||||
{
|
||||
key: "item",
|
||||
kind: "item",
|
||||
id: "item-current",
|
||||
attributes: {},
|
||||
roles: [],
|
||||
children: [
|
||||
{
|
||||
key: "field",
|
||||
kind: "field",
|
||||
attributes: { name: "company" },
|
||||
roles: ["primary-text"],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
semanticTree,
|
||||
templateParts: ["timeline-line", "timeline-marker"],
|
||||
} as const;
|
||||
const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const;
|
||||
|
||||
const views: EditorView[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const view of views.splice(0)) view.destroy();
|
||||
});
|
||||
|
||||
describe("Semantic CSS editor extensions", () => {
|
||||
it("uses only Semantic CSS registries and the current resume for completion", async () => {
|
||||
const selectorLabels = await getSemanticCssCompletionLabels("", 0, metadata);
|
||||
const propertyLabels = await getSemanticCssCompletionLabels("section {\n\tco", 13, metadata);
|
||||
const variableSource = "resume { --brand-accent: #f00; color: var(--br";
|
||||
const variableLabels = await getSemanticCssCompletionLabels(variableSource, variableSource.length, metadata);
|
||||
const systemLabels = await getSemanticCssCompletionLabels("--resume-", 5, metadata);
|
||||
const directiveLabels = await getSemanticCssCompletionLabels("@", 1, metadata);
|
||||
|
||||
expect(selectorLabels).toEqual(
|
||||
expect.arrayContaining([
|
||||
"section",
|
||||
"#section-experience",
|
||||
"#item-current",
|
||||
'[name="company"]',
|
||||
'[role~="primary-text"]',
|
||||
'template-part[name="timeline-marker"]',
|
||||
]),
|
||||
);
|
||||
expect(propertyLabels).toContain("color");
|
||||
expect(propertyLabels).toContain("-resume-fixed");
|
||||
expect(propertyLabels).not.toContain("cursor");
|
||||
expect(propertyLabels).not.toContain("font-family");
|
||||
expect(variableLabels).toEqual(expect.arrayContaining(["--brand-accent", "--resume-primary-color"]));
|
||||
expect(systemLabels).toEqual(expect.arrayContaining(["--resume-primary-color", "--resume-sidebar-width"]));
|
||||
expect(systemLabels).not.toContain("--resume-font-family");
|
||||
expect(directiveLabels).toEqual(expect.arrayContaining(["@media", "@version 1;"]));
|
||||
});
|
||||
|
||||
it("offers only the current property's registered compiler vocabulary", () => {
|
||||
const displaySource = "section { display: f";
|
||||
const borderStyleSource = "section { border-style: d";
|
||||
const fontSizeSource = "section { font-size: 1";
|
||||
|
||||
const displayLabels = getSemanticCssCompletionLabels(displaySource, displaySource.length, metadata);
|
||||
const borderStyleLabels = getSemanticCssCompletionLabels(borderStyleSource, borderStyleSource.length, metadata);
|
||||
const fontSizeLabels = getSemanticCssCompletionLabels(fontSizeSource, fontSizeSource.length, metadata);
|
||||
|
||||
expect(displayLabels).toEqual(expect.arrayContaining(["flex", "none", "inherit"]));
|
||||
expect(displayLabels).not.toEqual(expect.arrayContaining(["portrait", "dashed", "pt"]));
|
||||
expect(borderStyleLabels).toEqual(expect.arrayContaining(["dashed", "dotted", "solid"]));
|
||||
expect(borderStyleLabels).not.toContain("double");
|
||||
expect(fontSizeLabels).toEqual(expect.arrayContaining(["pt", "rem"]));
|
||||
expect(fontSizeLabels).not.toEqual(expect.arrayContaining(["none", "normal", "max-content"]));
|
||||
});
|
||||
|
||||
it.each(borderShorthands)("offers complete %s shorthand values instead of bare units", (property) => {
|
||||
const source = `section { ${property}: `;
|
||||
const labels = getSemanticCssCompletionLabels(source, source.length, metadata);
|
||||
|
||||
expect(labels).toEqual(expect.arrayContaining(["1pt dotted", "1pt dashed", "1pt solid"]));
|
||||
expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"]));
|
||||
});
|
||||
|
||||
it("escapes dynamic IDs and attribute values before inserting selectors", () => {
|
||||
const unsafeMetadata = {
|
||||
semanticTree: {
|
||||
...semanticTree,
|
||||
children: [
|
||||
{
|
||||
key: "unsafe",
|
||||
kind: "field",
|
||||
id: "123 current#item",
|
||||
attributes: { name: 'company"lead\n' },
|
||||
roles: [],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
templateParts: ['timeline"marker\n'],
|
||||
} as const;
|
||||
|
||||
const labels = getSemanticCssCompletionLabels("", 0, unsafeMetadata);
|
||||
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
"#\\31 23\\ current\\#item",
|
||||
'[name="company\\"lead\\a "]',
|
||||
'template-part[name="timeline\\"marker\\a "]',
|
||||
]),
|
||||
);
|
||||
expect(labels).not.toContain("#123 current#item");
|
||||
expect(labels).not.toContain('[name="company"lead\n"]');
|
||||
});
|
||||
|
||||
it("builds hover text from the same registries", () => {
|
||||
expect(getSemanticCssHoverDocumentation("section", metadata)).toMatch(
|
||||
/semantic element.*placement.*featured-summary/i,
|
||||
);
|
||||
const colorDocumentation = getSemanticCssHoverDocumentation("color", metadata);
|
||||
expect(colorDocumentation).toMatch(/property.*inherited.*field/i);
|
||||
expect(colorDocumentation?.match(/section-heading/g)).toHaveLength(1);
|
||||
expect(getSemanticCssHoverDocumentation("--resume-primary-color", metadata)).toMatch(
|
||||
/read-only.*builder primary color/i,
|
||||
);
|
||||
expect(getSemanticCssHoverDocumentation("#section-experience", metadata)).toMatch(/current resume.*section/i);
|
||||
expect(getSemanticCssHoverDocumentation('template-part[name="timeline-line"]', metadata)).toMatch(
|
||||
/current template part/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps compiler offsets and only decorates compiler-confirmed color values", () => {
|
||||
const source = "@version 1;\nsection { color: #ff0000; background-color: rgb(0 0 0); }\n";
|
||||
const compiled = compileStylesheet({ languageVersion: 1, text: source });
|
||||
expect(compiled.program).not.toBeNull();
|
||||
const tokens = collectCompiledColorTokens(source, compiled.program);
|
||||
expect(tokens).toEqual([
|
||||
{ from: source.indexOf("#ff0000"), to: source.indexOf("#ff0000") + 7, value: "#ff0000" },
|
||||
{
|
||||
from: source.indexOf("rgb(0 0 0)"),
|
||||
to: source.indexOf("rgb(0 0 0)") + "rgb(0 0 0)".length,
|
||||
value: "rgb(0 0 0)",
|
||||
},
|
||||
]);
|
||||
|
||||
const diagnostic: SemanticCssDiagnostic = {
|
||||
code: "INVALID_VALUE",
|
||||
severity: "error",
|
||||
message: "Bad value",
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 2 },
|
||||
end: { line: 1, column: 30, offset: 99 },
|
||||
},
|
||||
};
|
||||
expect(mapCompilerDiagnostics(10, [diagnostic])).toEqual([
|
||||
expect.objectContaining({ from: 2, to: 10, severity: "error", message: "Bad value" }),
|
||||
]);
|
||||
|
||||
const selected = vi.fn();
|
||||
const view = new EditorView({
|
||||
doc: source,
|
||||
extensions: createSemanticCssEditorExtensions({
|
||||
metadata,
|
||||
diagnostics: [],
|
||||
colorTokens: tokens,
|
||||
onColorSelect: selected,
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
const swatches = view.dom.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
|
||||
expect(swatches).toHaveLength(2);
|
||||
swatches[0]?.click();
|
||||
expect(selected).toHaveBeenCalledWith(tokens[0], expect.any(DOMRect));
|
||||
});
|
||||
|
||||
it("preserves exact clipboard text and emits one change for an IME composition", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||
const source = "@version 1;\n/* exact spacing */\n";
|
||||
await copySourceToClipboard(source);
|
||||
expect(writeText).toHaveBeenCalledWith(source);
|
||||
|
||||
const onChange = vi.fn();
|
||||
const view = new EditorView({
|
||||
doc: "",
|
||||
extensions: compositionAwareDocumentListener(onChange),
|
||||
});
|
||||
views.push(view);
|
||||
view.contentDOM.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "" }));
|
||||
view.dispatch({
|
||||
changes: { from: 0, insert: "セク" },
|
||||
annotations: Transaction.userEvent.of("input.type.compose"),
|
||||
});
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: 2, insert: "セクション" },
|
||||
annotations: Transaction.userEvent.of("input.type.compose"),
|
||||
});
|
||||
view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "セクション" }));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(view.state.doc.toString()).toBe("セクション");
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith("セクション");
|
||||
});
|
||||
|
||||
it("opens the built-in search and replace panel", () => {
|
||||
const view = new EditorView({
|
||||
doc: "section { color: red; }",
|
||||
extensions: createSemanticCssEditorExtensions({
|
||||
metadata,
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
onColorSelect: vi.fn(),
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "f" }));
|
||||
|
||||
expect(view.dom.querySelector("[name=search]")).not.toBeNull();
|
||||
expect(view.dom.querySelector("[name=replace]")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete";
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorState, Extension } from "@codemirror/state";
|
||||
import type { DecorationSet, EditorView as EditorViewType, ViewUpdate } from "@codemirror/view";
|
||||
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet/registry";
|
||||
import type { SemanticCssColorToken } from "./color-tokens";
|
||||
import type { SemanticCssEditorMetadata } from "./protocol";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { linter, lintGutter } from "@codemirror/lint";
|
||||
import { search, searchKeymap } from "@codemirror/search";
|
||||
import { Decoration, EditorView, hoverTooltip, keymap, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||
import {
|
||||
escapeCssIdentifier,
|
||||
escapeCssString,
|
||||
PROPERTY_REGISTRY_V1,
|
||||
SEMANTIC_NODE_KINDS,
|
||||
SEMANTIC_REGISTRY_V1,
|
||||
SYSTEM_VARIABLE_REGISTRY_V1,
|
||||
} from "@reactive-resume/resume/stylesheet/registry";
|
||||
|
||||
export type SemanticCssColorSelection = (token: SemanticCssColorToken, rect: DOMRect) => void;
|
||||
|
||||
const directives = ["@media", "@version 1;"] as const;
|
||||
|
||||
function walk(root: SemanticNode): SemanticNode[] {
|
||||
const nodes: SemanticNode[] = [];
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (!node) continue;
|
||||
nodes.push(node);
|
||||
stack.push(...node.children);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function selectorLabels(metadata: SemanticCssEditorMetadata): string[] {
|
||||
const nodes = walk(metadata.semanticTree);
|
||||
const attributes = unique([
|
||||
"id",
|
||||
"role",
|
||||
...Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ attributes }) => attributes),
|
||||
]);
|
||||
const roles = unique(Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ roles }) => roles));
|
||||
return unique([
|
||||
...SEMANTIC_NODE_KINDS,
|
||||
"*",
|
||||
...nodes.flatMap((node) => (node.id ? [`#${escapeCssIdentifier(node.id)}`] : [])),
|
||||
...attributes.map((attribute) => `[${escapeCssIdentifier(attribute)}]`),
|
||||
...nodes.flatMap((node) =>
|
||||
Object.entries(node.attributes).map(
|
||||
([name, value]) => `[${escapeCssIdentifier(name)}=${escapeCssString(value)}]`,
|
||||
),
|
||||
),
|
||||
...roles.map((role) => `[role~=${escapeCssString(role)}]`),
|
||||
...metadata.templateParts.map((name) => `template-part[name=${escapeCssString(name)}]`),
|
||||
":root",
|
||||
":first-child",
|
||||
":last-child",
|
||||
":only-child",
|
||||
":nth-child()",
|
||||
":nth-of-type()",
|
||||
":is()",
|
||||
":where()",
|
||||
":not()",
|
||||
]);
|
||||
}
|
||||
|
||||
function userVariables(source: string): string[] {
|
||||
return unique([...source.matchAll(/(--(?!resume-)[-_a-zA-Z0-9]+)\s*:/g)].map((match) => match[1] as string));
|
||||
}
|
||||
|
||||
function completionKind(source: string, position: number): "directive" | "property" | "selector" | "system" | "value" {
|
||||
const before = source.slice(0, position);
|
||||
if (/--resume-[-\w]*$/.test(before)) return "system";
|
||||
if (/@[-\w]*$/.test(before)) return "directive";
|
||||
const open = before.lastIndexOf("{");
|
||||
const close = before.lastIndexOf("}");
|
||||
if (open <= close) return "selector";
|
||||
const declaration = before.slice(Math.max(open, before.lastIndexOf(";")) + 1);
|
||||
return declaration.includes(":") ? "value" : "property";
|
||||
}
|
||||
|
||||
function declarationProperty(source: string, position: number): string | undefined {
|
||||
const before = source.slice(0, position);
|
||||
const open = before.lastIndexOf("{");
|
||||
const close = before.lastIndexOf("}");
|
||||
if (open <= close) return;
|
||||
const declaration = before.slice(Math.max(open, before.lastIndexOf(";")) + 1);
|
||||
const colon = declaration.indexOf(":");
|
||||
if (colon < 0) return;
|
||||
const property = declaration.slice(0, colon).trim().toLowerCase();
|
||||
return property || undefined;
|
||||
}
|
||||
|
||||
function completionLabels(source: string, position: number, metadata: SemanticCssEditorMetadata): string[] {
|
||||
switch (completionKind(source, position)) {
|
||||
case "directive":
|
||||
return [...directives];
|
||||
case "property":
|
||||
return Object.keys(PROPERTY_REGISTRY_V1);
|
||||
case "selector":
|
||||
return selectorLabels(metadata);
|
||||
case "system":
|
||||
return Object.keys(SYSTEM_VARIABLE_REGISTRY_V1);
|
||||
case "value": {
|
||||
const property = declarationProperty(source, position);
|
||||
const definition = property ? PROPERTY_REGISTRY_V1[property] : undefined;
|
||||
return unique([
|
||||
...(definition?.values ?? []),
|
||||
...(definition?.units ?? []),
|
||||
...userVariables(source),
|
||||
...Object.keys(SYSTEM_VARIABLE_REGISTRY_V1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getSemanticCssCompletionLabels(
|
||||
source: string,
|
||||
position: number,
|
||||
metadata: SemanticCssEditorMetadata,
|
||||
): readonly string[] {
|
||||
return completionLabels(source, position, metadata);
|
||||
}
|
||||
|
||||
export function getSemanticCssHoverDocumentation(
|
||||
label: string,
|
||||
metadata: SemanticCssEditorMetadata,
|
||||
): string | undefined {
|
||||
const semantic = SEMANTIC_REGISTRY_V1[label as keyof typeof SEMANTIC_REGISTRY_V1];
|
||||
if (semantic) {
|
||||
return `Semantic element ${label}. Attributes: ${semantic.attributes.join(", ") || "none"}. Roles: ${semantic.roles.join(", ") || "none"}.`;
|
||||
}
|
||||
const property = PROPERTY_REGISTRY_V1[label];
|
||||
if (property) {
|
||||
return `Semantic CSS ${property.category} property ${label}. ${property.inheritable ? "Inherited" : "Not inherited"}. Applies to: ${property.appliesTo.join(", ")}.`;
|
||||
}
|
||||
const systemVariable = SYSTEM_VARIABLE_REGISTRY_V1[label as keyof typeof SYSTEM_VARIABLE_REGISTRY_V1];
|
||||
if (systemVariable) return `Read-only Semantic CSS system variable. ${systemVariable.description}`;
|
||||
const normalized = label.startsWith("#") ? label.slice(1) : label;
|
||||
const currentNode = walk(metadata.semanticTree).find((node) => node.id === normalized);
|
||||
if (currentNode) return `Current resume ${currentNode.kind} ID.`;
|
||||
const part = label.match(/^template-part\[name="(.+)"\]$/)?.[1] ?? label;
|
||||
if (metadata.templateParts.includes(part)) return `Current template part ${part}.`;
|
||||
return;
|
||||
}
|
||||
|
||||
export function mapCompilerDiagnostics(
|
||||
docLength: number,
|
||||
diagnostics: readonly SemanticCssDiagnostic[],
|
||||
): readonly Diagnostic[] {
|
||||
return diagnostics.map(({ message, severity, range, code }) => ({
|
||||
from: Math.max(0, Math.min(docLength, range.start.offset)),
|
||||
to: Math.max(0, Math.min(docLength, Math.max(range.start.offset, range.end.offset))),
|
||||
severity,
|
||||
message,
|
||||
source: code,
|
||||
}));
|
||||
}
|
||||
|
||||
export function compositionAwareDocumentListener(
|
||||
onChange: (source: string) => void,
|
||||
ignore?: (update: ViewUpdate) => boolean,
|
||||
): Extension {
|
||||
let composing = false;
|
||||
return [
|
||||
EditorView.domEventHandlers({
|
||||
compositionstart: () => {
|
||||
composing = true;
|
||||
return false;
|
||||
},
|
||||
compositionend: (_event, view) => {
|
||||
composing = false;
|
||||
queueMicrotask(() => onChange(view.state.doc.toString()));
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !composing && !ignore?.(update)) onChange(update.state.doc.toString());
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function completionSource(metadata: SemanticCssEditorMetadata): CompletionSource {
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
const source = context.state.doc.toString();
|
||||
const labels = completionLabels(source, context.pos, metadata);
|
||||
const word = context.matchBefore(/(?:--|[-@#])?[-_a-zA-Z0-9]*$/);
|
||||
if (!context.explicit && (!word || word.from === word.to)) return null;
|
||||
const options: Completion[] = labels.map((label) => ({
|
||||
label,
|
||||
type: label.startsWith("@") ? "keyword" : label.startsWith("#") || label.includes("[") ? "text" : "property",
|
||||
}));
|
||||
return { from: word?.from ?? context.pos, options, validFor: /[-_@#a-zA-Z0-9]*/ };
|
||||
};
|
||||
}
|
||||
|
||||
function tokenAt(state: EditorState, position: number): { from: number; to: number; label: string } | undefined {
|
||||
const line = state.doc.lineAt(position);
|
||||
const before = line.text.slice(0, position - line.from).match(/(?:--|[#@])?[-_a-zA-Z0-9]+$/)?.[0] ?? "";
|
||||
const after = line.text.slice(position - line.from).match(/^[-_a-zA-Z0-9]+/)?.[0] ?? "";
|
||||
if (!before && !after) return;
|
||||
const from = position - before.length;
|
||||
return { from, to: position + after.length, label: `${before}${after}` };
|
||||
}
|
||||
|
||||
function hoverExtension(metadata: SemanticCssEditorMetadata): Extension {
|
||||
return hoverTooltip((view, position) => {
|
||||
const token = tokenAt(view.state, position);
|
||||
if (!token) return null;
|
||||
const documentation = getSemanticCssHoverDocumentation(token.label, metadata);
|
||||
if (!documentation) return null;
|
||||
return {
|
||||
pos: token.from,
|
||||
end: token.to,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "cm-semantic-css-hover";
|
||||
dom.textContent = documentation;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
class ColorSwatch extends WidgetType {
|
||||
constructor(
|
||||
private readonly token: SemanticCssColorToken,
|
||||
private readonly onSelect: SemanticCssColorSelection,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: ColorSwatch): boolean {
|
||||
return (
|
||||
other.token.from === this.token.from && other.token.to === this.token.to && other.token.value === this.token.value
|
||||
);
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "semantic-css-color-swatch";
|
||||
button.title = `Edit color ${this.token.value}`;
|
||||
button.setAttribute("aria-label", button.title);
|
||||
button.style.backgroundColor = this.token.value;
|
||||
button.addEventListener("click", () => this.onSelect(this.token, button.getBoundingClientRect()));
|
||||
return button;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function colorDecorations(
|
||||
view: EditorViewType,
|
||||
tokens: readonly SemanticCssColorToken[],
|
||||
onSelect: SemanticCssColorSelection,
|
||||
): DecorationSet {
|
||||
const ranges = tokens
|
||||
.filter(
|
||||
(token) =>
|
||||
token.from >= 0 &&
|
||||
token.to <= view.state.doc.length &&
|
||||
view.visibleRanges.some(({ from, to }) => token.to >= from && token.from <= to),
|
||||
)
|
||||
.map((token) => Decoration.widget({ widget: new ColorSwatch(token, onSelect), side: 1 }).range(token.to));
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
function colorExtension(tokens: readonly SemanticCssColorToken[], onSelect: SemanticCssColorSelection): Extension {
|
||||
return [
|
||||
EditorView.baseTheme({
|
||||
".semantic-css-color-swatch": {
|
||||
display: "inline-block",
|
||||
width: "0.75rem",
|
||||
height: "0.75rem",
|
||||
marginInline: "0.25rem",
|
||||
padding: "0",
|
||||
verticalAlign: "middle",
|
||||
border: "1px solid currentColor",
|
||||
borderRadius: "9999px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}),
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorViewType) {
|
||||
this.decorations = colorDecorations(view, tokens, onSelect);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = colorDecorations(update.view, tokens, onSelect);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin) => plugin.decorations },
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function createSemanticCssEditorExtensions(input: {
|
||||
metadata: SemanticCssEditorMetadata;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
colorTokens: readonly SemanticCssColorToken[];
|
||||
onColorSelect: SemanticCssColorSelection;
|
||||
}): Extension {
|
||||
return [
|
||||
autocompletion({ override: [completionSource(input.metadata)] }),
|
||||
hoverExtension(input.metadata),
|
||||
search({ top: true }),
|
||||
keymap.of(searchKeymap),
|
||||
lintGutter(),
|
||||
linter((view) => mapCompilerDiagnostics(view.state.doc.length, input.diagnostics), { delay: 0 }),
|
||||
colorExtension(input.colorTokens, input.onColorSelect),
|
||||
];
|
||||
}
|
||||
|
||||
export async function copySourceToClipboard(source: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(source);
|
||||
}
|
||||
|
||||
export type { SemanticCssEditorMetadata };
|
||||
@@ -0,0 +1,240 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
|
||||
import StylesheetEditorShell, { StylesheetCodeEditor } from "./editor";
|
||||
import { LegacyStylesheetBanner } from "./legacy-banner";
|
||||
import { StylesheetStatus } from "./status";
|
||||
import { useStylesheetStore } from "./store";
|
||||
|
||||
const media = vi.hoisted(() => ({ mobile: false }));
|
||||
|
||||
vi.mock("usehooks-ts", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("usehooks-ts")>()),
|
||||
useMediaQuery: () => media.mobile,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/theme/provider", () => ({
|
||||
useTheme: () => ({ theme: "light" }),
|
||||
}));
|
||||
|
||||
const error: SemanticCssDiagnostic = {
|
||||
code: "SEMANTIC_CSS_UNKNOWN_PROPERTY",
|
||||
severity: "error",
|
||||
message: "Unknown property",
|
||||
range: {
|
||||
start: { line: 2, column: 3, offset: 17 },
|
||||
end: { line: 2, column: 9, offset: 23 },
|
||||
},
|
||||
};
|
||||
|
||||
const guideName = /read the applying custom styles guide.*opens in new tab/i;
|
||||
|
||||
const expectGuideLink = (root: HTMLElement) => {
|
||||
const link = within(root).getByRole("link", { name: guideName });
|
||||
expect(link).toHaveAttribute("href", "https://docs.rxresu.me/applying-custom-styles");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
Object.defineProperty(Element.prototype, "getAnimations", { configurable: true, value: () => [] });
|
||||
});
|
||||
|
||||
const renderWithI18n = (element: React.ReactNode) => render(<I18nProvider i18n={i18n}>{element}</I18nProvider>);
|
||||
|
||||
describe("stylesheet editor status", () => {
|
||||
it("shows that invalid source keeps the last valid preview", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="semantic" status="error" diagnostics={[error]} />);
|
||||
|
||||
expect(screen.getByText(/preview and export use the last valid version/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Unknown property")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a valid legacy draft as ready to activate", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[]} />);
|
||||
|
||||
expect(screen.getByText("Ready to activate")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Applied")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels legacy warnings without claiming they are applied", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[{ ...error, severity: "warning" }]} />);
|
||||
|
||||
expect(screen.getByText("Ready to activate with warnings")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Applied with warnings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables activation while the converted draft has errors", () => {
|
||||
renderWithI18n(<LegacyStylesheetBanner disabled onActivate={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /activate semantic css/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StylesheetCodeEditor", () => {
|
||||
it("owns one LTR EditorView and ignores externally replaced documents", () => {
|
||||
const onChange = vi.fn();
|
||||
const destroy = vi.spyOn(EditorView.prototype, "destroy");
|
||||
const props = {
|
||||
diagnostics: [] as const,
|
||||
theme: "light" as const,
|
||||
onChange,
|
||||
onUndo: vi.fn(),
|
||||
onRedo: vi.fn(),
|
||||
};
|
||||
const { container, rerender, unmount } = render(
|
||||
<div style={{ height: 200 }}>
|
||||
<StylesheetCodeEditor value="@version 1;\n" {...props} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".cm-editor")).toHaveLength(1);
|
||||
expect(container.querySelector(".cm-editor")).toHaveAttribute("dir", "ltr");
|
||||
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute("dir", "ltr");
|
||||
|
||||
rerender(
|
||||
<div style={{ height: 200 }}>
|
||||
<StylesheetCodeEditor value={"@version 1;\nsection { color: red; }\n"} {...props} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveTextContent("color: red");
|
||||
|
||||
rerender(
|
||||
<div style={{ height: 200 }}>
|
||||
<StylesheetCodeEditor
|
||||
value={"@version 1;\nsection { color: red; }\n"}
|
||||
{...props}
|
||||
diagnostics={[error]}
|
||||
theme="dark"
|
||||
readOnly
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute(
|
||||
"contenteditable",
|
||||
"false",
|
||||
);
|
||||
expect(container.querySelector(".cm-gutter-lint")).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
destroy.mockRestore();
|
||||
});
|
||||
|
||||
it("reuses one React color picker for compiler-confirmed swatches", async () => {
|
||||
const source = "section { color: #f00; background-color: #fff; }";
|
||||
const first = source.indexOf("#f00");
|
||||
const second = source.indexOf("#fff");
|
||||
const { container } = render(
|
||||
<StylesheetCodeEditor
|
||||
value={source}
|
||||
diagnostics={[]}
|
||||
colorTokens={[
|
||||
{ from: first, to: first + 4, value: "#f00" },
|
||||
{ from: second, to: second + 4, value: "#fff" },
|
||||
]}
|
||||
theme="light"
|
||||
onChange={vi.fn()}
|
||||
onUndo={vi.fn()}
|
||||
onRedo={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const swatches = container.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
|
||||
expect(swatches).toHaveLength(2);
|
||||
|
||||
swatches[0]?.click();
|
||||
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
|
||||
swatches[1]?.click();
|
||||
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("StylesheetEditorShell", () => {
|
||||
it("links desktop editor help to the Semantic CSS language reference", () => {
|
||||
media.mobile = false;
|
||||
const { container } = render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expectGuideLink(container);
|
||||
});
|
||||
|
||||
it("makes the editor and mutation controls read-only while a restore is pending", () => {
|
||||
media.mobile = false;
|
||||
useStylesheetStore.setState({
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
diagnostics: [],
|
||||
status: "idle",
|
||||
canUndo: true,
|
||||
canRedo: true,
|
||||
restoreLocked: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute(
|
||||
"contenteditable",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Activate Semantic CSS" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Undo stylesheet edit" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Redo stylesheet edit" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Format stylesheet" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Reset to applied stylesheet" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("moves the only visible editor into a titled mobile sheet", async () => {
|
||||
media.mobile = true;
|
||||
useStylesheetStore.setState({
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
diagnostics: [{ ...error, severity: "warning" }],
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".cm-editor")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open focus mode" }));
|
||||
|
||||
const sheet = await screen.findByRole("dialog");
|
||||
expect(within(sheet).getByRole("heading", { name: "Semantic CSS stylesheet" })).toBeInTheDocument();
|
||||
expect(within(sheet).getByRole("button", { name: "Activate Semantic CSS" })).toBeInTheDocument();
|
||||
expect(within(sheet).getByRole("toolbar", { name: "Stylesheet editor" })).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Ready to activate with warnings")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Unknown property")).toBeInTheDocument();
|
||||
expectGuideLink(sheet);
|
||||
expect(document.querySelectorAll(".cm-editor")).toHaveLength(1);
|
||||
media.mobile = false;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import type { SemanticCssColorToken } from "./color-tokens";
|
||||
import type { SemanticCssEditorMetadata } from "./protocol";
|
||||
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { Annotation, Compartment, EditorState, Prec, Transaction } from "@codemirror/state";
|
||||
import {
|
||||
drawSelection,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from "@codemirror/view";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BookOpenIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useTheme } from "@/features/theme/provider";
|
||||
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
|
||||
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
|
||||
import { enterStylesheetFocusMode } from "./focus-mode";
|
||||
import { formatEditorDocument } from "./formatter";
|
||||
import { LegacyStylesheetBanner } from "./legacy-banner";
|
||||
import { StylesheetStatus } from "./status";
|
||||
import { useStylesheetStore } from "./store";
|
||||
import { StylesheetToolbar } from "./toolbar";
|
||||
|
||||
const externalReplacement = Annotation.define<boolean>();
|
||||
const emptyMetadata: SemanticCssEditorMetadata = {
|
||||
semanticTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
|
||||
templateParts: [],
|
||||
};
|
||||
|
||||
type EditorCompartments = {
|
||||
theme: Compartment;
|
||||
readOnly: Compartment;
|
||||
intelligence: Compartment;
|
||||
};
|
||||
|
||||
const editorTheme = (dark: boolean): Extension =>
|
||||
EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
height: "100%",
|
||||
backgroundColor: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
direction: "ltr",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
lineHeight: "1.5",
|
||||
},
|
||||
".cm-content": { minHeight: "100%", padding: "0.75rem 0" },
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--muted)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "var(--accent)",
|
||||
},
|
||||
"&.cm-focused": { outline: "none" },
|
||||
},
|
||||
{ dark },
|
||||
);
|
||||
|
||||
const readOnlyExtensions = (readOnly: boolean): Extension => [
|
||||
EditorState.readOnly.of(readOnly),
|
||||
EditorView.editable.of(!readOnly),
|
||||
];
|
||||
|
||||
export type StylesheetCodeEditorProps = {
|
||||
value: string;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
colorTokens?: readonly SemanticCssColorToken[];
|
||||
metadata?: SemanticCssEditorMetadata;
|
||||
theme: "light" | "dark";
|
||||
readOnly?: boolean;
|
||||
label?: string;
|
||||
onChange(value: string): void;
|
||||
onFocusChange?(focused: boolean): void;
|
||||
onReady?(view: EditorView | null): void;
|
||||
onUndo(): void;
|
||||
onRedo(): void;
|
||||
};
|
||||
|
||||
export function StylesheetCodeEditor({
|
||||
value,
|
||||
diagnostics,
|
||||
colorTokens = [],
|
||||
metadata = emptyMetadata,
|
||||
theme,
|
||||
readOnly = false,
|
||||
label = "Semantic CSS stylesheet",
|
||||
onChange,
|
||||
onFocusChange,
|
||||
onReady,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: StylesheetCodeEditorProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const colorTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const openColorPickerRef = useRef(false);
|
||||
const compartmentsRef = useRef<EditorCompartments | null>(null);
|
||||
const initialPropsRef = useRef({ value, diagnostics, colorTokens, metadata, theme, readOnly, label });
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onFocusChangeRef = useRef(onFocusChange);
|
||||
const onReadyRef = useRef(onReady);
|
||||
const onUndoRef = useRef(onUndo);
|
||||
const onRedoRef = useRef(onRedo);
|
||||
const [selectedColor, setSelectedColor] = useState<{
|
||||
token: SemanticCssColorToken;
|
||||
left: number;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const selectColor = useCallback((token: SemanticCssColorToken, rect: DOMRect) => {
|
||||
const hostRect = hostRef.current?.getBoundingClientRect();
|
||||
if (!hostRect) return;
|
||||
openColorPickerRef.current = true;
|
||||
setSelectedColor({ token, left: rect.left - hostRect.left, top: rect.top - hostRect.top });
|
||||
}, []);
|
||||
|
||||
onChangeRef.current = onChange;
|
||||
onFocusChangeRef.current = onFocusChange;
|
||||
onReadyRef.current = onReady;
|
||||
onUndoRef.current = onUndo;
|
||||
onRedoRef.current = onRedo;
|
||||
|
||||
useEffect(() => {
|
||||
const parent = hostRef.current;
|
||||
if (!parent) return;
|
||||
const initial = initialPropsRef.current;
|
||||
|
||||
const compartments: EditorCompartments = {
|
||||
theme: new Compartment(),
|
||||
readOnly: new Compartment(),
|
||||
intelligence: new Compartment(),
|
||||
};
|
||||
compartmentsRef.current = compartments;
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
doc: initial.value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightSpecialChars(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
css(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
EditorView.editorAttributes.of({ dir: "ltr" }),
|
||||
EditorView.contentAttributes.of({ "aria-label": initial.label, dir: "ltr", spellcheck: "false" }),
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-z",
|
||||
run: () => {
|
||||
onUndoRef.current();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod-Shift-z",
|
||||
run: () => {
|
||||
onRedoRef.current();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod-y",
|
||||
run: () => {
|
||||
onRedoRef.current();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
keymap.of([indentWithTab, ...defaultKeymap]),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocusChangeRef.current?.(true);
|
||||
},
|
||||
blur: () => {
|
||||
onFocusChangeRef.current?.(false);
|
||||
},
|
||||
}),
|
||||
compositionAwareDocumentListener(
|
||||
(source) => onChangeRef.current(source),
|
||||
(update) => update.transactions.some((transaction) => transaction.annotation(externalReplacement)),
|
||||
),
|
||||
compartments.theme.of(editorTheme(initial.theme === "dark")),
|
||||
compartments.readOnly.of(readOnlyExtensions(initial.readOnly)),
|
||||
compartments.intelligence.of(
|
||||
createSemanticCssEditorExtensions({
|
||||
metadata: initial.metadata,
|
||||
diagnostics: initial.diagnostics,
|
||||
colorTokens: initial.colorTokens,
|
||||
onColorSelect: selectColor,
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
viewRef.current = view;
|
||||
onReadyRef.current?.(view);
|
||||
|
||||
return () => {
|
||||
onReadyRef.current?.(null);
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
compartmentsRef.current = null;
|
||||
};
|
||||
}, [selectColor]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
const compartments = compartmentsRef.current;
|
||||
if (!view || !compartments) return;
|
||||
view.dispatch({ effects: compartments.theme.reconfigure(editorTheme(theme === "dark")) });
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
const compartments = compartmentsRef.current;
|
||||
if (!view || !compartments) return;
|
||||
view.dispatch({ effects: compartments.readOnly.reconfigure(readOnlyExtensions(readOnly)) });
|
||||
}, [readOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
const compartments = compartmentsRef.current;
|
||||
if (!view || !compartments) return;
|
||||
view.dispatch({
|
||||
effects: compartments.intelligence.reconfigure(
|
||||
createSemanticCssEditorExtensions({
|
||||
metadata,
|
||||
diagnostics,
|
||||
colorTokens,
|
||||
onColorSelect: selectColor,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}, [colorTokens, diagnostics, metadata, selectColor]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || view.state.doc.toString() === value) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
annotations: externalReplacement.of(true),
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColor || !openColorPickerRef.current) return;
|
||||
openColorPickerRef.current = false;
|
||||
queueMicrotask(() => colorTriggerRef.current?.click());
|
||||
}, [selectedColor]);
|
||||
|
||||
const updateColor = (value: string) => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !selectedColor) return;
|
||||
const { from, to } = selectedColor.token;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: value },
|
||||
annotations: Transaction.userEvent.of("input"),
|
||||
});
|
||||
setSelectedColor((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
token: { from, to: from + value.length, value },
|
||||
}
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className="relative h-full overflow-hidden rounded-md border text-xs" dir="ltr">
|
||||
{selectedColor && (
|
||||
<div className="pointer-events-none absolute z-20" style={{ left: selectedColor.left, top: selectedColor.top }}>
|
||||
<ColorPicker
|
||||
value={selectedColor.token.value}
|
||||
onChange={updateColor}
|
||||
trigger={
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
ref={colorTriggerRef}
|
||||
data-semantic-css-color-picker-trigger=""
|
||||
type="button"
|
||||
title={t`Edit color ${selectedColor.token.value}`}
|
||||
aria-label={t`Edit color ${selectedColor.token.value}`}
|
||||
className="pointer-events-auto size-3 rounded-full border border-foreground/40"
|
||||
style={{ backgroundColor: selectedColor.token.value }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type StylesheetEditorShellProps = {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
|
||||
const { theme } = useTheme();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
|
||||
const [focusOpen, setFocusOpen] = useState(false);
|
||||
const restoreDesktopRef = useRef<(() => void) | null>(null);
|
||||
const mode = useStylesheetStore((state) => state.mode);
|
||||
const source = useStylesheetStore((state) => state.source.text);
|
||||
const applied = useStylesheetStore((state) => state.applied.text);
|
||||
const diagnostics = useStylesheetStore((state) => state.diagnostics);
|
||||
const colorTokens = useStylesheetStore((state) => state.colorTokens);
|
||||
const metadata = useStylesheetStore((state) => state.editorMetadata);
|
||||
const status = useStylesheetStore((state) => state.status);
|
||||
const restoreLocked = useStylesheetStore((state) => state.restoreLocked);
|
||||
const canUndo = useStylesheetStore((state) => state.canUndo);
|
||||
const canRedo = useStylesheetStore((state) => state.canRedo);
|
||||
const setSourceText = useStylesheetStore((state) => state.setSourceText);
|
||||
const setFocused = useStylesheetStore((state) => state.setFocused);
|
||||
const activate = useStylesheetStore((state) => state.activate);
|
||||
const undo = useStylesheetStore((state) => state.undo);
|
||||
const redo = useStylesheetStore((state) => state.redo);
|
||||
const refreshIntelligence = useStylesheetStore((state) => state.refreshIntelligence);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
const hasErrors = status === "error" || diagnostics.some(({ severity }) => severity === "error");
|
||||
const isChecking = status === "compiling" || status === "preflighting" || status === "saving";
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
restoreDesktopRef.current?.();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshIntelligence();
|
||||
}, [refreshIntelligence]);
|
||||
|
||||
const toggleFocus = () => {
|
||||
if (isMobile) {
|
||||
setFocusOpen((open) => !open);
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoreDesktopRef.current) {
|
||||
restoreDesktopRef.current();
|
||||
restoreDesktopRef.current = null;
|
||||
setFocusOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { rightSidebar, layout, setLayout } = useBuilderSidebarStore.getState();
|
||||
restoreDesktopRef.current = enterStylesheetFocusMode({
|
||||
rightPanel: rightSidebar,
|
||||
currentLayout: layout,
|
||||
setLayout,
|
||||
});
|
||||
setFocusOpen(true);
|
||||
};
|
||||
|
||||
const editor = (
|
||||
<StylesheetCodeEditor
|
||||
value={source}
|
||||
diagnostics={diagnostics}
|
||||
colorTokens={colorTokens}
|
||||
metadata={metadata}
|
||||
theme={theme}
|
||||
readOnly={readOnly || restoreLocked}
|
||||
label={t`Semantic CSS stylesheet`}
|
||||
onChange={setSourceText}
|
||||
onFocusChange={setFocused}
|
||||
onReady={(view) => {
|
||||
editorViewRef.current = view;
|
||||
}}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
/>
|
||||
);
|
||||
const editorChrome = (
|
||||
<div className="space-y-3">
|
||||
{mode === "legacy" && (
|
||||
<LegacyStylesheetBanner disabled={restoreLocked || hasErrors || isChecking} onActivate={activate} />
|
||||
)}
|
||||
|
||||
<StylesheetToolbar
|
||||
source={source}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
focused={focusOpen}
|
||||
disabled={restoreLocked}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onFormat={() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) void formatEditorDocument(view).catch(() => undefined);
|
||||
}}
|
||||
onReset={() => setSourceText(applied)}
|
||||
onFocusToggle={toggleFocus}
|
||||
/>
|
||||
|
||||
<p className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<BookOpenIcon aria-hidden="true" className="shrink-0" />
|
||||
<span>
|
||||
<Trans>Not sure what to write?</Trans>{" "}
|
||||
<a
|
||||
className="text-primary underline underline-offset-4"
|
||||
href="https://docs.rxresu.me/applying-custom-styles"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans>Read the Applying Custom Styles guide.</Trans>
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
(<Trans>opens in new tab</Trans>)
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className={focusOpen ? (isMobile ? "h-[55svh]" : "h-[calc(100svh-14rem)]") : "h-72"}>{editor}</div>
|
||||
|
||||
<StylesheetStatus mode={mode} status={status} diagnostics={diagnostics} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!(isMobile && focusOpen) && editorChrome}
|
||||
<Sheet open={isMobile && focusOpen} onOpenChange={setFocusOpen}>
|
||||
<SheetContent side="right" className="w-full max-w-full gap-3 overflow-hidden p-4 sm:max-w-full">
|
||||
<SheetTitle>
|
||||
<Trans>Semantic CSS stylesheet</Trans>
|
||||
</SheetTitle>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{isMobile && focusOpen ? editorChrome : null}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StylesheetEditorShell;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { enterStylesheetFocusMode } from "./focus-mode";
|
||||
|
||||
describe("stylesheet focus mode", () => {
|
||||
it("resizes and restores the desktop right panel", () => {
|
||||
const currentLayout = { left: 22, artboard: 56, right: 22 };
|
||||
const resize = vi.fn();
|
||||
const setLayout = vi.fn();
|
||||
const rightPanel = { current: { resize } };
|
||||
|
||||
const restore = enterStylesheetFocusMode({ rightPanel, currentLayout, setLayout });
|
||||
|
||||
expect(resize).toHaveBeenCalledWith("45%");
|
||||
restore();
|
||||
expect(resize).toHaveBeenLastCalledWith("22%");
|
||||
expect(setLayout).toHaveBeenCalledWith(currentLayout);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { BuilderLayout } from "@/routes/builder/$resumeId/-store/sidebar";
|
||||
|
||||
type FocusPanel = {
|
||||
current: { resize(size: string): void } | null;
|
||||
};
|
||||
|
||||
export type StylesheetFocusModeInput = {
|
||||
rightPanel: FocusPanel | null;
|
||||
currentLayout: BuilderLayout;
|
||||
setLayout(layout: BuilderLayout): void;
|
||||
};
|
||||
|
||||
export function enterStylesheetFocusMode({
|
||||
rightPanel,
|
||||
currentLayout,
|
||||
setLayout,
|
||||
}: StylesheetFocusModeInput): () => void {
|
||||
rightPanel?.current?.resize("45%");
|
||||
|
||||
return () => {
|
||||
rightPanel?.current?.resize(`${currentLayout.right}%`);
|
||||
setLayout(currentLayout);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { formatEditorDocument, formatSemanticCss } from "./formatter";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const view of views.splice(0)) view.destroy();
|
||||
});
|
||||
|
||||
describe("Semantic CSS formatter", () => {
|
||||
it("preserves comments and translates the cursor", async () => {
|
||||
const result = await formatSemanticCss("/* keep */ section{color:red}", 18);
|
||||
|
||||
expect(result.formatted).toContain("/* keep */");
|
||||
expect(result.formatted).toContain("section {");
|
||||
expect(result.cursorOffset).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("applies an explicit format as one editor transaction", async () => {
|
||||
const transactions = vi.fn();
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "/* keep */ section{color:red}",
|
||||
selection: { anchor: 18 },
|
||||
extensions: EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) transactions(update.transactions);
|
||||
}),
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
|
||||
await formatEditorDocument(view);
|
||||
|
||||
expect(view.state.doc.toString()).toContain("section {");
|
||||
expect(transactions).toHaveBeenCalledOnce();
|
||||
expect(transactions.mock.calls[0]?.[0]).toHaveLength(1);
|
||||
expect(view.state.selection.main.head).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("leaves malformed source untouched when formatting fails", async () => {
|
||||
const dispatch = vi.spyOn(EditorView.prototype, "dispatch");
|
||||
const view = new EditorView({ doc: "section {" });
|
||||
views.push(view);
|
||||
|
||||
await expect(formatEditorDocument(view)).rejects.toThrow(/css|syntax|unexpected/i);
|
||||
expect(view.state.doc.toString()).toBe("section {");
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
dispatch.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { EditorSelection, Transaction } from "@codemirror/state";
|
||||
|
||||
export type FormattedSemanticCss = {
|
||||
formatted: string;
|
||||
cursorOffset: number;
|
||||
};
|
||||
|
||||
export async function formatSemanticCss(source: string, cursorOffset: number): Promise<FormattedSemanticCss> {
|
||||
const [{ formatWithCursor }, { default: postcss }] = await Promise.all([
|
||||
import("prettier/standalone"),
|
||||
import("prettier/plugins/postcss"),
|
||||
]);
|
||||
return formatWithCursor(source, {
|
||||
parser: "css",
|
||||
plugins: [postcss],
|
||||
cursorOffset,
|
||||
useTabs: true,
|
||||
tabWidth: 4,
|
||||
printWidth: 120,
|
||||
});
|
||||
}
|
||||
|
||||
export async function formatEditorDocument(view: EditorView): Promise<void> {
|
||||
const source = view.state.doc.toString();
|
||||
const result = await formatSemanticCss(source, view.state.selection.main.head);
|
||||
if (view.state.doc.toString() !== source) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: result.formatted },
|
||||
selection: EditorSelection.cursor(Math.min(result.cursorOffset, result.formatted.length)),
|
||||
annotations: Transaction.userEvent.of("input.format"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
|
||||
export type LegacyStylesheetBannerProps = {
|
||||
disabled: boolean;
|
||||
onActivate(): void;
|
||||
};
|
||||
|
||||
export function LegacyStylesheetBanner({ disabled, onActivate }: LegacyStylesheetBannerProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Converted stylesheet draft</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>
|
||||
<Trans>Your legacy styles remain active until you explicitly activate this Semantic CSS draft.</Trans>
|
||||
</p>
|
||||
<Button type="button" size="sm" disabled={disabled} onClick={onActivate}>
|
||||
<Trans>Activate Semantic CSS</Trans>
|
||||
<ArrowRightIcon data-icon="inline-end" />
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function SemanticStylesheetReadOnlyNotice() {
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Semantic styles remain active</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>This instance does not currently allow Semantic CSS editing.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { inspectPdfPageCount } from "./pdf-inspection";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workerDestroy: vi.fn(),
|
||||
getDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
PDFWorker: class {
|
||||
destroy = mocks.workerDestroy;
|
||||
},
|
||||
getDocument: mocks.getDocument,
|
||||
}));
|
||||
|
||||
describe("inspectPdfPageCount", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("inspects a copy through a nested worker without detaching the result buffer", async () => {
|
||||
const destroy = vi.fn();
|
||||
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||
mocks.getDocument.mockReturnValue({ promise: Promise.resolve({ numPages: 3 }), destroy });
|
||||
const pdf = Uint8Array.of(1, 2, 3, 4).buffer;
|
||||
|
||||
await expect(inspectPdfPageCount(pdf, () => nestedWorker)).resolves.toBe(3);
|
||||
|
||||
expect(mocks.getDocument).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.any(ArrayBuffer), worker: expect.any(Object) }),
|
||||
);
|
||||
const inspectedPdf = mocks.getDocument.mock.calls[0]?.[0].data as ArrayBuffer;
|
||||
expect(inspectedPdf).not.toBe(pdf);
|
||||
expect(Array.from(new Uint8Array(inspectedPdf))).toEqual([1, 2, 3, 4]);
|
||||
expect(Array.from(new Uint8Array(pdf))).toEqual([1, 2, 3, 4]);
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("destroys the loading task and nested worker when parsing fails", async () => {
|
||||
const destroy = vi.fn();
|
||||
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||
mocks.getDocument.mockReturnValue({ promise: Promise.reject(new Error("invalid PDF")), destroy });
|
||||
|
||||
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("invalid PDF");
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("terminates the nested worker even when loading-task cleanup fails", async () => {
|
||||
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||
mocks.getDocument.mockReturnValue({
|
||||
promise: Promise.resolve({ numPages: 1 }),
|
||||
destroy: vi.fn().mockRejectedValue(new Error("cleanup failed")),
|
||||
});
|
||||
|
||||
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("cleanup failed");
|
||||
|
||||
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
let pdfModule: Promise<typeof import("pdfjs-dist/legacy/build/pdf.mjs")>;
|
||||
|
||||
const loadPdfModule = () => (pdfModule ??= import("pdfjs-dist/legacy/build/pdf.mjs"));
|
||||
|
||||
const createNestedWorker = () =>
|
||||
new Worker(new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url), {
|
||||
type: "module",
|
||||
name: "semantic-css-pdfjs",
|
||||
});
|
||||
|
||||
export async function initializePdfInspection(): Promise<void> {
|
||||
await loadPdfModule();
|
||||
}
|
||||
|
||||
export async function inspectPdfPageCount(
|
||||
pdf: ArrayBuffer,
|
||||
createWorker: () => Worker = createNestedWorker,
|
||||
): Promise<number> {
|
||||
const { PDFWorker, getDocument } = await loadPdfModule();
|
||||
const nestedWorker = createWorker();
|
||||
const WorkerWithPort = PDFWorker as unknown as new (options: { port: Worker }) => InstanceType<typeof PDFWorker>;
|
||||
const worker = new WorkerWithPort({ port: nestedWorker });
|
||||
let loadingTask: ReturnType<typeof getDocument> | undefined;
|
||||
|
||||
try {
|
||||
// PDF.js transfers its input to the nested worker and detaches the buffer.
|
||||
// Keep the caller's buffer intact so preflight can return those same bytes.
|
||||
loadingTask = getDocument({ data: pdf.slice(0), worker });
|
||||
const document = await loadingTask.promise;
|
||||
return document.numPages;
|
||||
} finally {
|
||||
try {
|
||||
if (loadingTask) await loadingTask.destroy();
|
||||
else worker.destroy();
|
||||
} finally {
|
||||
nestedWorker.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PreflightWorkerRequest } from "./protocol";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
renderPreflightPdf: vi.fn(),
|
||||
initializePdfInspection: vi.fn(async () => undefined),
|
||||
inspectPdfPageCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/pdf/preflight", () => ({
|
||||
renderPreflightPdf: mocks.renderPreflightPdf,
|
||||
}));
|
||||
|
||||
vi.mock("./pdf-inspection", () => ({
|
||||
initializePdfInspection: mocks.initializePdfInspection,
|
||||
inspectPdfPageCount: mocks.inspectPdfPageCount,
|
||||
}));
|
||||
|
||||
describe("stylesheet preflight worker", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("serializes schema errors into a correlated preflight error packet", async () => {
|
||||
let handler: ((event: MessageEvent<PreflightWorkerRequest>) => Promise<void>) | undefined;
|
||||
const postMessage = vi.fn();
|
||||
vi.stubGlobal("self", {
|
||||
postMessage,
|
||||
addEventListener: vi.fn((_type, listener) => {
|
||||
handler = listener as typeof handler;
|
||||
}),
|
||||
});
|
||||
const issues = [{ path: ["customSections", 0, "items", 0, "company"] }];
|
||||
mocks.renderPreflightPdf.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Invalid resume data"), { name: "ZodError", issues }),
|
||||
);
|
||||
vi.resetModules();
|
||||
await import("./preflight.worker");
|
||||
|
||||
await handler?.({
|
||||
data: {
|
||||
type: "preflight",
|
||||
requestId: 7,
|
||||
editGeneration: 3,
|
||||
input: {} as never,
|
||||
limits: {} as never,
|
||||
},
|
||||
} as unknown as MessageEvent<PreflightWorkerRequest>);
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith({
|
||||
type: "preflight_error",
|
||||
requestId: 7,
|
||||
editGeneration: 3,
|
||||
cause: { name: "ZodError", message: "Invalid resume data", issues },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { PdfPreflightFailure } from "@reactive-resume/pdf/preflight";
|
||||
import type {
|
||||
PreflightWorkerError,
|
||||
PreflightWorkerRequest,
|
||||
PreflightWorkerResponse,
|
||||
SerializedPreflightCause,
|
||||
} from "./protocol";
|
||||
import { Buffer } from "buffer";
|
||||
import { initializePdfInspection, inspectPdfPageCount } from "./pdf-inspection";
|
||||
import { getPreflightTransferables } from "./protocol";
|
||||
|
||||
Object.assign(globalThis, { Buffer });
|
||||
|
||||
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
||||
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
||||
return;
|
||||
}
|
||||
return { name: cause.name, message: cause.message, issues: cause.issues };
|
||||
};
|
||||
|
||||
const initialization = Promise.all([import("@reactive-resume/pdf/preflight"), initializePdfInspection()] as const);
|
||||
void initialization.then(() => self.postMessage({ type: "preflight_ready" }));
|
||||
|
||||
self.addEventListener("message", async ({ data }: MessageEvent<PreflightWorkerRequest>) => {
|
||||
if (data.type !== "preflight") return;
|
||||
const [{ renderPreflightPdf }] = await initialization;
|
||||
let rendered: Awaited<ReturnType<typeof renderPreflightPdf>>;
|
||||
|
||||
try {
|
||||
rendered = await renderPreflightPdf(data.input, data.limits);
|
||||
} catch (cause) {
|
||||
const serializedCause = serializeZodCause(cause);
|
||||
if (serializedCause) {
|
||||
const response: PreflightWorkerError = {
|
||||
type: "preflight_error",
|
||||
requestId: data.requestId,
|
||||
editGeneration: data.editGeneration,
|
||||
cause: serializedCause,
|
||||
};
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
const result = failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.");
|
||||
const response: PreflightWorkerResponse = {
|
||||
type: "preflight_result",
|
||||
requestId: data.requestId,
|
||||
editGeneration: data.editGeneration,
|
||||
result,
|
||||
};
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
|
||||
let result: PreflightWorkerResponse["result"];
|
||||
if (!rendered.ok) {
|
||||
result = rendered;
|
||||
} else if (rendered.bytes.byteLength > data.limits.maxBytes) {
|
||||
result = failure("STYLESHEET_PREFLIGHT_BYTE_LIMIT", "The PDF exceeds the preflight byte limit.");
|
||||
} else {
|
||||
try {
|
||||
const pdf = Uint8Array.from(rendered.bytes).buffer;
|
||||
const pageCount = await inspectPdfPageCount(pdf);
|
||||
result =
|
||||
pageCount > data.limits.maxPages
|
||||
? failure("STYLESHEET_PREFLIGHT_PAGE_LIMIT", "The PDF exceeds the preflight page limit.")
|
||||
: {
|
||||
ok: true,
|
||||
pageCount,
|
||||
byteCount: pdf.byteLength,
|
||||
diagnostics: rendered.diagnostics,
|
||||
pdf,
|
||||
};
|
||||
} catch {
|
||||
result = failure("STYLESHEET_PREFLIGHT_PARSE_FAILED", "The generated PDF could not be inspected.");
|
||||
}
|
||||
}
|
||||
|
||||
const response: PreflightWorkerResponse = {
|
||||
type: "preflight_result",
|
||||
requestId: data.requestId,
|
||||
editGeneration: data.editGeneration,
|
||||
result,
|
||||
};
|
||||
self.postMessage(response, { transfer: getPreflightTransferables(response) });
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
BrowserPdfPreflightResult,
|
||||
PdfPreflightPageLimits,
|
||||
StylesheetPreflightInput,
|
||||
} from "@reactive-resume/pdf/preflight";
|
||||
import type {
|
||||
AuthoredPageContext,
|
||||
BaseSettingsSnapshot,
|
||||
SemanticCssDiagnostic,
|
||||
SemanticNode,
|
||||
StyleProgram,
|
||||
} from "@reactive-resume/resume/stylesheet";
|
||||
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { SemanticCssColorToken } from "./color-tokens";
|
||||
|
||||
export type SemanticCssEditorMetadata = {
|
||||
semanticTree: SemanticNode;
|
||||
templateParts: readonly string[];
|
||||
};
|
||||
|
||||
export type CompileWorkerInput = {
|
||||
editGeneration: number;
|
||||
source: StylesheetSource;
|
||||
semanticTree: SemanticNode;
|
||||
baseSettings: BaseSettingsSnapshot;
|
||||
pages: readonly AuthoredPageContext[];
|
||||
};
|
||||
|
||||
export type CompileWorkerRequest = CompileWorkerInput & {
|
||||
type: "compile";
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
export type CompileWorkerResponse = {
|
||||
type: "compile_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
program: StyleProgram | null;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
colorTokens?: readonly SemanticCssColorToken[];
|
||||
};
|
||||
|
||||
type PreflightLimits = PdfPreflightPageLimits & {
|
||||
maxPages: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
export type PreflightWorkerInput = {
|
||||
editGeneration: number;
|
||||
input: StylesheetPreflightInput;
|
||||
limits: PreflightLimits;
|
||||
};
|
||||
|
||||
export type PreflightWorkerRequest = PreflightWorkerInput & {
|
||||
type: "preflight";
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
export type PreflightWorkerResponse = {
|
||||
type: "preflight_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
result: BrowserPdfPreflightResult;
|
||||
};
|
||||
|
||||
export type SerializedPreflightCause = {
|
||||
name: string;
|
||||
message: string;
|
||||
issues: readonly unknown[];
|
||||
};
|
||||
|
||||
export type PreflightWorkerError = {
|
||||
type: "preflight_error";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
cause: SerializedPreflightCause;
|
||||
};
|
||||
|
||||
export type PreflightWorkerReady = {
|
||||
type: "preflight_ready";
|
||||
};
|
||||
|
||||
export function getPreflightTransferables(response: PreflightWorkerResponse): Transferable[] {
|
||||
return response.result.ok ? [response.result.pdf] : [];
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getState: vi.fn(),
|
||||
workers: [] as FakeWorker[],
|
||||
}));
|
||||
|
||||
class FakeWorker {
|
||||
terminated = false;
|
||||
|
||||
constructor() {
|
||||
mocks.workers.push(this);
|
||||
}
|
||||
|
||||
postMessage() {}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
stylesheet: {
|
||||
getState: { call: mocks.getState },
|
||||
mutate: { call: vi.fn() },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const stylesheet = (text: string): SemanticStylesheet => {
|
||||
const source = { languageVersion: 1, text };
|
||||
return { mode: "semantic", source, applied: source };
|
||||
};
|
||||
|
||||
describe("stylesheet store reinitialization", () => {
|
||||
beforeEach(() => {
|
||||
mocks.workers.length = 0;
|
||||
vi.stubGlobal("Worker", FakeWorker);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("suspends edits while a delayed restore is pending, then atomically installs the restored state", async () => {
|
||||
const storeModule = await import("./store");
|
||||
const cleanup = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
let finishRestore: (() => void) | undefined;
|
||||
const delayedRestore = new Promise<void>((resolve) => {
|
||||
finishRestore = resolve;
|
||||
});
|
||||
const restore = async () => {
|
||||
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||
await delayedRestore;
|
||||
return storeModule.replaceStylesheetStoreAfterRestore({
|
||||
resumeId: "resume-1",
|
||||
resumeData: defaultResumeData,
|
||||
initial: { stylesheet: stylesheet("restored"), revision: 9, renderDataVersion: 12 },
|
||||
token,
|
||||
});
|
||||
};
|
||||
|
||||
const pendingRestore = restore();
|
||||
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
|
||||
const editGeneration = storeModule.useStylesheetStore.getState().editGeneration;
|
||||
storeModule.useStylesheetStore.getState().setSourceText("edit while restoring");
|
||||
storeModule.useStylesheetStore.getState().deactivate();
|
||||
storeModule.useStylesheetStore.getState().undo();
|
||||
expect(storeModule.useStylesheetStore.getState().source.text).toBe("old");
|
||||
expect(storeModule.useStylesheetStore.getState().editGeneration).toBe(editGeneration);
|
||||
finishRestore?.();
|
||||
const replaced = await pendingRestore;
|
||||
expect(replaced).toBe(true);
|
||||
expect(mocks.getState).not.toHaveBeenCalled();
|
||||
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
|
||||
resumeId: "resume-1",
|
||||
source: { text: "restored" },
|
||||
applied: { text: "restored" },
|
||||
revision: 9,
|
||||
renderDataVersion: 12,
|
||||
restoreLocked: false,
|
||||
});
|
||||
storeModule.useStylesheetStore.getState().setSourceText("later edit");
|
||||
expect(storeModule.useStylesheetStore.getState().source.text).toBe("later edit");
|
||||
expect(mocks.workers).toHaveLength(4);
|
||||
expect(mocks.workers.slice(0, 2).every((worker) => worker.terminated)).toBe(true);
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(mocks.workers.slice(2).every((worker) => worker.terminated)).toBe(true);
|
||||
expect(storeModule.useStylesheetStore.getState().resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unlocks interaction after a restore request fails", async () => {
|
||||
const storeModule = await import("./store");
|
||||
const cleanup = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
|
||||
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
|
||||
expect(storeModule.unlockStylesheetStoreAfterRestore(token)).toBe(true);
|
||||
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(false);
|
||||
|
||||
storeModule.useStylesheetStore.getState().setSourceText("edit after failure");
|
||||
expect(storeModule.useStylesheetStore.getState().source.text).toBe("edit after failure");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("ignores a stale same-resume restore completion after away-and-back runtime replacement", async () => {
|
||||
const storeModule = await import("./store");
|
||||
const cleanupFirst = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("first"), revision: 1, renderDataVersion: 1 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
const staleToken = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||
|
||||
cleanupFirst();
|
||||
const cleanupSecond = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("second"), revision: 2, renderDataVersion: 2 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
|
||||
const replaced = storeModule.replaceStylesheetStoreAfterRestore({
|
||||
resumeId: "resume-1",
|
||||
resumeData: defaultResumeData,
|
||||
initial: { stylesheet: stylesheet("stale restore"), revision: 3, renderDataVersion: 3 },
|
||||
token: staleToken,
|
||||
});
|
||||
|
||||
expect(replaced).toBe(false);
|
||||
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
|
||||
source: { text: "second" },
|
||||
revision: 2,
|
||||
renderDataVersion: 2,
|
||||
});
|
||||
|
||||
cleanupSecond();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { WarningCircleIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
|
||||
export type StylesheetStatusProps = {
|
||||
mode: "legacy" | "semantic";
|
||||
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
};
|
||||
|
||||
export function StylesheetStatus({ mode, status, diagnostics }: StylesheetStatusProps) {
|
||||
const errors = diagnostics.filter(({ severity }) => severity === "error");
|
||||
const warnings = diagnostics.filter(({ severity }) => severity === "warning");
|
||||
const hasErrors = status === "error" || errors.length > 0;
|
||||
const isPending = status === "compiling" || status === "preflighting" || status === "saving";
|
||||
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
{hasErrors ? (
|
||||
<Badge variant="destructive">
|
||||
<WarningCircleIcon data-icon="inline-start" />
|
||||
<Trans>Error</Trans>
|
||||
</Badge>
|
||||
) : isPending ? (
|
||||
<Badge variant="outline">{mode === "legacy" ? <Trans>Checking draft</Trans> : <Trans>Checking</Trans>}</Badge>
|
||||
) : warnings.length > 0 ? (
|
||||
<Badge variant="secondary">
|
||||
<WarningIcon data-icon="inline-start" />
|
||||
{mode === "legacy" ? <Trans>Ready to activate with warnings</Trans> : <Trans>Applied with warnings</Trans>}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Applied</Trans>}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
<Alert variant="destructive">
|
||||
<WarningCircleIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Stylesheet has errors</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview and export use the last valid version.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{diagnostics.length > 0 && (
|
||||
<ScrollArea className="max-h-32 rounded-md border">
|
||||
<ul className="space-y-2 p-3 text-xs">
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.range.start.offset}`} className="space-y-0.5">
|
||||
<p className="font-medium">{diagnostic.message}</p>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>
|
||||
Line {diagnostic.range.start.line}, column {diagnostic.range.start.column}
|
||||
</Trans>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createStylesheetStoreRuntime } from "./store";
|
||||
|
||||
const source = (text: string): StylesheetSource => ({ languageVersion: 1, text });
|
||||
const stylesheet = (text: string): SemanticStylesheet => ({
|
||||
mode: "semantic",
|
||||
source: source(text),
|
||||
applied: source(text),
|
||||
});
|
||||
|
||||
const initial = {
|
||||
stylesheet: stylesheet("generation zero"),
|
||||
revision: 3,
|
||||
renderDataVersion: 7,
|
||||
};
|
||||
|
||||
type MutationResult = {
|
||||
stylesheet: SemanticStylesheet;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
editGeneration: number;
|
||||
diagnostics: [];
|
||||
};
|
||||
|
||||
describe("stylesheet store runtime", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
it("clears compiler-confirmed color tokens synchronously when same-length source text changes", () => {
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 1_000_000,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
runtime.store.setState({ colorTokens: [{ from: 17, to: 20, value: "red" }] });
|
||||
|
||||
runtime.store.getState().setSourceText("section { color: var; }");
|
||||
|
||||
expect(runtime.store.getState().source.text).toBe("section { color: var; }");
|
||||
expect(runtime.store.getState().colorTokens).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects delayed editor intelligence for a canonically replaced source", async () => {
|
||||
let resolveCompile!: (value: {
|
||||
type: "compile_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
program: { languageVersion: number; rules: [] };
|
||||
diagnostics: [
|
||||
{
|
||||
code: string;
|
||||
severity: "error";
|
||||
message: string;
|
||||
range: {
|
||||
start: { line: number; column: number; offset: number };
|
||||
end: { line: number; column: number; offset: number };
|
||||
};
|
||||
},
|
||||
];
|
||||
colorTokens: [{ from: number; to: number; value: string }];
|
||||
}) => void;
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
|
||||
resumeData: defaultResumeData,
|
||||
compile: () => new Promise((resolve) => (resolveCompile = resolve)),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
runtime.store.getState().refreshIntelligence();
|
||||
runtime.rebaseCanonical({
|
||||
stylesheet: stylesheet("section { color: blue; }"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
});
|
||||
resolveCompile({
|
||||
type: "compile_result",
|
||||
requestId: 1,
|
||||
editGeneration: 0,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [
|
||||
{
|
||||
code: "OLD_SOURCE",
|
||||
severity: "error",
|
||||
message: "Old source diagnostic",
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 2, offset: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
colorTokens: [{ from: 17, to: 20, value: "red" }],
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("section { color: blue; }"),
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes stale acknowledgements before saving the replaceable pending edit", async () => {
|
||||
const resolvers: Array<(value: MutationResult) => void> = [];
|
||||
const mutate = vi.fn(
|
||||
(_input: unknown) =>
|
||||
new Promise<MutationResult>((resolve) => {
|
||||
resolvers.push((value) => resolve(value));
|
||||
}),
|
||||
);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("generation one");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("generation two");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
resolvers[0]?.({
|
||||
stylesheet: stylesheet("generation one"),
|
||||
revision: 4,
|
||||
renderDataVersion: 8,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
revision: 4,
|
||||
renderDataVersion: 8,
|
||||
source: source("generation two"),
|
||||
applied: source("generation zero"),
|
||||
});
|
||||
expect(mutate).toHaveBeenCalledTimes(2);
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
expectedRevision: 4,
|
||||
expectedRenderDataVersion: 8,
|
||||
editGeneration: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rebases conflicts without dropping the focused local draft", async () => {
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
data: {
|
||||
state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("local unsaved source"),
|
||||
revision: 9,
|
||||
renderDataVersion: 11,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(true);
|
||||
runtime.store.getState().setSourceText("local unsaved source");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
revision: 9,
|
||||
renderDataVersion: 11,
|
||||
source: source("local unsaved source"),
|
||||
});
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({ expectedRevision: 8, expectedRenderDataVersion: 11 });
|
||||
});
|
||||
|
||||
it("keeps the newer pending edit when an older request conflicts", async () => {
|
||||
let rejectFirst!: (error: unknown) => void;
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("newer"),
|
||||
revision: 9,
|
||||
renderDataVersion: 11,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("older");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("newer");
|
||||
await vi.runAllTimersAsync();
|
||||
rejectFirst({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
data: { state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 } },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
transition: "edit_source",
|
||||
editGeneration: 2,
|
||||
source: source("newer"),
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates pending preflight eligibility on content changes and keeps versions monotonic", async () => {
|
||||
let resolveMutation!: (result: MutationResult) => void;
|
||||
let resolveRepreflight!: (result: {
|
||||
type: "preflight_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
result: {
|
||||
ok: true;
|
||||
pageCount: number;
|
||||
byteCount: number;
|
||||
diagnostics: [];
|
||||
pdf: ArrayBuffer;
|
||||
};
|
||||
}) => void;
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new Promise<MutationResult>((resolve) => (resolveMutation = resolve)))
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("newer"),
|
||||
revision: 11,
|
||||
renderDataVersion: 20,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRepreflight = resolve;
|
||||
}),
|
||||
);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight,
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("older");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("newer");
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 10,
|
||||
renderDataVersion: 20,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(preflight).toHaveBeenCalledTimes(3);
|
||||
|
||||
resolveMutation({
|
||||
stylesheet: stylesheet("older"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(runtime.store.getState()).toMatchObject({ revision: 10, renderDataVersion: 20 });
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRepreflight({
|
||||
type: "preflight_result",
|
||||
requestId: 3,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
source: source("newer"),
|
||||
expectedRevision: 10,
|
||||
expectedRenderDataVersion: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not requeue an invalidated in-flight candidate after conflict", async () => {
|
||||
let rejectMutation!: (error: unknown) => void;
|
||||
let resolveRepreflight!: (result: {
|
||||
type: "preflight_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
result: {
|
||||
ok: true;
|
||||
pageCount: number;
|
||||
byteCount: number;
|
||||
diagnostics: [];
|
||||
pdf: ArrayBuffer;
|
||||
};
|
||||
}) => void;
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new Promise<MutationResult>((_resolve, reject) => (rejectMutation = reject)))
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("newer"),
|
||||
revision: 11,
|
||||
renderDataVersion: 20,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRepreflight = resolve;
|
||||
}),
|
||||
);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight,
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("older");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("newer");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 10,
|
||||
renderDataVersion: 20,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
rejectMutation({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
data: { state: { stylesheet: stylesheet("remote"), revision: 10, renderDataVersion: 20 } },
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRepreflight({
|
||||
type: "preflight_result",
|
||||
requestId: 3,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
source: source("newer"),
|
||||
expectedRevision: 10,
|
||||
expectedRenderDataVersion: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles a deferred focused canonical source on blur", () => {
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(true);
|
||||
runtime.rebaseCanonical({
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 8,
|
||||
renderDataVersion: 11,
|
||||
});
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("generation zero"),
|
||||
applied: source("remote"),
|
||||
revision: 8,
|
||||
renderDataVersion: 11,
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(false);
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("remote"),
|
||||
applied: source("remote"),
|
||||
});
|
||||
});
|
||||
|
||||
it("persists invalid source while preserving applied and restores stylesheet history separately", async () => {
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source: source("invalid {"),
|
||||
applied: source("generation zero"),
|
||||
},
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 1,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "PARSE_ERROR",
|
||||
severity: "error",
|
||||
message: "Invalid",
|
||||
range: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 1, offset: 0 } },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("generation zero"),
|
||||
revision: 5,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration, source: candidate }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: candidate.text === "invalid {" ? null : { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("invalid {");
|
||||
await vi.runAllTimersAsync();
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("invalid {"),
|
||||
applied: source("generation zero"),
|
||||
});
|
||||
expect(mutate.mock.calls[0]?.[0]).toMatchObject({ transition: "edit_source", source: source("invalid {") });
|
||||
|
||||
runtime.store.getState().undo();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
transition: "restore_history",
|
||||
restore: stylesheet("generation zero"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish historical applied state before restore acknowledgement", async () => {
|
||||
const mutate = vi.fn(() => new Promise<MutationResult>(() => {}));
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: stylesheet("current applied") },
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
runtime.store.setState({
|
||||
source: source("local invalid"),
|
||||
applied: source("current applied"),
|
||||
undoStack: [stylesheet("historical")],
|
||||
canUndo: true,
|
||||
});
|
||||
|
||||
runtime.store.getState().undo();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().source).toEqual(source("historical"));
|
||||
expect(runtime.store.getState().applied).toEqual(source("current applied"));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transition: "restore_history", restore: stylesheet("historical") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries the focused draft against a newer content render-data version", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
stylesheet: stylesheet("local"),
|
||||
revision: 4,
|
||||
renderDataVersion: 12,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(true);
|
||||
runtime.store.getState().setSourceText("local");
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 9,
|
||||
renderDataVersion: 12,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().source).toEqual(source("local"));
|
||||
expect(mutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a queued draft when content changes after editor blur", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
stylesheet: stylesheet("local"),
|
||||
revision: 10,
|
||||
renderDataVersion: 12,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("local");
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 9,
|
||||
renderDataVersion: 12,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().source).toEqual(source("local"));
|
||||
expect(mutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("queues activation only after browser preflight succeeds", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
stylesheet: stylesheet("generation zero"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
type: "preflight_result",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
result: {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
|
||||
message: "failed",
|
||||
diagnostics: [],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
type: "preflight_result",
|
||||
requestId: 2,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: { ...initial.stylesheet, mode: "legacy" } },
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight,
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().activate();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
|
||||
runtime.store.getState().activate();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transition: "activate", source: source("generation zero") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates both worker clients and clears the store on cleanup", () => {
|
||||
const destroy = vi.fn();
|
||||
let mutationSignal: AbortSignal | undefined;
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn((_input: unknown, signal: AbortSignal) => {
|
||||
mutationSignal = signal;
|
||||
return new Promise<MutationResult>(() => {});
|
||||
}),
|
||||
destroy,
|
||||
});
|
||||
|
||||
runtime.store.getState().deactivate();
|
||||
runtime.destroy();
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(mutationSignal?.aborted).toBe(true);
|
||||
expect(runtime.store.getState().resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("coalesces rapid source edits and bounds stylesheet history", () => {
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 1_000_000,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
for (let index = 0; index < 10; index++) runtime.store.getState().setSourceText(`rapid ${index}`);
|
||||
expect(runtime.store.getState().undoStack).toHaveLength(1);
|
||||
expect(runtime.store.getState().undoStack[0]).toEqual(stylesheet("generation zero"));
|
||||
|
||||
for (let index = 0; index < 60; index++) {
|
||||
vi.advanceTimersByTime(501);
|
||||
runtime.store.getState().setSourceText(`separate ${index}`);
|
||||
}
|
||||
expect(runtime.store.getState().undoStack).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,677 @@
|
||||
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { StoreApi } from "zustand/vanilla";
|
||||
import type { SemanticCssColorToken } from "./color-tokens";
|
||||
import type {
|
||||
CompileWorkerInput,
|
||||
CompileWorkerResponse,
|
||||
PreflightWorkerInput,
|
||||
PreflightWorkerResponse,
|
||||
SemanticCssEditorMetadata,
|
||||
} from "./protocol";
|
||||
import { create } from "zustand/react";
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import {
|
||||
buildSemanticTree,
|
||||
getTemplateSemanticManifest,
|
||||
semanticNodeKeys,
|
||||
shouldShowResumeHeader,
|
||||
} from "@reactive-resume/pdf/semantic-tree";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
|
||||
|
||||
export type StylesheetCanonicalState = {
|
||||
stylesheet: SemanticStylesheet;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
};
|
||||
|
||||
type StylesheetMutationResult = StylesheetCanonicalState & {
|
||||
editGeneration: number;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
};
|
||||
|
||||
type EditMutation = {
|
||||
id: string;
|
||||
expectedRevision: number;
|
||||
expectedRenderDataVersion: number;
|
||||
editGeneration: number;
|
||||
transition: "edit_source";
|
||||
source: StylesheetSource;
|
||||
};
|
||||
|
||||
type RestoreMutation = {
|
||||
id: string;
|
||||
expectedRevision: number;
|
||||
expectedRenderDataVersion: number;
|
||||
editGeneration: number;
|
||||
transition: "restore_history";
|
||||
restore: SemanticStylesheet;
|
||||
};
|
||||
|
||||
type ActivateMutation = Omit<EditMutation, "transition"> & { transition: "activate" };
|
||||
type DeactivateMutation = Omit<EditMutation, "transition" | "source"> & { transition: "deactivate" };
|
||||
type StylesheetMutation = EditMutation | RestoreMutation | ActivateMutation | DeactivateMutation;
|
||||
|
||||
type Candidate =
|
||||
| { generation: number; transition: "edit_source"; source: StylesheetSource }
|
||||
| { generation: number; transition: "restore_history"; restore: SemanticStylesheet }
|
||||
| { generation: number; transition: "activate"; source: StylesheetSource }
|
||||
| { generation: number; transition: "deactivate" };
|
||||
|
||||
export type StylesheetStoreState = {
|
||||
resumeId?: string;
|
||||
mode: SemanticStylesheet["mode"];
|
||||
source: StylesheetSource;
|
||||
applied: StylesheetSource;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
editGeneration: number;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
colorTokens: readonly SemanticCssColorToken[];
|
||||
editorMetadata: SemanticCssEditorMetadata;
|
||||
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
|
||||
restoreLocked: boolean;
|
||||
focused: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undoStack: SemanticStylesheet[];
|
||||
redoStack: SemanticStylesheet[];
|
||||
setSourceText(text: string): void;
|
||||
setFocused(focused: boolean): void;
|
||||
activate(): void;
|
||||
deactivate(): void;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
refreshIntelligence(): void;
|
||||
};
|
||||
|
||||
type RuntimeDependencies = {
|
||||
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse>;
|
||||
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse>;
|
||||
mutate(input: StylesheetMutation, signal: AbortSignal): Promise<StylesheetMutationResult>;
|
||||
destroy?(): void;
|
||||
};
|
||||
|
||||
type CreateStylesheetStoreRuntimeOptions = RuntimeDependencies & {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
debounceMs?: number;
|
||||
store?: StoreApi<StylesheetStoreState>;
|
||||
};
|
||||
|
||||
const emptySource = (): StylesheetSource => ({ languageVersion: 1, text: "@version 1;\n" });
|
||||
const emptySemanticTree = (): SemanticNode => ({
|
||||
key: "resume",
|
||||
kind: "resume",
|
||||
attributes: {},
|
||||
roles: [],
|
||||
children: [],
|
||||
});
|
||||
const HISTORY_COALESCE_MS = 500;
|
||||
const MAX_HISTORY_ENTRIES = 50;
|
||||
|
||||
const inactiveState = (): Omit<
|
||||
StylesheetStoreState,
|
||||
"setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence"
|
||||
> => ({
|
||||
resumeId: undefined,
|
||||
mode: "legacy",
|
||||
source: emptySource(),
|
||||
applied: emptySource(),
|
||||
revision: 0,
|
||||
renderDataVersion: 0,
|
||||
editGeneration: 0,
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
editorMetadata: { semanticTree: emptySemanticTree(), templateParts: [] },
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
focused: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
});
|
||||
|
||||
const sourceFromText = (source: StylesheetSource, text: string): StylesheetSource => ({ ...source, text });
|
||||
const sourcesEqual = (left: StylesheetSource, right: StylesheetSource) =>
|
||||
left.languageVersion === right.languageVersion && left.text === right.text;
|
||||
const isEditorFocused = () =>
|
||||
typeof document !== "undefined" && document.activeElement instanceof HTMLElement
|
||||
? document.activeElement.closest(".cm-editor") !== null
|
||||
: false;
|
||||
const currentStylesheet = (state: StylesheetStoreState): SemanticStylesheet => ({
|
||||
mode: state.mode,
|
||||
source: structuredClone(state.source),
|
||||
applied: structuredClone(state.applied),
|
||||
});
|
||||
const appendHistory = (stack: SemanticStylesheet[], value: SemanticStylesheet) =>
|
||||
[...stack, value].slice(-MAX_HISTORY_ENTRIES);
|
||||
|
||||
const pageDimensions = (data: ResumeData) => {
|
||||
const format = data.metadata.page.format;
|
||||
const size = format === "letter" ? { width: 612, height: 792 } : { width: 595.28, height: 841.89 };
|
||||
return data.metadata.layout.pages.map((_page, index) => ({
|
||||
pageKey: semanticNodeKeys.page(index + 1),
|
||||
...size,
|
||||
}));
|
||||
};
|
||||
|
||||
const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
|
||||
const pages = data.metadata.layout.pages.map((page, index) =>
|
||||
buildSemanticTree({
|
||||
data,
|
||||
template: data.metadata.template,
|
||||
page,
|
||||
pageNumber: index + 1,
|
||||
showHeader: shouldShowResumeHeader(data, index),
|
||||
}),
|
||||
);
|
||||
const semanticTree: SemanticNode = {
|
||||
key: semanticNodeKeys.resume(),
|
||||
kind: "resume",
|
||||
attributes: { template: data.metadata.template },
|
||||
roles: [],
|
||||
children: pages.flatMap(({ children }) => children),
|
||||
};
|
||||
return {
|
||||
semanticTree,
|
||||
templateParts: getTemplateSemanticManifest(data.metadata.template).parts.map(({ name }) => name),
|
||||
};
|
||||
};
|
||||
|
||||
const compileInput = (
|
||||
data: ResumeData,
|
||||
source: StylesheetSource,
|
||||
editGeneration: number,
|
||||
semanticTree: SemanticNode,
|
||||
): CompileWorkerInput => {
|
||||
return {
|
||||
editGeneration,
|
||||
source,
|
||||
semanticTree,
|
||||
baseSettings: {
|
||||
picture: data.picture,
|
||||
template: data.metadata.template,
|
||||
design: data.metadata.design,
|
||||
typography: data.metadata.typography,
|
||||
page: data.metadata.page,
|
||||
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
|
||||
},
|
||||
pages: pageDimensions(data),
|
||||
};
|
||||
};
|
||||
|
||||
const conflictState = (error: unknown): StylesheetCanonicalState | undefined => {
|
||||
if (!error || typeof error !== "object") return;
|
||||
const value = error as { code?: string; data?: { state?: StylesheetCanonicalState } };
|
||||
return value.code === "STYLESHEET_REVISION_CONFLICT" ? value.data?.state : undefined;
|
||||
};
|
||||
|
||||
export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRuntimeOptions) {
|
||||
let resumeData = structuredClone(options.resumeData);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let inFlight: Candidate | undefined;
|
||||
let pending: Candidate | undefined;
|
||||
let latestCandidate: Candidate | undefined;
|
||||
let deferredCanonical: StylesheetCanonicalState | undefined;
|
||||
let validationEpoch = 0;
|
||||
let intelligenceEpoch = 0;
|
||||
let historyLastEditAt = 0;
|
||||
let historyCanCoalesce = false;
|
||||
let destroyed = false;
|
||||
const abortController = new AbortController();
|
||||
const debounceMs = options.debounceMs ?? 180;
|
||||
const initial = options.initial.stylesheet;
|
||||
let editorMetadata = createEditorMetadata(resumeData);
|
||||
const store =
|
||||
options.store ??
|
||||
createStore<StylesheetStoreState>(() => ({
|
||||
...inactiveState(),
|
||||
setSourceText: () => {},
|
||||
setFocused: () => {},
|
||||
activate: () => {},
|
||||
deactivate: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
refreshIntelligence: () => {},
|
||||
}));
|
||||
|
||||
const patch = (next: Partial<StylesheetStoreState>) => store.setState(next);
|
||||
const replaceCanonical = (canonical: StylesheetCanonicalState, preserveSource: boolean) => {
|
||||
const state = store.getState();
|
||||
const next: Partial<StylesheetStoreState> = {
|
||||
revision: Math.max(state.revision, canonical.revision),
|
||||
renderDataVersion: Math.max(state.renderDataVersion, canonical.renderDataVersion),
|
||||
};
|
||||
if (canonical.revision >= state.revision) {
|
||||
next.mode = canonical.stylesheet.mode;
|
||||
const nextSource = preserveSource ? state.source : canonical.stylesheet.source;
|
||||
next.source = nextSource;
|
||||
next.applied = canonical.stylesheet.applied;
|
||||
if (!sourcesEqual(nextSource, state.source)) {
|
||||
intelligenceEpoch += 1;
|
||||
next.colorTokens = [];
|
||||
}
|
||||
}
|
||||
patch(next);
|
||||
};
|
||||
const resetHistoryCoalescing = () => {
|
||||
historyLastEditAt = 0;
|
||||
historyCanCoalesce = false;
|
||||
};
|
||||
|
||||
const startNext = () => {
|
||||
if (destroyed || inFlight || !pending) return;
|
||||
const candidate = pending;
|
||||
pending = undefined;
|
||||
inFlight = candidate;
|
||||
const requestValidationEpoch = validationEpoch;
|
||||
const state = store.getState();
|
||||
const common = {
|
||||
id: options.resumeId,
|
||||
expectedRevision: state.revision,
|
||||
expectedRenderDataVersion: state.renderDataVersion,
|
||||
editGeneration: candidate.generation,
|
||||
};
|
||||
let input: StylesheetMutation;
|
||||
if (candidate.transition === "edit_source" || candidate.transition === "activate") {
|
||||
input = { ...common, transition: candidate.transition, source: candidate.source };
|
||||
} else if (candidate.transition === "restore_history") {
|
||||
input = { ...common, transition: "restore_history", restore: candidate.restore };
|
||||
} else {
|
||||
input = { ...common, transition: "deactivate" };
|
||||
}
|
||||
patch({ status: "saving" });
|
||||
|
||||
void options
|
||||
.mutate(input, abortController.signal)
|
||||
.then((result) => {
|
||||
if (destroyed) return;
|
||||
const state = store.getState();
|
||||
const staleStylesheet = result.revision < state.revision;
|
||||
patch({
|
||||
revision: Math.max(state.revision, result.revision),
|
||||
renderDataVersion: Math.max(state.renderDataVersion, result.renderDataVersion),
|
||||
});
|
||||
if (result.editGeneration !== store.getState().editGeneration) return;
|
||||
if (staleStylesheet) return;
|
||||
const sourceChanged = !sourcesEqual(result.stylesheet.source, state.source);
|
||||
if (sourceChanged) intelligenceEpoch += 1;
|
||||
patch({
|
||||
mode: result.stylesheet.mode,
|
||||
source: result.stylesheet.source,
|
||||
applied: result.stylesheet.applied,
|
||||
diagnostics: result.diagnostics,
|
||||
colorTokens: sourceChanged ? [] : state.colorTokens,
|
||||
status: result.diagnostics.some(({ severity }) => severity === "error") ? "error" : "applied",
|
||||
});
|
||||
if (latestCandidate?.generation === result.editGeneration) latestCandidate = undefined;
|
||||
if (deferredCanonical && result.revision >= deferredCanonical.revision) deferredCanonical = undefined;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (destroyed) return;
|
||||
const canonical = conflictState(error);
|
||||
if (!canonical) {
|
||||
patch({ status: "error" });
|
||||
return;
|
||||
}
|
||||
replaceCanonical(canonical, true);
|
||||
if (requestValidationEpoch === validationEpoch) pending ??= candidate;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
startNext();
|
||||
});
|
||||
};
|
||||
|
||||
const queue = (candidate: Candidate) => {
|
||||
latestCandidate = candidate;
|
||||
pending = candidate;
|
||||
startNext();
|
||||
};
|
||||
|
||||
const processCandidate = async (candidate: Candidate) => {
|
||||
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
|
||||
const candidateValidationEpoch = validationEpoch;
|
||||
if (candidate.transition === "deactivate") {
|
||||
queue(candidate);
|
||||
return;
|
||||
}
|
||||
const source = candidate.transition === "restore_history" ? candidate.restore.applied : candidate.source;
|
||||
patch({ status: "compiling" });
|
||||
let compiled: CompileWorkerResponse;
|
||||
try {
|
||||
compiled = await options.compile(
|
||||
compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (destroyed || compiled.editGeneration !== store.getState().editGeneration) return;
|
||||
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||
|
||||
if (!compiled.program) {
|
||||
if (candidate.transition === "edit_source") queue(candidate);
|
||||
else patch({ status: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (compiled.program) {
|
||||
patch({ status: "preflighting" });
|
||||
let preflight: PreflightWorkerResponse;
|
||||
try {
|
||||
preflight = await options.preflight({
|
||||
editGeneration: candidate.generation,
|
||||
input: { data: resumeData, template: resumeData.metadata.template, stylesheet: source },
|
||||
limits: {
|
||||
maxPages: 20,
|
||||
maxBytes: 10_000_000,
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (candidate.generation !== store.getState().editGeneration) return;
|
||||
patch({ status: "error" });
|
||||
if (candidate.transition === "edit_source") queue(candidate);
|
||||
return;
|
||||
}
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return;
|
||||
if (!preflight.result.ok) {
|
||||
patch({ diagnostics: [...compiled.diagnostics, ...preflight.result.diagnostics], status: "error" });
|
||||
if (candidate.transition !== "edit_source") return;
|
||||
}
|
||||
}
|
||||
|
||||
queue(candidate);
|
||||
};
|
||||
|
||||
const schedule = (candidate: Candidate) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
latestCandidate = candidate;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void processCandidate(candidate);
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const restore = (target: SemanticStylesheet, opposite: "undoStack" | "redoStack") => {
|
||||
const state = store.getState();
|
||||
const stack = opposite === "undoStack" ? state.undoStack : state.redoStack;
|
||||
const previous = stack.at(-1);
|
||||
if (!previous) return;
|
||||
const generation = state.editGeneration + 1;
|
||||
const other = opposite === "undoStack" ? "redoStack" : "undoStack";
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
source: previous.source,
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
[opposite]: stack.slice(0, -1),
|
||||
[other]: appendHistory(state[other], target),
|
||||
canUndo: opposite === "redoStack" || stack.length > 1,
|
||||
canRedo: opposite === "undoStack" || stack.length > 1,
|
||||
});
|
||||
schedule({ generation, transition: "restore_history", restore: previous });
|
||||
};
|
||||
|
||||
store.setState({
|
||||
resumeId: options.resumeId,
|
||||
mode: initial.mode,
|
||||
source: structuredClone(initial.source),
|
||||
applied: structuredClone(initial.applied),
|
||||
revision: options.initial.revision,
|
||||
renderDataVersion: options.initial.renderDataVersion,
|
||||
editGeneration: 0,
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
editorMetadata,
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
focused: false,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
setSourceText(text) {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || text === state.source.text) return;
|
||||
const generation = state.editGeneration + 1;
|
||||
const nextSource = sourceFromText(state.source, text);
|
||||
const now = Date.now();
|
||||
const undoStack =
|
||||
historyCanCoalesce && now - historyLastEditAt <= HISTORY_COALESCE_MS
|
||||
? state.undoStack
|
||||
: appendHistory(state.undoStack, currentStylesheet(state));
|
||||
historyLastEditAt = now;
|
||||
historyCanCoalesce = true;
|
||||
patch({
|
||||
source: nextSource,
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack,
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
schedule({ generation, transition: "edit_source", source: nextSource });
|
||||
},
|
||||
setFocused(focused) {
|
||||
patch({ focused });
|
||||
if (focused || !deferredCanonical) return;
|
||||
const canonical = deferredCanonical;
|
||||
deferredCanonical = undefined;
|
||||
const candidate = latestCandidate;
|
||||
const hasLocalDraft =
|
||||
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
replaceCanonical(canonical, hasLocalDraft);
|
||||
if (hasLocalDraft && candidate) schedule(candidate);
|
||||
else resetHistoryCoalescing();
|
||||
},
|
||||
activate() {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || state.mode === "semantic") return;
|
||||
const generation = state.editGeneration + 1;
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
schedule({ generation, transition: "activate", source: state.source });
|
||||
},
|
||||
deactivate() {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || state.mode === "legacy") return;
|
||||
const generation = state.editGeneration + 1;
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
queue({ generation, transition: "deactivate" });
|
||||
},
|
||||
undo() {
|
||||
if (store.getState().restoreLocked) return;
|
||||
restore(currentStylesheet(store.getState()), "undoStack");
|
||||
},
|
||||
redo() {
|
||||
if (store.getState().restoreLocked) return;
|
||||
restore(currentStylesheet(store.getState()), "redoStack");
|
||||
},
|
||||
refreshIntelligence() {
|
||||
const state = store.getState();
|
||||
const generation = state.editGeneration;
|
||||
const source = structuredClone(state.source);
|
||||
const requestEpoch = ++intelligenceEpoch;
|
||||
void options
|
||||
.compile(compileInput(resumeData, source, generation, editorMetadata.semanticTree))
|
||||
.then((compiled) => {
|
||||
const current = store.getState();
|
||||
if (
|
||||
destroyed ||
|
||||
requestEpoch !== intelligenceEpoch ||
|
||||
current.editGeneration !== generation ||
|
||||
!sourcesEqual(current.source, source)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
replaceResumeSnapshot(data: ResumeData, canonical: StylesheetCanonicalState) {
|
||||
const candidate = latestCandidate;
|
||||
resumeData = structuredClone(data);
|
||||
editorMetadata = createEditorMetadata(resumeData);
|
||||
patch({ editorMetadata });
|
||||
const renderDataChanged = canonical.renderDataVersion > store.getState().renderDataVersion;
|
||||
const preserveSource = store.getState().focused || isEditorFocused() || candidate !== undefined;
|
||||
replaceCanonical(canonical, preserveSource);
|
||||
if (renderDataChanged) {
|
||||
validationEpoch += 1;
|
||||
pending = undefined;
|
||||
if (candidate) schedule(candidate);
|
||||
}
|
||||
},
|
||||
rebaseCanonical(canonical: StylesheetCanonicalState) {
|
||||
const candidate = latestCandidate;
|
||||
const hasLocalDraft =
|
||||
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
const focused = store.getState().focused || isEditorFocused();
|
||||
const sourceChanged = store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
if (focused && sourceChanged && canonical.revision >= store.getState().revision) deferredCanonical = canonical;
|
||||
const preserveSource = (focused && sourceChanged) || hasLocalDraft;
|
||||
replaceCanonical(canonical, preserveSource);
|
||||
if (hasLocalDraft && candidate) schedule(candidate);
|
||||
else if (!preserveSource) resetHistoryCoalescing();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
abortController.abort();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
pending = undefined;
|
||||
latestCandidate = undefined;
|
||||
deferredCanonical = undefined;
|
||||
options.destroy?.();
|
||||
store.setState(inactiveState());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const useStylesheetStore = create<StylesheetStoreState>(() => ({
|
||||
...inactiveState(),
|
||||
setSourceText: () => {},
|
||||
setFocused: () => {},
|
||||
activate: () => {},
|
||||
deactivate: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
refreshIntelligence: () => {},
|
||||
}));
|
||||
|
||||
let activeRuntime: ReturnType<typeof createStylesheetStoreRuntime> | undefined;
|
||||
declare const stylesheetRuntimeTokenBrand: unique symbol;
|
||||
export type StylesheetRuntimeToken = Readonly<{ [stylesheetRuntimeTokenBrand]: true }>;
|
||||
let activeRuntimeToken: StylesheetRuntimeToken | undefined;
|
||||
|
||||
const compilerClient = () =>
|
||||
createCompileWorkerClient(
|
||||
() =>
|
||||
new Worker(new URL("./stylesheet.worker.ts", import.meta.url), { type: "module", name: "semantic-css-compiler" }),
|
||||
);
|
||||
const preflightClient = () =>
|
||||
createPreflightWorkerClient(
|
||||
() =>
|
||||
new Worker(new URL("./preflight.worker.ts", import.meta.url), { type: "module", name: "semantic-css-preflight" }),
|
||||
5_000,
|
||||
);
|
||||
|
||||
export function initializeStylesheetStore(input: {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
}) {
|
||||
activeRuntime?.destroy();
|
||||
const compiler = compilerClient();
|
||||
const preflight = preflightClient();
|
||||
preflight.warmup();
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
...input,
|
||||
store: useStylesheetStore,
|
||||
compile: compiler.compile,
|
||||
preflight: preflight.preflight,
|
||||
mutate: (mutation, signal) => orpc.resume.stylesheet.mutate.call(mutation, { signal }),
|
||||
destroy: () => {
|
||||
compiler.destroy();
|
||||
preflight.destroy();
|
||||
},
|
||||
});
|
||||
activeRuntime = runtime;
|
||||
activeRuntimeToken = {} as StylesheetRuntimeToken;
|
||||
return () => {
|
||||
if (activeRuntime?.store.getState().resumeId !== input.resumeId) return;
|
||||
activeRuntime.destroy();
|
||||
activeRuntime = undefined;
|
||||
activeRuntimeToken = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function lockStylesheetStoreForRestore(resumeId: string): StylesheetRuntimeToken | undefined {
|
||||
if (!activeRuntime || !activeRuntimeToken) return;
|
||||
const state = activeRuntime.store.getState();
|
||||
if (state.resumeId !== resumeId || state.restoreLocked) return;
|
||||
activeRuntime.store.setState({ restoreLocked: true });
|
||||
return activeRuntimeToken;
|
||||
}
|
||||
|
||||
export function unlockStylesheetStoreAfterRestore(token: StylesheetRuntimeToken | undefined): boolean {
|
||||
if (!activeRuntime || !token || activeRuntimeToken !== token) return false;
|
||||
activeRuntime.store.setState({ restoreLocked: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function replaceStylesheetStoreAfterRestore(input: {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
token: StylesheetRuntimeToken | undefined;
|
||||
}): boolean {
|
||||
if (
|
||||
!activeRuntime ||
|
||||
!input.token ||
|
||||
activeRuntimeToken !== input.token ||
|
||||
activeRuntime.store.getState().resumeId !== input.resumeId ||
|
||||
!activeRuntime.store.getState().restoreLocked
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
initializeStylesheetStore(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function refreshStylesheetStore(resumeId: string, resumeData?: ResumeData) {
|
||||
if (!activeRuntime || activeRuntime.store.getState().resumeId !== resumeId) return;
|
||||
const canonical = await orpc.resume.stylesheet.getState.call({ id: resumeId });
|
||||
if (resumeData) activeRuntime.replaceResumeSnapshot(resumeData, canonical);
|
||||
else activeRuntime.rebaseCanonical(canonical);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
|
||||
import { analyzeStylesheet, compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { collectCompiledColorTokens } from "./color-tokens";
|
||||
|
||||
self.addEventListener("message", ({ data }: MessageEvent<CompileWorkerRequest>) => {
|
||||
if (data.type !== "compile") return;
|
||||
const compiled = compileStylesheet(data.source);
|
||||
const diagnostics = compiled.program
|
||||
? [...compiled.diagnostics, ...analyzeStylesheet(compiled.program, data.semanticTree)]
|
||||
: compiled.diagnostics;
|
||||
const response: CompileWorkerResponse = {
|
||||
type: "compile_result",
|
||||
requestId: data.requestId,
|
||||
editGeneration: data.editGeneration,
|
||||
program: compiled.program,
|
||||
diagnostics,
|
||||
colorTokens: collectCompiledColorTokens(data.source.text, compiled.program),
|
||||
};
|
||||
self.postMessage(response);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArrowCounterClockwiseIcon,
|
||||
ArrowsInIcon,
|
||||
ArrowsOutIcon,
|
||||
ArrowUUpLeftIcon,
|
||||
ArrowUUpRightIcon,
|
||||
CopyIcon,
|
||||
MagicWandIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { copySourceToClipboard } from "./editor-extensions";
|
||||
|
||||
type ToolbarButtonProps = {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick(): void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function ToolbarButton({ label, disabled, onClick, children }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button type="button" size="icon-sm" variant="ghost" aria-label={label} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export type StylesheetToolbarProps = {
|
||||
source: string;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
focused: boolean;
|
||||
disabled?: boolean;
|
||||
onUndo(): void;
|
||||
onRedo(): void;
|
||||
onFormat(): void;
|
||||
onReset(): void;
|
||||
onFocusToggle(): void;
|
||||
};
|
||||
|
||||
export function StylesheetToolbar({
|
||||
source,
|
||||
canUndo,
|
||||
canRedo,
|
||||
focused,
|
||||
disabled = false,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onFormat,
|
||||
onReset,
|
||||
onFocusToggle,
|
||||
}: StylesheetToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1" role="toolbar" aria-label={t`Stylesheet editor`}>
|
||||
<ToolbarButton label={t`Undo stylesheet edit`} disabled={disabled || !canUndo} onClick={onUndo}>
|
||||
<ArrowUUpLeftIcon data-icon="inline-start" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={t`Redo stylesheet edit`} disabled={disabled || !canRedo} onClick={onRedo}>
|
||||
<ArrowUUpRightIcon data-icon="inline-start" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={t`Copy stylesheet`} onClick={() => void copySourceToClipboard(source)}>
|
||||
<CopyIcon data-icon="inline-start" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={t`Format stylesheet`} disabled={disabled} onClick={onFormat}>
|
||||
<MagicWandIcon data-icon="inline-start" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={t`Reset to applied stylesheet`} disabled={disabled} onClick={onReset}>
|
||||
<ArrowCounterClockwiseIcon data-icon="inline-start" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={focused ? t`Exit focus mode` : t`Open focus mode`} onClick={onFocusToggle}>
|
||||
{focused ? <ArrowsInIcon data-icon="inline-start" /> : <ArrowsOutIcon data-icon="inline-start" />}
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPreflightTransferables } from "./protocol";
|
||||
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
|
||||
|
||||
type Listener = (event: MessageEvent) => void;
|
||||
|
||||
function worker() {
|
||||
const listeners = new Set<Listener>();
|
||||
return {
|
||||
postMessage: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
addEventListener: vi.fn((_type: string, listener: Listener) => listeners.add(listener)),
|
||||
removeEventListener: vi.fn((_type: string, listener: Listener) => listeners.delete(listener)),
|
||||
emit(data: unknown) {
|
||||
for (const listener of listeners) listener(new MessageEvent("message", { data }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("stylesheet worker clients", () => {
|
||||
it("rejects stale compiler results by request id", async () => {
|
||||
const fake = worker();
|
||||
const client = createCompileWorkerClient(() => fake);
|
||||
const first = client.compile({ editGeneration: 1 } as never);
|
||||
const second = client.compile({ editGeneration: 2 } as never);
|
||||
|
||||
fake.emit({ type: "compile_result", requestId: 1, editGeneration: 1, program: null, diagnostics: [] });
|
||||
fake.emit({ type: "compile_result", requestId: 2, editGeneration: 2, program: null, diagnostics: [] });
|
||||
|
||||
await expect(first).rejects.toThrow("stale");
|
||||
await expect(second).resolves.toMatchObject({ requestId: 2 });
|
||||
});
|
||||
|
||||
it("terminates and recreates a timed-out preflight worker", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = worker();
|
||||
const replacement = worker();
|
||||
const createWorker = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement);
|
||||
const client = createPreflightWorkerClient(createWorker, 10);
|
||||
|
||||
const timedOut = client.preflight({ editGeneration: 1 } as never);
|
||||
first.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
await expect(timedOut).resolves.toMatchObject({
|
||||
result: { ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" },
|
||||
});
|
||||
expect(first.terminate).toHaveBeenCalledOnce();
|
||||
|
||||
const next = client.preflight({ editGeneration: 2 } as never);
|
||||
replacement.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
replacement.emit({
|
||||
type: "preflight_result",
|
||||
requestId: 2,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||
});
|
||||
await expect(next).resolves.toMatchObject({ requestId: 2 });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("warms the preflight worker before a request starts its deadline", () => {
|
||||
const fake = worker();
|
||||
const createWorker = vi.fn(() => fake);
|
||||
const client = createPreflightWorkerClient(createWorker, 5_000);
|
||||
|
||||
client.warmup();
|
||||
|
||||
expect(createWorker).toHaveBeenCalledOnce();
|
||||
expect(fake.addEventListener).toHaveBeenCalledOnce();
|
||||
expect(fake.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for readiness without consuming the request deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fake = worker();
|
||||
const client = createPreflightWorkerClient(() => fake, 5, 20);
|
||||
const result = client.preflight({ editGeneration: 1 } as never);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
expect(fake.terminate).not.toHaveBeenCalled();
|
||||
expect(fake.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
fake.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fake.postMessage).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await expect(result).resolves.toMatchObject({ result: { code: "STYLESHEET_PREFLIGHT_TIMEOUT" } });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects structured resume-data failures without waiting for the timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fake = worker();
|
||||
const client = createPreflightWorkerClient(() => fake, 5_000);
|
||||
const pending = client.preflight({ editGeneration: 1 } as never);
|
||||
const outcome = pending.catch((error: unknown) => error);
|
||||
|
||||
fake.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
fake.emit({
|
||||
type: "preflight_error",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
cause: {
|
||||
name: "ZodError",
|
||||
message: "Invalid resume data",
|
||||
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await outcome).toMatchObject({
|
||||
name: "ZodError",
|
||||
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(fake.terminate).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("bounds readiness, recreates once, and rejects after the retry also times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = worker();
|
||||
const replacement = worker();
|
||||
const client = createPreflightWorkerClient(
|
||||
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
|
||||
5,
|
||||
10,
|
||||
);
|
||||
const result = client.preflight({ editGeneration: 1 } as never);
|
||||
const rejection = expect(result).rejects.toThrow("did not become ready");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(first.terminate).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
await rejection;
|
||||
expect(replacement.terminate).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not recreate a warming worker after destroy", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fake = worker();
|
||||
const createWorker = vi.fn(() => fake);
|
||||
const client = createPreflightWorkerClient(createWorker, 5, 10);
|
||||
client.warmup();
|
||||
|
||||
client.destroy();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(createWorker).toHaveBeenCalledOnce();
|
||||
expect(fake.terminate).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("terminates stale preflight work when a newer request starts", async () => {
|
||||
const first = worker();
|
||||
const replacement = worker();
|
||||
const client = createPreflightWorkerClient(
|
||||
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
|
||||
1_000,
|
||||
);
|
||||
const stale = client.preflight({ editGeneration: 1 } as never);
|
||||
first.emit({ type: "preflight_ready" });
|
||||
await Promise.resolve();
|
||||
const staleOutcome = stale.catch((error: unknown) => error);
|
||||
const current = client.preflight({ editGeneration: 2 } as never);
|
||||
|
||||
expect(first.terminate).toHaveBeenCalledOnce();
|
||||
replacement.emit({ type: "preflight_ready" });
|
||||
await Promise.resolve();
|
||||
replacement.emit({
|
||||
type: "preflight_result",
|
||||
requestId: 2,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||
});
|
||||
expect(await staleOutcome).toEqual(expect.objectContaining({ message: expect.stringContaining("stale") }));
|
||||
await expect(current).resolves.toMatchObject({ requestId: 2 });
|
||||
});
|
||||
|
||||
it("transfers the generated PDF buffer", () => {
|
||||
const pdf = new ArrayBuffer(4);
|
||||
expect(
|
||||
getPreflightTransferables({
|
||||
type: "preflight_result",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf },
|
||||
}),
|
||||
).toEqual([pdf]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
CompileWorkerInput,
|
||||
CompileWorkerRequest,
|
||||
CompileWorkerResponse,
|
||||
PreflightWorkerError,
|
||||
PreflightWorkerInput,
|
||||
PreflightWorkerReady,
|
||||
PreflightWorkerRequest,
|
||||
PreflightWorkerResponse,
|
||||
} from "./protocol";
|
||||
|
||||
type WorkerListener = (event: MessageEvent<unknown>) => void;
|
||||
|
||||
export type StylesheetWorker = {
|
||||
postMessage(message: unknown, transfer?: Transferable[]): void;
|
||||
terminate(): void;
|
||||
addEventListener(type: "message", listener: WorkerListener): void;
|
||||
removeEventListener(type: "message", listener: WorkerListener): void;
|
||||
};
|
||||
|
||||
type Pending<T> = {
|
||||
resolve(value: T): void;
|
||||
reject(error: Error): void;
|
||||
};
|
||||
|
||||
export function createCompileWorkerClient(createWorker: () => StylesheetWorker) {
|
||||
const worker = createWorker();
|
||||
const pending = new Map<number, Pending<CompileWorkerResponse>>();
|
||||
let latestRequestId = 0;
|
||||
|
||||
const onMessage: WorkerListener = ({ data }) => {
|
||||
const response = data as CompileWorkerResponse;
|
||||
if (response?.type !== "compile_result") return;
|
||||
const request = pending.get(response.requestId);
|
||||
if (!request) return;
|
||||
pending.delete(response.requestId);
|
||||
if (response.requestId !== latestRequestId) {
|
||||
request.reject(new Error("Discarded stale stylesheet compiler result."));
|
||||
return;
|
||||
}
|
||||
request.resolve(response);
|
||||
};
|
||||
worker.addEventListener("message", onMessage);
|
||||
|
||||
return {
|
||||
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse> {
|
||||
const requestId = ++latestRequestId;
|
||||
const request: CompileWorkerRequest = { ...input, type: "compile", requestId };
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(requestId, { resolve, reject });
|
||||
worker.postMessage(request);
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
worker.removeEventListener("message", onMessage);
|
||||
worker.terminate();
|
||||
for (const request of pending.values()) request.reject(new Error("Stylesheet compiler worker was terminated."));
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutResult = (request: PreflightWorkerRequest): PreflightWorkerResponse => ({
|
||||
type: "preflight_result",
|
||||
requestId: request.requestId,
|
||||
editGeneration: request.editGeneration,
|
||||
result: {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_TIMEOUT",
|
||||
message: "The PDF preflight exceeded its deadline.",
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
export function createPreflightWorkerClient(
|
||||
createWorker: () => StylesheetWorker,
|
||||
timeoutMs: number,
|
||||
readinessTimeoutMs = 10_000,
|
||||
) {
|
||||
let worker: StylesheetWorker | undefined;
|
||||
let requestId = 0;
|
||||
let ready = false;
|
||||
let destroyed = false;
|
||||
let readiness:
|
||||
| (Pending<StylesheetWorker> & {
|
||||
promise: Promise<StylesheetWorker>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
})
|
||||
| undefined;
|
||||
const pending = new Map<number, Pending<PreflightWorkerResponse> & { timer?: ReturnType<typeof setTimeout> }>();
|
||||
|
||||
const onMessage: WorkerListener = ({ data }) => {
|
||||
if ((data as PreflightWorkerReady)?.type === "preflight_ready") {
|
||||
if (!worker || !readiness) return;
|
||||
clearTimeout(readiness.timer);
|
||||
ready = true;
|
||||
readiness.resolve(worker);
|
||||
readiness = undefined;
|
||||
return;
|
||||
}
|
||||
const workerError = data as PreflightWorkerError;
|
||||
if (workerError?.type === "preflight_error") {
|
||||
const request = pending.get(workerError.requestId);
|
||||
if (!request) return;
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
pending.delete(workerError.requestId);
|
||||
request.reject(
|
||||
Object.assign(new Error(workerError.cause.message), {
|
||||
name: workerError.cause.name,
|
||||
issues: workerError.cause.issues,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const response = data as PreflightWorkerResponse;
|
||||
if (response?.type !== "preflight_result") return;
|
||||
const request = pending.get(response.requestId);
|
||||
if (!request) return;
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
pending.delete(response.requestId);
|
||||
request.resolve(response);
|
||||
};
|
||||
|
||||
const terminate = () => {
|
||||
if (!worker) return;
|
||||
worker.removeEventListener("message", onMessage);
|
||||
worker.terminate();
|
||||
worker = undefined;
|
||||
ready = false;
|
||||
if (readiness) {
|
||||
clearTimeout(readiness.timer);
|
||||
readiness.reject(new Error("Stylesheet preflight worker did not become ready."));
|
||||
readiness = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getReadyWorker = () => {
|
||||
if (destroyed) return Promise.reject(new Error("Stylesheet preflight worker was terminated."));
|
||||
if (worker && ready) return Promise.resolve(worker);
|
||||
if (readiness) return readiness.promise;
|
||||
worker = createWorker();
|
||||
worker.addEventListener("message", onMessage);
|
||||
let resolve!: (value: StylesheetWorker) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<StylesheetWorker>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const timer = setTimeout(() => terminate(), readinessTimeoutMs);
|
||||
readiness = { promise, resolve, reject, timer };
|
||||
return promise;
|
||||
};
|
||||
|
||||
const waitUntilReady = async () => {
|
||||
try {
|
||||
return await getReadyWorker();
|
||||
} catch {
|
||||
return await getReadyWorker();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
warmup() {
|
||||
void waitUntilReady().catch(() => {});
|
||||
},
|
||||
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse> {
|
||||
if (pending.size > 0) {
|
||||
terminate();
|
||||
for (const stale of pending.values()) {
|
||||
if (stale.timer) clearTimeout(stale.timer);
|
||||
stale.reject(new Error("Discarded stale stylesheet preflight result."));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
const request: PreflightWorkerRequest = { ...input, type: "preflight", requestId: ++requestId };
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(request.requestId, { resolve, reject });
|
||||
void waitUntilReady()
|
||||
.then((readyWorker) => {
|
||||
const current = pending.get(request.requestId);
|
||||
if (!current) return;
|
||||
current.timer = setTimeout(() => {
|
||||
pending.delete(request.requestId);
|
||||
terminate();
|
||||
resolve(timeoutResult(request));
|
||||
}, timeoutMs);
|
||||
readyWorker.postMessage(request);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const current = pending.get(request.requestId);
|
||||
if (!current) return;
|
||||
pending.delete(request.requestId);
|
||||
reject(error instanceof Error ? error : new Error("Stylesheet preflight worker failed to start."));
|
||||
});
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
terminate();
|
||||
for (const request of pending.values()) {
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
request.reject(new Error("Stylesheet preflight worker was terminated."));
|
||||
}
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user