diff --git a/README.md b/README.md index 37a7d0c..3883329 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ Formatting and linting use Oxfmt and Oxlint. The production build compiles the R CI additionally builds the Stack CLI revision pinned in [`scripts/docs-validation.config.mjs`](./scripts/docs-validation.config.mjs) with its minimum supported Rust version, then runs `npm run docs:smoke` through `STACK_CLI_BIN`. This checks the documented version, help surface, and safe executable examples without adding Rust to normal Web builds. Intentional locale or execution differences must be declared in the same configuration with a non-empty reason; the validator rejects stale exceptions. -The example gallery is generated from the public specification commit pinned in [`scripts/example-corpus.config.mjs`](./scripts/example-corpus.config.mjs). [`example-corpus`](./example-corpus) is a hermetic snapshot for the Playground and documentation build; CI checks every catalog, schema, and `.stack` source byte against the pinned provider commit. `npm run examples:check` checks and renders all examples, resolves every namespaced icon against the published provider catalog, and detects stale SVG thumbnails. To intentionally advance the corpus, check out the new pinned specification revision, run `STACK_SPECIFICATION_ROOT=/path/to/specification npm run examples:sync`, and then run `npm run examples:generate`. +The example gallery loads canonical `.stack` sources from the public specification commit pinned in [`scripts/example-corpus.config.mjs`](./scripts/example-corpus.config.mjs). [`example-corpus`](./example-corpus) is a hermetic source snapshot shared with the Playground; CI checks every catalog, schema, and `.stack` source byte against the pinned provider commit. Previews render on the visitor's device with the same pinned WASM engine and adapter as the Playground. They are not checked-in or build-generated SVGs, so updating the site's engine also updates its example rendering. The browser loads examples near the viewport, displays engine SVG as a Blob-backed image, and releases image URLs on navigation. A source link remains available without JavaScript; operational failures offer retry, and engine diagnostics are shown separately. Provider artwork is not fetched or redistributed by the gallery: examples use the engine's fallback shapes unless the caller explicitly supplies icon packs. + +`npm run examples:check` checks and renders all examples in memory, resolves every namespaced icon against the published provider catalog, and checks safe SVG output without writing images. To intentionally advance the corpus, check out the new pinned specification revision, run `STACK_SPECIFICATION_ROOT=/path/to/specification npm run examples:sync`, then run `npm run examples:check`. Approved engine regression snapshots are separate test artifacts, not documentation previews. ## Brand mark diff --git a/docs/.vitepress/theme/components/ExampleGallery.vue b/docs/.vitepress/theme/components/ExampleGallery.vue index 3e169e3..356d142 100644 --- a/docs/.vitepress/theme/components/ExampleGallery.vue +++ b/docs/.vitepress/theme/components/ExampleGallery.vue @@ -1,6 +1,7 @@ + + diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css index bead330..69c0b99 100644 --- a/docs/.vitepress/theme/style.css +++ b/docs/.vitepress/theme/style.css @@ -632,7 +632,7 @@ body { grid-template-rows: auto 1fr; } -.stack-example-card__preview { +.stack-example-preview__image { display: grid; min-height: 12rem; padding: 1rem; @@ -641,7 +641,11 @@ body { place-items: center; } -.stack-example-card__preview img { +.stack-example-preview__image a { + width: 100%; +} + +.stack-example-preview__image img { display: block; width: 100%; height: 11rem; @@ -649,6 +653,29 @@ body { object-fit: contain; } +.stack-example-preview__fallback { + display: grid; + align-content: center; + gap: 0.5rem; + width: 100%; + height: 11rem; + overflow: auto; + text-align: center; +} + +.stack-example-preview__fallback button { + padding: 0.5rem; + border: 1px solid var(--vp-c-divider); + border-radius: 0.375rem; + color: var(--vp-c-text-1); +} + +.stack-example-preview__diagnostics { + padding: 0.5rem 1rem; + font-size: 0.75rem; + overflow-wrap: anywhere; +} + .stack-example-card__body { display: flex; min-width: 0; diff --git a/docs/public/examples/application-and-data.svg b/docs/public/examples/application-and-data.svg deleted file mode 100644 index 49ea47a..0000000 --- a/docs/public/examples/application-and-data.svg +++ /dev/null @@ -1,47 +0,0 @@ - - - Application and datastore - Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - Application and datastore - - - - - Application flows to Primary database: SQL - - - - - - Application: Business logic - - - Application - Business logic - - - Primary database: PostgreSQL - - - - Primary database - PostgreSQL - - - - diff --git a/docs/public/examples/aws-serverless-checkout.svg b/docs/public/examples/aws-serverless-checkout.svg deleted file mode 100644 index 9c5a8f6..0000000 --- a/docs/public/examples/aws-serverless-checkout.svg +++ /dev/null @@ -1,142 +0,0 @@ - - - AWS serverless checkout - Architecture diagram with 8 nodes, 3 groups, and 7 relationships. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - AWS serverless checkout - - - Edge - - Edge - - - Serverless compute - - Serverless compute - - - Data - - Data - - - - - Customer flows to CloudFront: HTTPS - - - - CloudFront flows to API Gateway: API request - - - - API Gateway flows to Checkout: Invoke - - - - Checkout flows to Orders: Write order - - - - Checkout flows to Order events: OrderPlaced - - - - Order events flows to Fulfillment: Dispatch - - - - Fulfillment flows to Receipts: Archive - - - - - - Customer - - - Customer - - - CloudFront - - - CloudFront - - - API Gateway - - - API Gateway - - - Checkout - - - Checkout - - - Order events - - - Order events - - - Fulfillment - - - Fulfillment - - - Orders - - - - Orders - - - Receipts - - - Receipts - - - - diff --git a/docs/public/examples/azure-event-platform.svg b/docs/public/examples/azure-event-platform.svg deleted file mode 100644 index 479b942..0000000 --- a/docs/public/examples/azure-event-platform.svg +++ /dev/null @@ -1,142 +0,0 @@ - - - Azure event platform - Architecture diagram with 8 nodes, 3 groups, and 7 relationships. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - Azure event platform - - - Delivery - - Delivery - - - Event processing - - Event processing - - - Data - - Data - - - - - Customer flows to Front Door: HTTPS - - - - Front Door flows to App Service: Route - - - - App Service flows to Ingest function: Submit - - - - Ingest function flows to Service Bus: Work item - - - - Service Bus flows to Background function: Dispatch - - - - Background function flows to Cosmos DB: Persist - - - - Background function flows to Storage account: Archive - - - - - - Customer - - - Customer - - - Front Door - - - Front Door - - - App Service - - - App Service - - - Ingest function - - - Ingest function - - - Service Bus - - - Service Bus - - - Background function - - - Background function - - - Cosmos DB - - - - Cosmos DB - - - Storage account - - - Storage account - - - - diff --git a/docs/public/examples/commerce-platform.svg b/docs/public/examples/commerce-platform.svg deleted file mode 100644 index bbce95c..0000000 --- a/docs/public/examples/commerce-platform.svg +++ /dev/null @@ -1,239 +0,0 @@ - - - Commerce platform - Architecture diagram with 13 nodes, 5 groups, and 12 relationships. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - Commerce platform - - - Storefront - - Storefront - - - Commerce services - - Commerce services - - - Asynchronous processing - - Asynchronous processing - - - Data - - Data - - - External systems - - External systems - - - - - Customer flows to Web storefront: Browse and buy - - - - Web storefront flows to Edge gateway: HTTPS - - - - Edge gateway flows to Catalog API: Catalog requests - - - - Edge gateway flows to Checkout API: Checkout requests - - - - Catalog API flows to Product database: SQL - - - - Catalog API flows to Product media: Media URLs - - - - Checkout API flows to Order database: Transactions - - - - Checkout API flows to Payment provider: Payment API - - - - Checkout API flows to Event bus: OrderPlaced - - - - Event bus flows to Fulfillment worker: OrderPlaced - - - - Event bus flows to Notification worker: OrderPlaced - - - - Notification worker flows to Email provider: Send receipt - - - - - - Customer - - - Customer - - - Web storefront: Next.js - - - Web storefront - Next.js - - - Edge gateway - - - Edge gateway - - - Catalog API: Products and pricing - - - Catalog API - Products and pricing - - - Checkout API: Order orchestration - - - Checkout API - Order orchestration - - - Event bus - - - Event bus - - - Fulfillment worker - - - Fulfillment worker - - - Notification worker - - - Notification worker - - - Product database - - - - Product database - - - Order database - - - - Order database - - - Product media - - - Product media - - - Payment provider - - - Payment provider - - - Email provider - - - Email provider - - - - diff --git a/docs/public/examples/gcp-data-service.svg b/docs/public/examples/gcp-data-service.svg deleted file mode 100644 index 60396b1..0000000 --- a/docs/public/examples/gcp-data-service.svg +++ /dev/null @@ -1,111 +0,0 @@ - - - GCP data service - Architecture diagram with 6 nodes, 2 groups, and 5 relationships. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - GCP data service - - - Application - - Application - - - Analytics - - Analytics - - - - - Web client flows to Cloud Run API: HTTPS - - - - Cloud Run API flows to Cloud SQL: SQL - - - - Cloud Run API flows to Cloud Storage: Uploads - - - - Cloud Run API flows to BigQuery: Events - - - - Analyst flows to BigQuery: Queries - - - - - - Web client - - - Web client - - - Cloud Run API - - - Cloud Run API - - - Cloud SQL - - - - Cloud SQL - - - Cloud Storage - - - Cloud Storage - - - BigQuery - - - - BigQuery - - - Analyst - - - Analyst - - - - diff --git a/docs/public/examples/github-delivery-workflow.svg b/docs/public/examples/github-delivery-workflow.svg deleted file mode 100644 index a42b011..0000000 --- a/docs/public/examples/github-delivery-workflow.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - GitHub delivery workflow - Architecture diagram with 5 nodes, 2 groups, and 4 relationships. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - GitHub delivery workflow - - - Source - - Source - - - Delivery - - Delivery - - - - - Developer flows to GitHub repository: Push - - - - GitHub repository flows to GitHub Actions: Workflow - - - - GitHub Actions flows to Vercel: Deploy - - - - Vercel flows to Sentry: Report errors - - - - - - Developer - - - Developer - - - GitHub repository - - - GitHub repository - - - GitHub Actions - - - GitHub Actions - - - Vercel - - - Vercel - - - Sentry - - - Sentry - - - - diff --git a/docs/public/examples/groups-and-layout.svg b/docs/public/examples/groups-and-layout.svg deleted file mode 100644 index d19d7f7..0000000 --- a/docs/public/examples/groups-and-layout.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - Public application - Architecture diagram with 5 nodes, 2 groups, and 4 relationships. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - Public application - - - Clients - - Clients - - - Platform - - Platform - - - - - Browser flows to Edge gateway: HTTPS - - - - Mobile app flows to Edge gateway: HTTPS - - - - Edge gateway flows to Application API: HTTPS - - - - Application API flows to Primary database: SQL - - - - - - Browser - - - Browser - - - Mobile app - - - Mobile app - - - Edge gateway - - - Edge gateway - - - Application API - - - Application API - - - Primary database - - - - Primary database - - - - diff --git a/docs/public/examples/hello-stack.svg b/docs/public/examples/hello-stack.svg deleted file mode 100644 index 36d97c7..0000000 --- a/docs/public/examples/hello-stack.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - Hello Stack - Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - Hello Stack - - - - - Web app flows to API - - - - - - Web app - - - Web app - - - API - - - API - - - - diff --git a/docs/public/examples/mixed-provider-platform.svg b/docs/public/examples/mixed-provider-platform.svg deleted file mode 100644 index 79ec4b8..0000000 --- a/docs/public/examples/mixed-provider-platform.svg +++ /dev/null @@ -1,133 +0,0 @@ - - - Mixed-provider platform - Architecture diagram with 7 nodes, 4 groups, and 6 relationships. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - - - - - - - Mixed-provider platform - - - Platform - - Platform - - - Experience - - Experience - - - Processing - - Processing - - - Operations - - Operations - - - - - Customer flows to Web application: HTTPS - - - - Web application is connected bidirectionally with Public API: Live checkout - - - - Public API flows to Checkout: Invoke - - - - Checkout flows to Order events: OrderPlaced - - - - Checkout flows to Orders: Transaction - - - - Public API is associated with Error monitoring: Telemetry - - - - - - Customer - - - Customer - - - Web application - - - Web application - - - Public API - - - Public API - - - Checkout - - - Checkout - - - Order events - - - Order events - - - Orders - - - - Orders - - - Error monitoring - - - Error monitoring - - - - diff --git a/package.json b/package.json index a960e55..74a96b4 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "docs:preview": "vitepress preview docs", "examples:check": "node scripts/validate-example-corpus.mjs", "examples:check:source": "node scripts/sync-example-corpus.mjs --check", - "examples:generate": "node scripts/validate-example-corpus.mjs --write-thumbnails", "examples:sync": "node scripts/sync-example-corpus.mjs", "format": "oxfmt --write .", "format:check": "oxfmt --check .", diff --git a/scripts/docs-source.json b/scripts/docs-source.json index 69e33bb..333db85 100644 --- a/scripts/docs-source.json +++ b/scripts/docs-source.json @@ -1,5 +1,5 @@ { "repository": "stack-sh/docs", - "revision": "86c51be1ddcabd48b326bacdb6088eb2a2ff79bf", - "manifestSha256": "9e41cdce9df59d8b63e81691cce3da595df206d02c6ed79c7a1adec8e0f6d500" + "revision": "d61b1031cf54c90f10a009c804b77a9e3719c6e6", + "manifestSha256": "0c347301fd55ef39d24f02915d5ec0e78a313b072b37887d48b8ded69c28b7ae" } diff --git a/scripts/runtime-examples.test.mjs b/scripts/runtime-examples.test.mjs new file mode 100644 index 0000000..3c0af4c --- /dev/null +++ b/scripts/runtime-examples.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict" +import { readFile, readdir } from "node:fs/promises" +import test from "node:test" + +test("documentation examples cannot retain pre-rendered SVG counterparts", async () => { + const catalog = JSON.parse(await readFile("example-corpus/catalog.json", "utf8")) + for (const root of ["public", "docs/public"]) { + const files = await readdir(root, { recursive: true }) + for (const example of catalog.examples) { + assert.ok( + !files.some((file) => file.endsWith(`${example.id}.svg`)), + `${example.id} must render at runtime`, + ) + } + } + const gallery = await readFile("docs/.vitepress/theme/components/ExampleGallery.vue", "utf8") + assert.match(gallery, /]*\bviewBox="[^"]+"/) assert.doesNotMatch(rendered.svg, / entry.endsWith(".svg")) - .sort() -assert.deepEqual( - actualThumbnails, - expectedThumbnails.sort(), - "Example thumbnail inventory has drifted", -) console.log( - `${writeThumbnails ? "Generated" : "Validated"} ${catalog.examples.length} checked, rendered, and provider-resolved example thumbnails with @stack-sh/engine 0.7.0.`, + `Validated ${catalog.examples.length} canonical examples with @stack-sh/engine 0.7.0; no SVG files are generated.`, ) diff --git a/src/lib/example-preview.test.ts b/src/lib/example-preview.test.ts new file mode 100644 index 0000000..0270469 --- /dev/null +++ b/src/lib/example-preview.test.ts @@ -0,0 +1,121 @@ +import type { RenderResult } from "@stack-sh/engine" +import { expect, it, vi } from "vitest" +import { createExamplePreview } from "./example-preview" + +const result = { + svg: '', + diagnostics: [], +} as unknown as RenderResult + +it("renders into an isolated image and releases URLs on replacement and disposal", async () => { + const update = vi.fn() + const render = vi.fn().mockResolvedValue(result) + const preview = createExamplePreview(render, update) + await preview.load("first.stack") + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(update).toHaveBeenLastCalledWith({ status: "ready", result, url: "blob:stack-preview" }) + await preview.load("second.stack") + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1) + preview.dispose() + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2) + await preview.load("third.stack") + expect(render).toHaveBeenCalledTimes(2) +}) + +it("drops an obsolete render after a source change", async () => { + let finish!: (result: RenderResult) => void + const update = vi.fn() + const render = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + .mockResolvedValue(result) + const preview = createExamplePreview(render, update) + const pending = preview.load("old.stack") + await preview.load("new.stack") + finish(result) + await pending + expect(URL.createObjectURL).toHaveBeenCalledOnce() + preview.dispose() +}) + +it("does not allocate or publish a late image after navigation", async () => { + let finish!: (result: RenderResult) => void + const update = vi.fn() + const preview = createExamplePreview( + () => + new Promise((resolve) => { + finish = resolve + }), + update, + ) + const pending = preview.load("example.stack") + preview.dispose() + finish(result) + await pending + expect(URL.createObjectURL).not.toHaveBeenCalled() + expect(update).toHaveBeenCalledTimes(1) +}) + +it("keeps errors separate from operational failures and retains warning diagnostics", async () => { + const update = vi.fn() + const diagnosticFields = { + range: { + start: { byteOffset: 0, line: 1, column: 1 }, + end: { byteOffset: 1, line: 1, column: 2 }, + }, + expected: [], + help: null, + related: [], + } + const errors: RenderResult = { + ...result, + diagnostics: [ + { ...diagnosticFields, severity: "error", code: "STK1001", message: "Invalid source" }, + ], + } + const warnings: RenderResult = { + ...result, + diagnostics: [ + { + ...diagnosticFields, + severity: "warning", + code: "STK5001", + message: "Missing provider icon", + }, + ], + } + const render = vi.fn().mockResolvedValueOnce(errors).mockResolvedValueOnce(warnings) + const preview = createExamplePreview(render, update) + await preview.load("example.stack") + expect(update).toHaveBeenLastCalledWith({ status: "diagnostics", result: errors }) + expect(URL.createObjectURL).not.toHaveBeenCalled() + await preview.load("example.stack") + expect(update).toHaveBeenLastCalledWith({ + status: "ready", + result: warnings, + url: "blob:stack-preview", + }) + preview.dispose() +}) + +it("offers a recoverable operational state for loading failures or missing SVG", async () => { + const update = vi.fn() + const render = vi + .fn() + .mockRejectedValueOnce(new Error("network")) + .mockResolvedValueOnce({ ...result, svg: null }) + .mockResolvedValue(result) + const preview = createExamplePreview(render, update) + for (let attempt = 0; attempt < 2; attempt++) { + await preview.load("example.stack") + expect(update).toHaveBeenLastCalledWith({ status: "unavailable" }) + } + await preview.load("example.stack") + expect(update).toHaveBeenLastCalledWith({ status: "ready", result, url: "blob:stack-preview" }) + preview.dispose() +}) diff --git a/src/lib/example-preview.ts b/src/lib/example-preview.ts new file mode 100644 index 0000000..c443387 --- /dev/null +++ b/src/lib/example-preview.ts @@ -0,0 +1,48 @@ +import type { RenderResult } from "@stack-sh/engine" + +export type ExamplePreviewState = + | { status: "idle" | "loading" | "unavailable" } + | { status: "diagnostics"; result: RenderResult } + | { status: "ready"; result: RenderResult; url: string } + +/** Own image URLs and discard late results after navigation or source changes. */ +export function createExamplePreview( + render: (filename: string) => Promise, + update: (state: ExamplePreviewState) => void, +) { + let generation = 0 + let disposed = false + let imageUrl: string | undefined + + function releaseImage() { + if (imageUrl) URL.revokeObjectURL(imageUrl) + imageUrl = undefined + } + + return { + async load(filename: string) { + if (disposed) return + const current = ++generation + releaseImage() + update({ status: "loading" }) + try { + const result = await render(filename) + if (disposed || current !== generation) return + if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error")) { + update({ status: "diagnostics", result }) + return + } + if (!result.svg) throw new Error("Example did not produce an SVG") + imageUrl = URL.createObjectURL(new Blob([result.svg], { type: "image/svg+xml" })) + update({ status: "ready", result, url: imageUrl }) + } catch { + if (!disposed && current === generation) update({ status: "unavailable" }) + } + }, + dispose() { + disposed = true + generation++ + releaseImage() + }, + } +} diff --git a/src/lib/render-example.test.ts b/src/lib/render-example.test.ts new file mode 100644 index 0000000..e5d3457 --- /dev/null +++ b/src/lib/render-example.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import corpus from "../../example-corpus/catalog.json" +import { EXAMPLE_SOURCE } from "./example" +import { renderExample } from "./render-example" +import { initializeStackEngine, renderStack } from "./stack-engine" + +vi.mock("./stack-engine", () => ({ + initializeStackEngine: vi.fn().mockResolvedValue(undefined), + renderStack: vi.fn().mockReturnValue({ svg: "", diagnostics: [] }), +})) + +beforeEach(() => vi.mocked(initializeStackEngine).mockResolvedValue(undefined)) + +describe("canonical runtime examples", () => { + it("renders every catalog source through the shared Playground adapter", async () => { + for (const example of corpus.examples) { + await renderExample(example.source) + expect(renderStack).toHaveBeenLastCalledWith(expect.stringContaining("stack 1.0")) + } + expect(initializeStackEngine).toHaveBeenCalledTimes(corpus.examples.length) + await renderExample("04-commerce-platform.stack") + expect(renderStack).toHaveBeenLastCalledWith(EXAMPLE_SOURCE) + }) + + it("rejects unknown paths without fetching or initializing the engine", async () => { + await expect(renderExample("../../private.stack")).rejects.toThrow("Unknown canonical example") + expect(initializeStackEngine).not.toHaveBeenCalled() + }) + + it("can retry after a failed engine load", async () => { + vi.mocked(initializeStackEngine).mockRejectedValueOnce(new Error("offline")) + await expect(renderExample("04-commerce-platform.stack")).rejects.toThrow("offline") + expect(renderStack).not.toHaveBeenCalled() + await renderExample("04-commerce-platform.stack") + expect(renderStack).toHaveBeenCalledOnce() + }) +}) diff --git a/src/lib/render-example.ts b/src/lib/render-example.ts new file mode 100644 index 0000000..9689cfb --- /dev/null +++ b/src/lib/render-example.ts @@ -0,0 +1,13 @@ +const sources = import.meta.glob("../../example-corpus/sources/*.stack", { + query: "?raw", + import: "default", +}) + +/** Load the pinned source, never a separately generated image. */ +export async function renderExample(filename: string) { + const loadSource = sources[`../../example-corpus/sources/${filename}`] + if (!loadSource) throw new Error("Unknown canonical example") + const [source, engine] = await Promise.all([loadSource(), import("./stack-engine")]) + await engine.initializeStackEngine() + return engine.renderStack(source) +} diff --git a/src/lib/stack-engine.test.ts b/src/lib/stack-engine.test.ts new file mode 100644 index 0000000..821d727 --- /dev/null +++ b/src/lib/stack-engine.test.ts @@ -0,0 +1,18 @@ +import { expect, it, vi } from "vitest" +import init from "@stack-sh/engine" +import { initializeStackEngine } from "./stack-engine" + +vi.mock("@stack-sh/engine", () => ({ default: vi.fn() })) + +it("shares WASM initialization and permits a fresh attempt after failure", async () => { + vi.mocked(init).mockRejectedValueOnce(new Error("offline")) + const first = initializeStackEngine() + expect(initializeStackEngine()).toBe(first) + await expect(first).rejects.toThrow("offline") + vi.mocked(init).mockResolvedValueOnce({} as Awaited>) + const retry = initializeStackEngine() + expect(retry).not.toBe(first) + await retry + expect(initializeStackEngine()).toBe(retry) + expect(init).toHaveBeenCalledTimes(2) +}) diff --git a/src/lib/stack-engine.ts b/src/lib/stack-engine.ts index 1498033..366a4bc 100644 --- a/src/lib/stack-engine.ts +++ b/src/lib/stack-engine.ts @@ -19,7 +19,12 @@ import init, { let initialization: Promise | undefined export function initializeStackEngine(): Promise { - initialization ??= init().then(() => undefined) + initialization ??= init() + .then(() => undefined) + .catch((error) => { + initialization = undefined + throw error + }) return initialization }