From c68f4179a50d69bf36b9fb994ffc4d322451b043 Mon Sep 17 00:00:00 2001 From: a-dev <1537796+a-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:50:27 +0400 Subject: [PATCH 1/5] feat: implement quiz link handling and validation limits --- e2e/fixtures/quiz-link.ts | 30 + e2e/fixtures/quizzes.ts | 23 + e2e/import.e2e.ts | 67 +- scripts/create-quiz-validator.spec.ts | 31 + skills/create-quiz/scripts/validate-quiz.mjs | 4923 +++++++++-------- .../import-quiz/model/use-quiz-link.ts | 58 + .../model/validate-quiz-json.spec.ts | 33 +- .../import-quiz/model/validate-quiz-json.ts | 15 +- .../import-quiz/ui/import-quiz-form.test.tsx | 112 + .../import-quiz/ui/import-quiz-form.tsx | 26 + .../lib/content/catalog-profile.spec.ts | 11 + src/shared/lib/content/catalog-profile.ts | 31 +- src/shared/lib/quiz-link/index.ts | 9 + src/shared/lib/quiz-link/quiz-link.spec.ts | 185 + src/shared/lib/quiz-link/quiz-link.ts | 166 + src/shared/lib/quiz/import-limits.spec.ts | 70 + src/shared/lib/quiz/import-limits.ts | 75 + src/shared/lib/quiz/index.ts | 9 + 18 files changed, 3421 insertions(+), 2453 deletions(-) create mode 100644 e2e/fixtures/quiz-link.ts create mode 100644 src/features/import-quiz/model/use-quiz-link.ts create mode 100644 src/features/import-quiz/ui/import-quiz-form.test.tsx create mode 100644 src/shared/lib/quiz-link/index.ts create mode 100644 src/shared/lib/quiz-link/quiz-link.spec.ts create mode 100644 src/shared/lib/quiz-link/quiz-link.ts create mode 100644 src/shared/lib/quiz/import-limits.spec.ts create mode 100644 src/shared/lib/quiz/import-limits.ts diff --git a/e2e/fixtures/quiz-link.ts b/e2e/fixtures/quiz-link.ts new file mode 100644 index 0000000..c3d5154 --- /dev/null +++ b/e2e/fixtures/quiz-link.ts @@ -0,0 +1,30 @@ +import type { Page } from "@playwright/test"; + +import type { Quiz } from "../../src/shared/lib/quiz"; +import { encodeQuizLink, QUIZ_LINK_PARAM } from "../../src/shared/lib/quiz-link"; + +/** The Import path with a Quiz link fragment, built by the same encoder the site decodes with. */ +export async function quizLinkPath(quiz: Quiz): Promise { + return `/import/#${QUIZ_LINK_PARAM}=${await encodeQuizLink(JSON.stringify(quiz))}`; +} + +/** + * Sets the Import textarea without `locator.fill`, which hangs for minutes on + * large text even in a bare textarea. Goes through the native setter so React's + * controlled input sees the change. + */ +export async function pasteQuizJson(page: Page, quiz: Quiz): Promise { + await page.evaluate( + (json) => { + const textarea = document.querySelector("#quiz-json"); + + if (textarea === null) throw new Error("Import textarea not found."); + + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + + setValue?.call(textarea, json); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, + JSON.stringify(quiz, null, 2), + ); +} diff --git a/e2e/fixtures/quizzes.ts b/e2e/fixtures/quizzes.ts index 5c6d4e5..7bd9d5a 100644 --- a/e2e/fixtures/quizzes.ts +++ b/e2e/fixtures/quizzes.ts @@ -135,3 +135,26 @@ export const changedContentHashQuiz: Quiz = { }, ], }; + +/** A valid single-choice Quiz with `questionCount` Questions, for the Import limit journeys. */ +export function makeQuiz(questionCount: number, id = `e2e-generated-${questionCount}`): Quiz { + return { + schemaVersion: 1, + id, + title: `E2E — ${questionCount} Questions`, + description: "A generated Quiz for the Import limit journeys.", + language: "en", + tags: ["e2e", "generated"], + questions: Array.from({ length: questionCount }, (_, index) => ({ + id: `question-${index + 1}`, + type: "single-choice" as const, + title: `Question ${index + 1}: which value is falsy in JavaScript?`, + explanation: "`0` is one of JavaScript's built-in falsy values.", + options: [ + { text: "`[]`", isCorrect: false }, + { text: "`0`", isCorrect: true }, + { text: "`{}`", isCorrect: false }, + ], + })), + }; +} diff --git a/e2e/import.e2e.ts b/e2e/import.e2e.ts index b83bca5..0b95e6a 100644 --- a/e2e/import.e2e.ts +++ b/e2e/import.e2e.ts @@ -1,6 +1,12 @@ import { expect, test, type Page } from "@playwright/test"; -import { changedContentHashQuiz, contentHashQuiz, singleChoiceQuiz } from "./fixtures/quizzes"; +import { pasteQuizJson, quizLinkPath } from "./fixtures/quiz-link"; +import { + changedContentHashQuiz, + contentHashQuiz, + makeQuiz, + singleChoiceQuiz, +} from "./fixtures/quizzes"; import { questionGroup } from "./fixtures/player"; import { seedQuiz } from "./fixtures/seed"; @@ -109,3 +115,62 @@ test("Import invalidates only changed Questions when Content hashes differ", asy await expect(editedQuestion.getByRole("radio").first()).toBeEnabled(); await expect(editedQuestion.getByRole("button", { name: "Submit" })).toBeDisabled(); }); + +test("A Quiz link opens in Import, saves, and plays", async ({ page }) => { + await page.goto(await quizLinkPath(singleChoiceQuiz)); + + await expect(page.getByRole("article", { name: singleChoiceQuiz.title })).toBeVisible(); + // The fragment is cleared right away; nothing is saved until the explicit click. + expect(page.url()).not.toContain("qos"); + + await page.getByRole("button", { name: "Save to Library" }).click(); + + await expect(page).toHaveURL(/\/library\/quiz\/\?id=e2e-single-choice$/); + + await page.getByRole("button", { name: "Start" }).click(); + await page.getByRole("radio", { name: "0" }).check(); + await page.getByRole("button", { name: "Submit" }).click(); + + await expect(questionGroup(page, 1).getByRole("status")).toContainText("Correct!"); +}); + +test("Back after saving a linked Quiz does not bring the link payload back", async ({ page }) => { + await page.goto(await quizLinkPath(singleChoiceQuiz)); + await page.getByRole("button", { name: "Save to Library" }).click(); + await expect(page).toHaveURL(/\/library\/quiz\/\?id=e2e-single-choice$/); + + await page.goBack(); + + await expect(page).toHaveURL(/\/import\/$/); + expect(page.url()).not.toContain("qos"); +}); + +test("A link to a 200-Question Quiz previews and saves", async ({ page }) => { + const quiz = makeQuiz(200); + + await page.goto(await quizLinkPath(quiz)); + + await expect(page.getByRole("article", { name: quiz.title })).toBeVisible(); + + await page.getByRole("button", { name: "Save to Library" }).click(); + + await expect(page).toHaveURL(new RegExp(`/library/quiz/\\?id=${quiz.id}$`)); +}); + +test("A 201-Question Quiz gets the same Question-count report from a link and from a paste", async ({ + page, +}) => { + const quiz = makeQuiz(201); + const report = page.getByRole("alert"); + + await page.goto(await quizLinkPath(quiz)); + await expect(report).toContainText("201 Questions; Quizbun imports up to 200"); + const reportFromLink = (await report.textContent()) ?? ""; + + await page.goto("/import/"); + await pasteQuizJson(page, quiz); + await page.getByRole("button", { name: "Validate" }).click(); + + await expect(report).toContainText("201 Questions; Quizbun imports up to 200"); + await expect(report).toHaveText(reportFromLink); +}); diff --git a/scripts/create-quiz-validator.spec.ts b/scripts/create-quiz-validator.spec.ts index 06bc1eb..6590361 100644 --- a/scripts/create-quiz-validator.spec.ts +++ b/scripts/create-quiz-validator.spec.ts @@ -54,6 +54,37 @@ describe("create-quiz standalone validation", () => { }); }); +describe("Quizbun Import limits", () => { + function makeLargeQuiz(questionCount: number) { + const quiz = readFixture(); + const [question] = quiz.questions; + quiz.questions = Array.from({ length: questionCount }, (_, index) => ({ + ...question, + id: `question-${index}`, + })); + return quiz; + } + + test("keeps the Standard profile Renderer-neutral: 201 Questions still pass, silently", async () => { + const result = await validateStandardInput(JSON.stringify(makeLargeQuiz(201)), { + checkMedia: false, + }); + + expect(result.output).not.toMatch(/quizbun/i); + expect(result.warnings).toEqual([]); + }); + + test("rejects 201 Questions in the Catalog profile", async () => { + const directory = makeTemporaryDirectory(); + const quiz = makeLargeQuiz(201); + writeFileSync(join(directory, `${quiz.id}.json`), JSON.stringify(quiz)); + + await expect( + validateTarget(directory, { checkMedia: false, profile: "catalog" }), + ).rejects.toThrow(/Import limits[\s\S]*201 Questions/); + }); +}); + describe("Standard profile targets", () => { test("validates a single Quiz file", async () => { const result = await validateTarget(fixturePath, { checkMedia: false, profile: "standard" }); diff --git a/skills/create-quiz/scripts/validate-quiz.mjs b/skills/create-quiz/scripts/validate-quiz.mjs index b5e3d2e..99cc65f 100644 --- a/skills/create-quiz/scripts/validate-quiz.mjs +++ b/skills/create-quiz/scripts/validate-quiz.mjs @@ -1127,9 +1127,9 @@ class DomHandler { lastNode.endIndex = this.parser.endIndex; } } else { - const node2 = new Text2(data); - this.addNode(node2); - this.lastNode = node2; + const node = new Text2(data); + this.addNode(node); + this.lastNode = node; } } oncomment(data) { @@ -1137,26 +1137,26 @@ class DomHandler { this.lastNode.data += data; return; } - const node2 = new Comment2(data); - this.addNode(node2); - this.lastNode = node2; + const node = new Comment2(data); + this.addNode(node); + this.lastNode = node; } oncommentend() { this.lastNode = null; } oncdatastart() { const text = new Text2(""); - const node2 = new CDATA2([text]); - this.addNode(node2); - text.parent = node2; + const node = new CDATA2([text]); + this.addNode(node); + text.parent = node; this.lastNode = text; } oncdataend() { this.lastNode = null; } onprocessinginstruction(name, data) { - const node2 = new ProcessingInstruction(name, data); - this.addNode(node2); + const node = new ProcessingInstruction(name, data); + this.addNode(node); } handleCallback(error) { if (typeof this.callback === "function") { @@ -1165,21 +1165,21 @@ class DomHandler { throw error; } } - addNode(node2) { + addNode(node) { const parent = this.tagStack[this.tagStack.length - 1]; const previousSibling = parent.children[parent.children.length - 1]; if (this.options.withStartIndices && this.parser) { - node2.startIndex = this.parser.startIndex; + node.startIndex = this.parser.startIndex; } if (this.options.withEndIndices && this.parser) { - node2.endIndex = this.parser.endIndex; + node.endIndex = this.parser.endIndex; } - parent.children.push(node2); + parent.children.push(node); if (previousSibling) { - node2.prev = previousSibling; - previousSibling.next = node2; + node.prev = previousSibling; + previousSibling.next = node; } - node2.parent = parent; + node.parent = parent; this.lastNode = null; } } @@ -11980,1771 +11980,1067 @@ var mn = k.walkTokens; var xn = k.parseInline; var Rn = T.parse; var Tn = R.lex; - -// src/shared/lib/render/markdown.ts -init_dist3(); -init_dist4(); -init_dist6(); - -// stub-prismjs:prismjs -var prismjs_default = { highlight: (code) => code, languages: {} }; - -// src/shared/lib/render/markdown.ts -var import_sanitize_html = __toESM(require_sanitize_html(), 1); -var MARKDOWN_OPTIONS = { - async: false, - breaks: false, - gfm: true -}; -var INLINE_TAGS = ["a", "br", "code", "em", "strong"]; -var BLOCK_TAGS = [ - "blockquote", - "h1", - "h2", - "h3", - "h4", - "li", - "ol", - "p", - "pre", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - "ul" -]; -var PLAIN_TEXT_BLOCK_TAGS = new Set(BLOCK_TAGS); -var LANGUAGE_CLASS_PATTERN = /^language-[a-z0-9-]+$/; -var TOKEN_CLASS_PATTERN = /^[a-z][a-z0-9-]*$/; -var PRISM_LANGUAGE_BY_MARKDOWN_LANGUAGE = { - bash: "bash", - css: "css", - html: "markup", - javascript: "javascript", - js: "javascript", - json: "json", - jsx: "jsx", - py: "python", - python: "python", - sh: "bash", - shell: "bash", - sql: "sql", - ts: "typescript", - tsx: "tsx", - typescript: "typescript" -}; -var transformAnchor = (_tagName, attribs) => { - const href = attribs.href?.trim(); - if (!href) { +// src/shared/lib/quiz/format-errors.ts +function formatQuizValidationErrors(error) { + const issueReports = error.issues.map(formatIssueReport); + return [ + "Quiz JSON is invalid. Please revise it to satisfy the Quiz Object Standard.", + "", + ...issueReports + ].join(` +`); +} +function formatIssueReport(issue, index) { + const { path, problem, fix } = explainIssue(issue); + return [`${index + 1}. Path: \`${path}\``, ` Problem: ${problem}`, ` Fix: ${fix}`].join(` +`); +} +function explainIssue(issue) { + if (issue.code === "unrecognized_keys") { + const keys = issue.keys.map((key) => `\`${key}\``).join(", "); + const path = issue.keys.length === 1 ? formatPath([...issue.path, issue.keys[0] ?? ""]) : formatPath(issue.path); return { - attribs: {}, - tagName: "a" + path, + problem: `Unknown field${issue.keys.length === 1 ? "" : "s"} ${keys}.`, + fix: "Remove unknown fields; the Standard is strict at every level." }; } - const transformedAttribs = { href }; - if (/^https?:\/\//i.test(href)) { - transformedAttribs.rel = "noreferrer"; + if (isPath(issue.path, ["schemaVersion"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: 'Use `"schemaVersion": 1`. Version strings such as `"1.0"` are invalid.' + }; } - return { - attribs: transformedAttribs, - tagName: "a" - }; -}; -var INLINE_SANITIZE_OPTIONS = { - allowedAttributes: { - a: ["href", "rel"] - }, - allowedSchemes: ["http", "https", "mailto"], - allowedTags: [...INLINE_TAGS], - allowProtocolRelative: false, - transformTags: { - a: transformAnchor + if (!isMissingField(issue)) { + const mediaExplanation = explainMediaIssue(issue); + if (mediaExplanation !== undefined) { + return mediaExplanation; + } } -}; -var SANITIZE_OPTIONS = { - allowedAttributes: { - a: ["href", "rel"], - code: ["class"] - }, - allowedClasses: { - code: [LANGUAGE_CLASS_PATTERN] - }, - allowedSchemes: ["http", "https", "mailto"], - allowedTags: [...INLINE_TAGS, ...BLOCK_TAGS], - allowProtocolRelative: false, - transformTags: { - a: transformAnchor, - h1: (_tagName, attribs) => ({ - attribs, - tagName: "h2" - }) + return explainFieldIssue(issue); +} +function explainFieldIssue(issue) { + if (issue.code === "invalid_union" && "options" in issue && issue.options !== undefined) { + const options = issue.options.map((option) => `\`${String(option)}\``).join(", "); + return { + path: formatPath(issue.path), + problem: issue.message, + fix: `Use one of: ${options}.` + }; } -}; -var HIGHLIGHTED_SANITIZE_OPTIONS = { - ...SANITIZE_OPTIONS, - allowedAttributes: { - a: ["href", "rel"], - code: ["class"], - pre: ["class"], - span: ["class"] - }, - allowedClasses: { - code: [LANGUAGE_CLASS_PATTERN], - pre: [LANGUAGE_CLASS_PATTERN], - span: [TOKEN_CLASS_PATTERN] - }, - allowedTags: [...INLINE_TAGS, "span", ...BLOCK_TAGS] -}; -function markdownToSafeHtml(markdown) { - if (markdown.trim().length === 0) { - return ""; + if (isPathEnding(issue.path, ["validation"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: 'Add a `validation` object with `mode: "text"` or `mode: "numeric"` and at least one accepted answer.' + }; } - const safeHtml = import_sanitize_html.default(k.parse(markdown, MARKDOWN_OPTIONS), SANITIZE_OPTIONS); - return highlightCodeBlocks(safeHtml); -} -function renderMarkdown(markdown) { - return addHeadingIds(markdownToSafeHtml(markdown)); -} -function renderInlineMarkdown(markdown) { - if (markdown.trim().length === 0) { - return ""; + if (isMissingField(issue)) { + return { + path: formatPath(issue.path), + problem: "Required field is missing.", + fix: "Add this required field using the shape defined by the Standard." + }; } - return import_sanitize_html.default(k.parseInline(markdown, MARKDOWN_OPTIONS), INLINE_SANITIZE_OPTIONS); -} -function highlightCodeBlocks(html) { - if (!html.includes(" isTag2(child) && child.name === "code"); - if (code === undefined) { - return; - } - const markdownLanguage = getMarkdownCodeLanguage(code.attribs.class); - if (markdownLanguage === undefined) { - return; +function explainRefinementIssue(issue) { + if (issue.code === "custom" && isPathEnding(issue.path, ["options"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Set the required correct Options: exactly one for `single-choice`, at least one for `multiple-choice`." + }; } - const prismLanguage = PRISM_LANGUAGE_BY_MARKDOWN_LANGUAGE[markdownLanguage]; - const grammar = prismjs_default.languages[prismLanguage]; - if (grammar === undefined) { - return; + if (issue.code === "custom" && isPathEnding(issue.path, ["id"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Give each Question a unique `id` within this Quiz." + }; } - const languageClass = `language-${markdownLanguage}`; - const highlightedHtml = prismjs_default.highlight(extractText(code.children, { separateBlocks: false }), grammar, prismLanguage); - pre.attribs.class = languageClass; - code.attribs.class = languageClass; - code.children = parseHtmlFragment(highlightedHtml); -} -function getMarkdownCodeLanguage(className) { - const language = className?.split(/\s+/).map((part) => part.match(/^language-([a-z0-9-]+)$/)?.[1]).find((part) => part !== undefined && (part in PRISM_LANGUAGE_BY_MARKDOWN_LANGUAGE)); - return language; + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Change this value to match the Standard at the reported path." + }; } -function createHeadingSlug(text) { - const baseSlug = text.replace(/<[^<>]+>/g, " ").toLowerCase().trim().replace(/&[a-z0-9#]+;/gi, " ").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); - return baseSlug.length > 0 ? baseSlug : "section"; +function isMissingField(issue) { + return issue.message === "Required field missing."; } -function addHeadingIds(html) { - const seenHeadings = new Map; - return html.replace(/(.*?)<\/h\1>/g, (_match, level, content) => { - const baseSlug = createHeadingSlug(content); - const duplicateCount = seenHeadings.get(baseSlug) ?? 0; - const nextCount = duplicateCount + 1; - seenHeadings.set(baseSlug, nextCount); - const slug = nextCount === 1 ? baseSlug : `${baseSlug}-${nextCount}`; - return `${content}`; - }); +function explainMediaIssue(issue) { + if (isPathEnding(issue.path, ["src"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Use a bare asset filename such as `cache-tiers.svg`, or an `https://` URL. `http://`, protocol-relative, and `data:` sources are not accepted." + }; + } + if (isPathEnding(issue.path, ["placement"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Use `question` or `explanation`, or omit `placement` entirely — an absent `placement` already means `question`." + }; + } + if (isPathEnding(issue.path, ["width"]) || isPathEnding(issue.path, ["height"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Set both to the image file's own pixel size, or omit both. Never estimate them: an absent pair is valid, a wrong one is not. In the Quizbun repository, `bun run quiz:sizes:generate` writes them for you." + }; + } + if (isPathEnding(issue.path, ["provider"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "`youtube` is the only video provider in version 1." + }; + } + if (isVideoIdPath(issue.path)) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Take the 11 characters after `v=` or `youtu.be/` in the video URL and use those alone." + }; + } + if (isPathEnding(issue.path, ["start"])) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Use a whole number of seconds, such as `90` for one minute thirty." + }; + } + if (issue.code === "too_small" && (isPathEnding(issue.path, ["images"]) || isPathEnding(issue.path, ["videos"]))) { + return { + path: formatPath(issue.path), + problem: issue.message, + fix: "Both media fields are optional; omit the field instead of leaving it empty." + }; + } + return; } -function parseHtmlFragment(html) { - return parseDocument(html, { decodeEntities: true }).children; +function formatPath(path) { + if (path.length === 0) { + return "root"; + } + return path.reduce((formattedPath, segment) => { + if (typeof segment === "number") { + return `${formattedPath}[${segment}]`; + } + const key = String(segment); + return formattedPath.length === 0 ? key : `${formattedPath}.${key}`; + }, ""); } -function renderHtmlFragment(nodes) { - return dist_default(nodes, { encodeEntities: "utf8" }); +function isPath(path, expectedPath) { + return path.length === expectedPath.length && path.every((segment, index) => segment === expectedPath[index]); } -function extractText(nodes, options) { - let text = ""; - for (const node of nodes) { - if (isText(node)) { - text += node.data; - continue; - } - if (isTag2(node) && node.name === "br") { - text += ` -`; - continue; - } - if (!hasChildren(node)) { - continue; - } - const inner = extractText(node.children, options); - text += options.separateBlocks && isTag2(node) && PLAIN_TEXT_BLOCK_TAGS.has(node.name) ? ` -${inner} -` : inner; +function isPathEnding(path, ending) { + if (path.length < ending.length) { + return false; } - return text; + return ending.every((segment, index) => path[path.length - ending.length + index] === segment); } -// src/shared/lib/render/tiers.ts -var MARKDOWN_FIELD_TIERS = { - acceptedAnswerDisplay: "inline", - explanation: "full", - imageCaption: "inline", - optionText: "inline", - questionDescription: "full", - questionReferences: "full", - questionTitle: "inline", - quizDescription: "full", - quizTitle: "inline", - tag: "plain" -}; -function escapeHtml(text) { - return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +function isIdLikePath(path) { + const lastSegment = path.at(-1); + const parentSegment = path.at(-2); + return lastSegment === "id" || parentSegment === "tags"; } -function renderMarkdownField(field, value) { - switch (MARKDOWN_FIELD_TIERS[field]) { - case "full": - return renderMarkdown(value); - case "inline": - return renderInlineMarkdown(value); - case "plain": - return escapeHtml(value); - } +function isVideoIdPath(path) { + return path.at(-1) === "id" && path.at(-3) === "videos"; } -// src/shared/lib/content/catalog-profile.ts -function checkCatalogProfile(quiz) { - const issues = []; - if (quiz.description === undefined) { - issues.push({ - severity: "error", - path: "description", - problem: "The Public catalog profile requires a `description`.", - fix: "Add a short `description` explaining what the quiz covers and who it is for." - }); - } - if (quiz.language === undefined) { - issues.push({ - severity: "error", - path: "language", - problem: "The Public catalog profile requires a `language`.", - fix: 'Add a BCP-47 `language` tag such as `"en"`.' - }); - } - if (quiz.tags.length === 0) { - issues.push({ - severity: "error", - path: "tags", - problem: "The Public catalog profile requires at least one Tag.", - fix: "Add one or more kebab-case Tags so the quiz is discoverable in the Catalog filter." - }); - } - issues.push(...checkRepeatedImageCaptions(quiz)); - for (const entry of listMarkdownFields(quiz)) { - issues.push(...checkMarkdownField(entry)); - } - return issues; +// src/shared/lib/quiz/import-limits.ts +var MAX_IMPORT_QUESTIONS = 200; +var MAX_IMPORT_BYTES = 1048576; +function utf8ByteLength(text) { + return new TextEncoder().encode(text).byteLength; } -function checkRepeatedImageCaptions(quiz) { - const issues = []; - const firstPathByCaption = new Map; - for (const [questionIndex, question] of quiz.questions.entries()) { - for (const [imageIndex, image] of (question.images ?? []).entries()) { - if (image.caption === undefined) - continue; - const path = `questions[${questionIndex}].images[${imageIndex}].caption`; - const firstPath = firstPathByCaption.get(image.caption); - if (firstPath === undefined) { - firstPathByCaption.set(image.caption, path); - continue; - } - issues.push({ - severity: "error", - path, - problem: `The Image caption repeats the caption at \`${firstPath}\`.`, - fix: "Use a different Image, or rewrite this caption to explain what the learner should notice here." - }); +// src/shared/lib/quiz/parse-quiz-json.ts +function parseQuizJson(rawText, sourceLabel) { + try { + return JSON.parse(rawText); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error([ + `JSON syntax error in ${sourceLabel}:`, + "Path: `root`", + `Problem: ${error.message}`, + "Fix: Provide exactly one valid JSON object with no markdown fences, comments, or trailing commas." + ].join(` +`), { cause: error }); } + throw error; } - return issues; } -function formatProfileIssues(fileLabel, issues) { - const reports = issues.map((issue, index) => [ - `${index + 1}. ${issue.severity === "warning" ? "Warning" : "Problem"} at path: \`${issue.path}\``, - ` Problem: ${issue.problem}`, - ` Fix: ${issue.fix}` - ].join(` -`)); - return [ - `Public quiz does not satisfy the Public catalog profile in ${fileLabel}:`, - "", - ...reports - ].join(` -`); +// node_modules/zod/v4/core/util.js +function getEnumValues(entries) { + const numericValues = Object.values(entries).filter((v) => typeof v === "number"); + const values = Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v); + return values; } -function checkMarkdownField(entry) { - const issues = []; - const tokens = k.lexer(entry.value); - if (containsRawHtml(tokens)) { - issues.push({ - severity: "error", - path: entry.path, - problem: "Raw HTML in the source text. The renderer always strips it.", - fix: "Rewrite the HTML as Markdown (e.g. `**bold**`, `` `code` ``, `[text](url)`)." - }); +function joinValues(array, separator = "|") { + return array.map((val) => stringifyPrimitive(val)).join(separator); +} +function jsonStringifyReplacer(_, value) { + if (typeof value === "bigint") + return value.toString(); + return value; +} + +class Cached { + constructor(getter) { + this._getter = getter; + this._value = undefined; } - if (MARKDOWN_FIELD_TIERS[entry.field] === "inline" && containsBlockMarkdown(tokens)) { - issues.push({ - severity: "warning", - path: entry.path, - problem: "Block Markdown (headings, lists, code blocks, or multiple paragraphs) in a short field. It degrades to literal text.", - fix: "Keep short fields to one line of inline Markdown, or move the long content to `description` / `explanation`." - }); + get value() { + const getter = this._getter; + if (getter !== undefined) { + this._value = getter(); + this._getter = undefined; + } + return this._value; } - if (renderMarkdownField(entry.field, entry.value).trim().length === 0) { - issues.push({ - severity: "error", - path: entry.path, - problem: "The field is empty after Markdown rendering and sanitization.", - fix: "Replace the content with text that survives sanitization (no raw-HTML-only values)." - }); - } - return issues; } -function* listMarkdownFields(quiz) { - yield { field: "quizTitle", path: "title", value: quiz.title }; - if (quiz.description !== undefined) { - yield { field: "quizDescription", path: "description", value: quiz.description }; - } - for (const [index, question] of quiz.questions.entries()) { - const questionPath = `questions[${index}]`; - yield { field: "questionTitle", path: `${questionPath}.title`, value: question.title }; - if (question.description !== undefined) { - yield { - field: "questionDescription", - path: `${questionPath}.description`, - value: question.description - }; - } - yield { - field: "explanation", - path: `${questionPath}.explanation`, - value: question.explanation - }; - if (question.type === "single-choice" || question.type === "multiple-choice") { - for (const [optionIndex, option] of question.options.entries()) { - yield { - field: "optionText", - path: `${questionPath}.options[${optionIndex}].text`, - value: option.text - }; - } - } - if (question.type === "input" && question.validation.mode === "text") { - for (const [answerIndex, answer] of question.validation.acceptedAnswers.entries()) { - yield { - field: "acceptedAnswerDisplay", - path: `${questionPath}.validation.acceptedAnswers[${answerIndex}]`, - value: answer - }; - } - } - } +function cached(getter) { + return new Cached(getter); } -function containsRawHtml(tokens) { - return tokens.some((token) => { - if (token.type === "html") { - return true; - } - if ("tokens" in token && token.tokens !== undefined && containsRawHtml(token.tokens)) { - return true; - } - return "items" in token && containsRawHtml(token.items); - }); +function nullish(input) { + return input === null || input === undefined; } -var INLINE_SAFE_BLOCK_TOKEN_TYPES = new Set(["paragraph", "space", "text"]); -function containsBlockMarkdown(tokens) { - const blockTokens = tokens.filter((token) => token.type !== "space"); - if (blockTokens.length > 1) { - return true; - } - return blockTokens.some((token) => !INLINE_SAFE_BLOCK_TOKEN_TYPES.has(token.type)); +function cleanRegex(source) { + const start = source.startsWith("^") ? 1 : 0; + const end = source.endsWith("$") ? source.length - 1 : source.length; + return source.slice(start, end); } -// src/shared/lib/content/image-dimensions.ts -import { readFileSync } from "node:fs"; -import { extname } from "node:path"; -var SUPPORTED_EXTENSIONS = [".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]; -var PIXELS_PER_UNIT = { - "": 1, - cm: 96 / 2.54, - in: 96, - mm: 96 / 25.4, - pc: 16, - pt: 96 / 72, - px: 1, - q: 96 / 25.4 / 4 -}; -function isSupportedImageExtension(fileName) { - return SUPPORTED_EXTENSIONS.includes(extname(fileName).toLowerCase()); +function floatSafeRemainder(val, step) { + const ratio = val / step; + const roundedRatio = Math.round(ratio); + const tolerance = 4 * Number.EPSILON * Math.max(Math.abs(ratio), 1); + if (Math.abs(ratio - roundedRatio) < tolerance) + return 0; + return ratio - roundedRatio; } -function readImageDimensions(filePath) { - const extension = extname(filePath).toLowerCase(); - if (!isSupportedImageExtension(filePath)) { - throw new Error(`Cannot measure "${extension || filePath}": supported formats are ${SUPPORTED_EXTENSIONS.join(", ")}.`); - } - const bytes = readFileSync(filePath); - if (extension === ".svg") { - return parseSvgDimensions(bytes.toString("utf8")); - } - if (extension === ".png") - return parsePngDimensions(bytes); - if (extension === ".gif") - return parseGifDimensions(bytes); - if (extension === ".webp") - return parseWebpDimensions(bytes); - if (extension === ".avif") - return parseAvifDimensions(bytes); - return parseJpegDimensions(bytes); +function assignProp(target, prop, value) { + Object.defineProperty(target, prop, { + value, + writable: true, + enumerable: true, + configurable: true + }); } -function parseSvgDimensions(source) { - const rootTag = findSvgRootTag(source); - if (rootTag === undefined) { - throw new Error("SVG has no root `` element."); - } - const width = parseSvgLength(readAttribute(rootTag, "width")); - const height = parseSvgLength(readAttribute(rootTag, "height")); - if (width !== undefined && height !== undefined) { - return toDimensions(width, height); - } - const viewBox = readAttribute(rootTag, "viewBox"); - const viewBoxNumbers = viewBox?.trim().split(/[\s,]+/).map(Number); - if (viewBoxNumbers?.length === 4 && viewBoxNumbers.every((value) => Number.isFinite(value))) { - const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers; - if (viewBoxWidth > 0 && viewBoxHeight > 0) { - return toDimensions(viewBoxWidth, viewBoxHeight); - } - } - throw new Error("SVG has no intrinsic size: add a `viewBox`, or `width` and `height` in absolute units."); +function rawShape(def) { + const desc = Object.getOwnPropertyDescriptor(def, "shape"); + return desc?.get ? desc.get.raw : desc?.value; } -var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); -function parsePngDimensions(bytes) { - const hasSignature = bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE); - if (!hasSignature || bytes.subarray(12, 16).toString("latin1") !== "IHDR") { - throw new Error("PNG is missing its signature or IHDR header chunk."); - } - return toDimensions(bytes.readUInt32BE(16), bytes.readUInt32BE(20)); +function sourceShape(schema) { + return rawShape(schema._zod.def) ?? schema._zod.def.shape; } -function parseGifDimensions(bytes) { - const header = bytes.length >= 10 ? bytes.subarray(0, 6).toString("latin1") : ""; - if (header !== "GIF87a" && header !== "GIF89a") { - throw new Error("GIF is missing its `GIF87a`/`GIF89a` header."); - } - return toDimensions(bytes.readUInt16LE(6), bytes.readUInt16LE(8)); +function deferProp(target, key, getter) { + Object.defineProperty(target, key, { + get() { + const value = getter(); + assignProp(this, key, value); + return value; + }, + enumerable: true, + configurable: true + }); } -function parseJpegDimensions(bytes) { - if (bytes.length < 4 || bytes.readUInt16BE(0) !== 65496) { - throw new Error("JPEG is missing its Start-of-Image marker."); - } - let offset = 2; - while (offset < bytes.length) { - const next = readNextMarker(bytes, offset); - if (next === undefined) - break; - const { marker } = next; - offset = next.offset; - if (isStandaloneMarker(marker)) +function putProp(target, key, value) { + if (key in target) + assignProp(target, key, value); + else + target[key] = value; +} +function mirrorShape(target, source, keys, wrap) { + const raw = sourceShape(source); + for (const key of keys) { + const desc = Object.getOwnPropertyDescriptor(raw, key); + if (!desc.enumerable) continue; - if (offset + 2 > bytes.length) - break; - if (marker === 218) - break; - if (isStartOfFrameMarker(marker)) - return readStartOfFrame(bytes, offset); - offset += bytes.readUInt16BE(offset); + if (desc.get) { + deferProp(target, key, () => { + const value = source._zod.def.shape[key]; + return wrap ? wrap(value, key) : value; + }); + } else + putProp(target, key, wrap ? wrap(desc.value, key) : desc.value); } - throw new Error("JPEG has no Start-of-Frame segment."); } -function parseWebpDimensions(bytes) { - const isRiffWebp = bytes.length >= 16 && bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP"; - if (!isRiffWebp) { - throw new Error("WebP is missing its RIFF/WEBP header."); +function mirrorProps(target, source) { + for (const key of Reflect.ownKeys(source)) { + const desc = Object.getOwnPropertyDescriptor(source, key); + if (!desc.enumerable) + continue; + if (desc.get) + deferProp(target, key, () => source[key]); + else + putProp(target, key, desc.value); } - let offset = 12; - while (offset + 8 <= bytes.length) { - const chunkType = bytes.subarray(offset, offset + 4).toString("latin1"); - const chunkSize = bytes.readUInt32LE(offset + 4); - const data = offset + 8; - const dimensions = readWebpImageChunk(bytes, chunkType, data); - if (dimensions !== undefined) - return dimensions; - offset = data + chunkSize + chunkSize % 2; +} +function mergeDefs(...defs) { + const mergedDescriptors = {}; + for (const def of defs) { + const descriptors = Object.getOwnPropertyDescriptors(def); + Object.assign(mergedDescriptors, descriptors); } - throw new Error("WebP has no `VP8X`, `VP8 `, or `VP8L` chunk."); + return Object.defineProperties({}, mergedDescriptors); } -function readWebpImageChunk(bytes, chunkType, data) { - if (chunkType === "VP8X" && data + 10 <= bytes.length) { - return toDimensions(readUInt24LE(bytes, data + 4) + 1, readUInt24LE(bytes, data + 7) + 1); +function esc(str) { + return JSON.stringify(str); +} +function slugify(input) { + return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); +} +var captureStackTrace = "captureStackTrace" in Error ? Error.captureStackTrace : (..._args) => {}; +function isObject(data) { + return typeof data === "object" && data !== null && !Array.isArray(data); +} +var allowsEval = /* @__PURE__ */ cached(() => { + if (globalConfig.jitless) { + return false; } - if (chunkType === "VP8 " && data + 10 <= bytes.length) { - const hasKeyFrameSignature = bytes[data + 3] === 157 && bytes[data + 4] === 1 && bytes[data + 5] === 42; - if (!hasKeyFrameSignature) { - throw new Error("WebP `VP8 ` chunk has no key-frame header."); - } - return toDimensions(bytes.readUInt16LE(data + 6) & 16383, bytes.readUInt16LE(data + 8) & 16383); + if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) { + return false; } - if (chunkType === "VP8L" && data + 5 <= bytes.length) { - if (bytes[data] !== 47) { - throw new Error("WebP `VP8L` chunk has no lossless signature byte."); - } - const header = bytes.readUInt32LE(data + 1); - return toDimensions((header & 16383) + 1, (header >>> 14 & 16383) + 1); - } - return; -} -function parseAvifDimensions(bytes) { - const meta = findBox(bytes, 0, bytes.length, "meta"); - if (meta === undefined) { - throw new Error("AVIF has no `meta` box."); - } - const metaChildrenStart = meta.contentStart + 4; - const properties = findBox(bytes, metaChildrenStart, meta.contentEnd, "iprp"); - const propertyContainer = properties && findBox(bytes, properties.contentStart, properties.contentEnd, "ipco"); - if (!properties || !propertyContainer) { - throw new Error("AVIF has no `iprp`/`ipco` property boxes."); + try { + const F = Function; + new F(""); + return true; + } catch (_) { + return false; } - const propertyBoxes = listBoxes(bytes, propertyContainer.contentStart, propertyContainer.contentEnd); - const primaryItemId = readPrimaryItemId(bytes, metaChildrenStart, meta.contentEnd); - const association = findBox(bytes, properties.contentStart, properties.contentEnd, "ipma"); - const associatedIndexes = association && primaryItemId !== undefined ? readPropertyIndexes(bytes, association, primaryItemId) : undefined; - const spatialExtents = associatedIndexes?.map((index) => propertyBoxes[index - 1]).find((box) => box?.type === "ispe") ?? propertyBoxes.find((box) => box.type === "ispe"); - if (spatialExtents === undefined || spatialExtents.contentStart + 12 > bytes.length) { - throw new Error("AVIF has no `ispe` box for its primary item."); +}); +function isPlainObject(o) { + if (isObject(o) === false) + return false; + const ctor = o.constructor; + if (ctor === undefined) + return true; + if (typeof ctor !== "function") + return true; + const prot = ctor.prototype; + if (isObject(prot) === false) + return false; + if (Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf") === false) { + return false; } - return toDimensions(bytes.readUInt32BE(spatialExtents.contentStart + 4), bytes.readUInt32BE(spatialExtents.contentStart + 8)); + return true; } -function listBoxes(bytes, start, end) { - const boxes = []; - let offset = start; - while (offset + 8 <= end) { - const box = readBox(bytes, offset, end); - if (box === undefined) - break; - boxes.push(box); - offset = box.end; - } - return boxes; +function shallowClone(o) { + if (isPlainObject(o)) + return { ...o }; + if (Array.isArray(o)) + return [...o]; + if (o instanceof Map) + return new Map(o); + if (o instanceof Set) + return new Set(o); + return o; } -function findBox(bytes, start, end, type) { - return listBoxes(bytes, start, end).find((box) => box.type === type); +var propertyKeyTypes = /* @__PURE__ */ new Set(["string", "number", "symbol"]); +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function readBox(bytes, offset, end) { - const declaredSize = bytes.readUInt32BE(offset); - const type = bytes.subarray(offset + 4, offset + 8).toString("latin1"); - let contentStart = offset + 8; - let size = declaredSize; - if (declaredSize === 1) { - if (offset + 16 > end) - return; - const high = bytes.readUInt32BE(offset + 8); - if (high !== 0) - return; - size = bytes.readUInt32BE(offset + 12); - contentStart = offset + 16; - } else if (declaredSize === 0) { - size = end - offset; - } - const boxEnd = offset + size; - if (size < contentStart - offset || boxEnd > end) - return; - return { contentEnd: boxEnd, contentStart, end: boxEnd, type }; +function clone(inst, def, params) { + const cl = new inst._zod.constr(def ?? inst._zod.def); + if (!def || params?.parent) + cl._zod.parent = inst; + return cl; } -function readPrimaryItemId(bytes, start, end) { - const primaryItem = findBox(bytes, start, end, "pitm"); - if (primaryItem === undefined) - return; - const version = bytes[primaryItem.contentStart]; - const idStart = primaryItem.contentStart + 4; - if (version === 0) { - return idStart + 2 <= end ? bytes.readUInt16BE(idStart) : undefined; +function normalizeParams(_params) { + const params = _params; + if (!params) + return {}; + if (typeof params === "string") + return { error: () => params }; + if (params?.message !== undefined) { + if (params?.error !== undefined) + throw new Error("Cannot specify both `message` and `error` params"); + params.error = params.message; } - return idStart + 4 <= end ? bytes.readUInt32BE(idStart) : undefined; + delete params.message; + if (typeof params.error === "string") + return { ...params, error: () => params.error }; + return params; } -function readPropertyIndexes(bytes, association, itemId) { - const version = bytes[association.contentStart] ?? 0; - const flags = readUInt24BE(bytes, association.contentStart + 1); - const usesWideIndexes = (flags & 1) === 1; - let offset = association.contentStart + 4; - if (offset + 4 > association.contentEnd) - return; - const entryCount = bytes.readUInt32BE(offset); - offset += 4; - for (let entry = 0;entry < entryCount; entry += 1) { - if (offset + 3 > association.contentEnd) - return; - const entryItemId = version < 1 ? bytes.readUInt16BE(offset) : bytes.readUInt32BE(offset); - offset += version < 1 ? 2 : 4; - const associationCount = bytes[offset] ?? 0; - offset += 1; - const entryIndexes = readAssociationIndexes(bytes, association.contentEnd, offset, associationCount, usesWideIndexes); - if (entryIndexes === undefined) - return; - offset = entryIndexes.end; - if (entryItemId === itemId) - return entryIndexes.indexes; +function stringifyPrimitive(value) { + if (typeof value === "bigint") + return value.toString() + "n"; + if (typeof value === "string") + return `"${value}"`; + return `${value}`; +} +function optionalKeys(shape) { + return Object.keys(shape).filter((k) => { + return shape[k]._zod.optin !== undefined && shape[k]._zod.optout === "optional"; + }); +} +var NUMBER_FORMAT_RANGES = /* @__PURE__ */ (() => ({ + safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], + int32: [-2147483648, 2147483647], + uint32: [0, 4294967295], + float32: [-340282346638528860000000000000000000000, 340282346638528860000000000000000000000], + float64: [-Number.MAX_VALUE, Number.MAX_VALUE] +}))(); +var BIGINT_FORMAT_RANGES = { + int64: [/* @__PURE__ */ BigInt("-9223372036854775808"), /* @__PURE__ */ BigInt("9223372036854775807")], + uint64: [/* @__PURE__ */ BigInt(0), /* @__PURE__ */ BigInt("18446744073709551615")] +}; +function pick(schema, mask) { + const currDef = schema._zod.def; + const checks = currDef.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + throw new Error(".pick() cannot be used on object schemas containing refinements"); } - return; + const newShape = {}; + mirrorShape(newShape, schema, maskedKeys(schema, mask)); + return clone(schema, mergeDefs(currDef, { shape: newShape, checks: [] })); } -function readAssociationIndexes(bytes, contentEnd, start, count, usesWideIndexes) { - const indexes = []; - let offset = start; - for (let index = 0;index < count; index += 1) { - if (offset >= contentEnd) - return; - if (usesWideIndexes) { - indexes.push(bytes.readUInt16BE(offset) & 32767); - offset += 2; - } else { - indexes.push((bytes[offset] ?? 0) & 127); - offset += 1; +function maskedKeys(schema, mask) { + const raw = sourceShape(schema); + const keys = []; + for (const key of Reflect.ownKeys(mask)) { + if (!Object.getOwnPropertyDescriptor(raw, key)?.enumerable) { + throw new Error(`Unrecognized key: "${String(key)}"`); } + if (mask[key]) + keys.push(key); } - return { end: offset, indexes }; + return keys; } -function readNextMarker(bytes, start) { - if (bytes[start] !== 255) { - throw new Error(`JPEG marker segment expected at byte ${start}.`); +function omit(schema, mask) { + const currDef = schema._zod.def; + const checks = currDef.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + throw new Error(".omit() cannot be used on object schemas containing refinements"); } - let offset = start; - while (offset < bytes.length && bytes[offset] === 255) - offset += 1; - const marker = bytes[offset]; - return marker === undefined ? undefined : { marker, offset: offset + 1 }; -} -function isStandaloneMarker(marker) { - return marker === 1 || marker >= 208 && marker <= 217; + const omitted = new Set(maskedKeys(schema, mask)); + const newShape = {}; + mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema)).filter((key) => !omitted.has(key))); + return clone(schema, mergeDefs(currDef, { shape: newShape, checks: [] })); } -function readStartOfFrame(bytes, offset) { - const segmentLength = bytes.readUInt16BE(offset); - if (segmentLength < 7 || offset + 7 > bytes.length) { - throw new Error("JPEG Start-of-Frame segment is truncated."); +function extend(schema, shape) { + if (!isPlainObject(shape)) { + throw new Error("Invalid input to extend: expected a plain object"); } - return toDimensions(bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3)); -} -function isStartOfFrameMarker(marker) { - return marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204; + const checks = schema._zod.def.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + const existingShape = sourceShape(schema); + for (const key of Reflect.ownKeys(shape)) { + if (Object.getOwnPropertyDescriptor(existingShape, key) !== undefined) { + throw new Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead."); + } + } + } + return clone(schema, mergeDefs(schema._zod.def, { shape: extended(schema, shape) })); } -function readUInt24LE(bytes, offset) { - return (bytes[offset] ?? 0) | (bytes[offset + 1] ?? 0) << 8 | (bytes[offset + 2] ?? 0) << 16; +function extended(schema, shape) { + const newShape = {}; + mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema))); + mirrorProps(newShape, shape); + return newShape; } -function readUInt24BE(bytes, offset) { - return (bytes[offset] ?? 0) << 16 | (bytes[offset + 1] ?? 0) << 8 | (bytes[offset + 2] ?? 0); +function safeExtend(schema, shape) { + if (!isPlainObject(shape)) { + throw new Error("Invalid input to safeExtend: expected a plain object"); + } + return clone(schema, mergeDefs(schema._zod.def, { shape: extended(schema, shape) })); } -function findSvgRootTag(source) { - const withoutComments = source.replaceAll(//g, ""); - return /]*>/i.exec(withoutComments)?.[0]; +function merge(a, b) { + if (!b?._zod?.def) { + throw new Error("Invalid input to merge: expected an object schema. To merge a plain shape, use `.extend()`."); + } + if (a._zod.def.checks?.length) { + throw new Error(".merge() cannot be used on object schemas containing refinements. Use .safeExtend() instead."); + } + const newShape = {}; + mirrorShape(newShape, a, Reflect.ownKeys(sourceShape(a))); + mirrorShape(newShape, b, Reflect.ownKeys(sourceShape(b))); + const def = mergeDefs(a._zod.def, { + shape: newShape, + get catchall() { + return b._zod.def.catchall; + }, + checks: b._zod.def.checks ?? [] + }); + return clone(a, def); } -function readAttribute(tag, name) { - const pattern = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i"); - const match = pattern.exec(tag); - return match?.[2] ?? match?.[3]; +function partial(Class, schema, mask, name = "partial") { + const currDef = schema._zod.def; + const checks = currDef.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + throw new Error(`.${name}() cannot be used on object schemas containing refinements`); + } + const selected = mask ? new Set(maskedKeys(schema, mask)) : undefined; + const newShape = {}; + mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema)), Class && ((value, key) => selected && !selected.has(key) ? value : new Class({ type: "optional", innerType: value }))); + return clone(schema, mergeDefs(schema._zod.def, { shape: newShape, checks: [] })); } -function parseSvgLength(value) { - if (value === undefined) - return; - const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)$/i.exec(value.trim()); - if (match === null) - return; - const amount = Number(match[1]); - const pixelsPerUnit = PIXELS_PER_UNIT[(match[2] ?? "").toLowerCase()]; - if (!Number.isFinite(amount) || amount <= 0 || pixelsPerUnit === undefined) - return; - return amount * pixelsPerUnit; +function required(Class, schema, mask) { + const selected = mask ? new Set(maskedKeys(schema, mask)) : undefined; + const newShape = {}; + mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema)), (value, key) => selected && !selected.has(key) ? value : new Class({ type: "nonoptional", innerType: value })); + return clone(schema, mergeDefs(schema._zod.def, { shape: newShape })); } -function toDimensions(width, height) { - const rounded = { - height: Math.max(1, Math.round(height)), - width: Math.max(1, Math.round(width)) - }; - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - throw new Error(`Read a non-positive image size (${width}×${height}).`); +function aborted(x, startIndex = 0) { + if (x.aborted === true) + return true; + for (let i = startIndex;i < x.issues.length; i++) { + if (x.issues[i]?.continue !== true) { + return true; + } } - return rounded; + return false; } -// src/shared/lib/content/public-quizzes.ts -import { execFileSync } from "node:child_process"; -import { lstatSync, readdirSync, readFileSync as readFileSync2 } from "node:fs"; -import { basename, relative, resolve } from "node:path"; -// src/shared/lib/quiz/format-errors.ts -function formatQuizValidationErrors(error) { - const issueReports = error.issues.map(formatIssueReport); - return [ - "Quiz JSON is invalid. Please revise it to satisfy the Quiz Object Standard.", - "", - ...issueReports - ].join(` -`); +function explicitlyAborted(x, startIndex = 0) { + if (x.aborted === true) + return true; + for (let i = startIndex;i < x.issues.length; i++) { + if (x.issues[i]?.continue === false) { + return true; + } + } + return false; } -function formatIssueReport(issue, index) { - const { path, problem, fix } = explainIssue(issue); - return [`${index + 1}. Path: \`${path}\``, ` Problem: ${problem}`, ` Fix: ${fix}`].join(` -`); +function prefixIssues(path, issues) { + return issues.map((iss) => { + var _a; + (_a = iss).path ?? (_a.path = []); + iss.path.unshift(path); + return iss; + }); } -function explainIssue(issue) { - if (issue.code === "unrecognized_keys") { - const keys = issue.keys.map((key) => `\`${key}\``).join(", "); - const path = issue.keys.length === 1 ? formatPath([...issue.path, issue.keys[0] ?? ""]) : formatPath(issue.path); - return { - path, - problem: `Unknown field${issue.keys.length === 1 ? "" : "s"} ${keys}.`, - fix: "Remove unknown fields; the Standard is strict at every level." - }; - } - if (isPath(issue.path, ["schemaVersion"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: 'Use `"schemaVersion": 1`. Version strings such as `"1.0"` are invalid.' - }; - } - if (!isMissingField(issue)) { - const mediaExplanation = explainMediaIssue(issue); - if (mediaExplanation !== undefined) { - return mediaExplanation; - } +function unwrapMessage(message) { + return typeof message === "string" ? message : message?.message; +} +function attachSchema(issues, start, inst) { + var _a; + for (let i = start;i < issues.length; i++) { + (_a = issues[i]).schema ?? (_a.schema = inst); } - return explainFieldIssue(issue); } -function explainFieldIssue(issue) { - if (issue.code === "invalid_union" && "options" in issue && issue.options !== undefined) { - const options = issue.options.map((option) => `\`${String(option)}\``).join(", "); - return { - path: formatPath(issue.path), - problem: issue.message, - fix: `Use one of: ${options}.` - }; +function finalizeIssue(iss, ctx, config) { + var _a; + const traits = iss.inst?._zod?.traits; + if (traits?.has("$ZodType")) { + if (traits.has("$ZodCheck")) + (_a = iss).schema ?? (_a.schema = iss.inst); + else + iss.schema = iss.inst; } - if (isPathEnding(issue.path, ["validation"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: 'Add a `validation` object with `mode: "text"` or `mode: "numeric"` and at least one accepted answer.' - }; + const schemaError = iss.schema !== iss.inst ? iss.schema?._zod.def?.error : undefined; + const message = iss.message ? iss.message : unwrapMessage(iss.inst?._zod.def?.error?.(iss)) ?? unwrapMessage(schemaError?.(iss)) ?? unwrapMessage(ctx?.error?.(iss)) ?? unwrapMessage(config.customError?.(iss)) ?? unwrapMessage(config.localeError?.(iss)) ?? "Invalid input"; + const full = {}; + for (const k of Object.keys(iss)) { + if (k === "inst" || k === "schema" || k === "continue" || k === "input" || k === "__proto__") + continue; + full[k] = iss[k]; } - if (isMissingField(issue)) { - return { - path: formatPath(issue.path), - problem: "Required field is missing.", - fix: "Add this required field using the shape defined by the Standard." - }; + full.path ?? (full.path = []); + full.message = message; + if (ctx?.reportInput) { + full.input = iss.input; } - if (issue.code === "invalid_format" && isIdLikePath(issue.path)) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Use lowercase latin letters, digits, and single hyphens; do not use spaces, underscores, or leading/trailing hyphens." - }; + return full; +} +var highSurrogate = /[\uD800-\uDBFF]/; +function codePointLength(str) { + const units = str.length; + if (!highSurrogate.test(str)) + return units; + let count = units; + for (let i = 0;i < units - 1; i++) { + if ((str.charCodeAt(i) & 64512) === 55296 && (str.charCodeAt(i + 1) & 64512) === 56320) { + count--; + i++; + } } - if (issue.code === "too_small" && "origin" in issue && issue.origin === "array") { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Add the required item or remove the incomplete object." - }; + return count; +} +function getLengthableOrigin(input) { + if (Array.isArray(input)) + return "array"; + if (typeof input === "string") + return "string"; + return "unknown"; +} +function parsedType(data) { + const t = typeof data; + switch (t) { + case "number": { + return Number.isNaN(data) ? "nan" : "number"; + } + case "object": { + if (data === null) { + return "null"; + } + if (Array.isArray(data)) { + return "array"; + } + const obj = data; + if (obj && Object.getPrototypeOf(obj) !== Object.prototype && "constructor" in obj && obj.constructor) { + return obj.constructor.name; + } + } } - return explainRefinementIssue(issue); + return t; } -function explainRefinementIssue(issue) { - if (issue.code === "custom" && isPathEnding(issue.path, ["options"])) { +function issue(...args) { + const [iss, input, inst] = args; + if (typeof iss === "string") { return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Set the required correct Options: exactly one for `single-choice`, at least one for `multiple-choice`." + message: iss, + code: "custom", + input, + inst }; } - if (issue.code === "custom" && isPathEnding(issue.path, ["id"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Give each Question a unique `id` within this Quiz." - }; + return { ...iss }; +} +function members(proto, table) { + for (const key in table) { + const desc = Object.getOwnPropertyDescriptor(table, key); + if (desc.get) + Object.defineProperty(proto, key, { ...desc, enumerable: false }); + else + defineBound(proto, key, desc.value); } - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Change this value to match the Standard at the reported path." - }; } -function isMissingField(issue) { - return issue.message === "Required field missing."; -} -function explainMediaIssue(issue) { - if (isPathEnding(issue.path, ["src"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Use a bare asset filename such as `cache-tiers.svg`, or an `https://` URL. `http://`, protocol-relative, and `data:` sources are not accepted." - }; - } - if (isPathEnding(issue.path, ["placement"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Use `question` or `explanation`, or omit `placement` entirely — an absent `placement` already means `question`." - }; - } - if (isPathEnding(issue.path, ["width"]) || isPathEnding(issue.path, ["height"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Set both to the image file's own pixel size, or omit both. Never estimate them: an absent pair is valid, a wrong one is not. In the Quizbun repository, `bun run quiz:sizes:generate` writes them for you." - }; - } - if (isPathEnding(issue.path, ["provider"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "`youtube` is the only video provider in version 1." - }; - } - if (isVideoIdPath(issue.path)) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Take the 11 characters after `v=` or `youtu.be/` in the video URL and use those alone." - }; - } - if (isPathEnding(issue.path, ["start"])) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Use a whole number of seconds, such as `90` for one minute thirty." - }; - } - if (issue.code === "too_small" && (isPathEnding(issue.path, ["images"]) || isPathEnding(issue.path, ["videos"]))) { - return { - path: formatPath(issue.path), - problem: issue.message, - fix: "Both media fields are optional; omit the field instead of leaving it empty." - }; - } - return; -} -function formatPath(path) { - if (path.length === 0) { - return "root"; - } - return path.reduce((formattedPath, segment) => { - if (typeof segment === "number") { - return `${formattedPath}[${segment}]`; - } - const key = String(segment); - return formattedPath.length === 0 ? key : `${formattedPath}.${key}`; - }, ""); +function own(inst, key, value, enumerable = true) { + Object.defineProperty(inst, key, { configurable: true, writable: true, enumerable, value }); + return value; } -function isPath(path, expectedPath) { - return path.length === expectedPath.length && path.every((segment, index) => segment === expectedPath[index]); +function hide(inst, key, value) { + return own(inst, key, value, false); } -function isPathEnding(path, ending) { - if (path.length < ending.length) { - return false; +function derived(computes, table) { + for (const key in computes) { + const compute = computes[key]; + Object.defineProperty(table, key, { + configurable: true, + enumerable: true, + get() { + return own(this, key, compute(this)); + }, + set(value) { + own(this, key, value); + } + }); } - return ending.every((segment, index) => path[path.length - ending.length + index] === segment); -} -function isIdLikePath(path) { - const lastSegment = path.at(-1); - const parentSegment = path.at(-2); - return lastSegment === "id" || parentSegment === "tags"; -} -function isVideoIdPath(path) { - return path.at(-1) === "id" && path.at(-3) === "videos"; + return table; } -// src/shared/lib/quiz/parse-quiz-json.ts -function parseQuizJson(rawText, sourceLabel) { - try { - return JSON.parse(rawText); - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error([ - `JSON syntax error in ${sourceLabel}:`, - "Path: `root`", - `Problem: ${error.message}`, - "Fix: Provide exactly one valid JSON object with no markdown fences, comments, or trailing commas." - ].join(` -`), { cause: error }); +function defineBound(proto, key, fn) { + Object.defineProperty(proto, key, { + configurable: true, + get() { + return this == null ? fn : own(this, key, fn.bind(this)); + }, + set(value) { + own(this, key, value); } - throw error; - } -} -// node_modules/zod/v4/core/util.js -function getEnumValues(entries) { - const numericValues = Object.values(entries).filter((v) => typeof v === "number"); - const values = Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v); - return values; -} -function joinValues(array, separator = "|") { - return array.map((val) => stringifyPrimitive(val)).join(separator); + }); } -function jsonStringifyReplacer(_, value) { - if (typeof value === "bigint") - return value.toString(); - return value; +function claim(inst, sentinel) { + const proto = Object.getPrototypeOf(inst); + return sentinel in proto ? undefined : proto; } - -class Cached { - constructor(getter) { - this._getter = getter; - this._value = undefined; +var installing; +var broke = false; +var breaker = { + configurable: true, + get() { + broke = true; + return; } - get value() { - const getter = this._getter; - if (getter !== undefined) { - this._value = getter(); - this._getter = undefined; - } - return this._value; +}; +function defineLazyInternal(inst, key, compute) { + const proto = Object.getPrototypeOf(inst._zod); + if (key in proto && installing !== inst._zod) { + installing = undefined; + return; } -} -function cached(getter) { - return new Cached(getter); -} -function nullish(input) { - return input === null || input === undefined; -} -function cleanRegex(source) { - const start = source.startsWith("^") ? 1 : 0; - const end = source.endsWith("$") ? source.length - 1 : source.length; - return source.slice(start, end); -} -function floatSafeRemainder(val, step) { - const ratio = val / step; - const roundedRatio = Math.round(ratio); - const tolerance = 4 * Number.EPSILON * Math.max(Math.abs(ratio), 1); - if (Math.abs(ratio - roundedRatio) < tolerance) - return 0; - return ratio - roundedRatio; -} -function assignProp(target, prop, value) { - Object.defineProperty(target, prop, { - value, - writable: true, - enumerable: true, - configurable: true + installing = inst._zod; + Object.defineProperty(proto, key, { + configurable: true, + get() { + Object.defineProperty(this, key, breaker); + const outer = broke; + broke = false; + try { + const value = compute(this); + if (broke) + delete this[key]; + else + Object.defineProperty(this, key, { configurable: true, writable: true, value }); + broke = broke || outer; + return value; + } catch (err) { + delete this[key]; + broke = broke || outer; + throw err; + } + }, + set(value) { + Object.defineProperty(this, key, { configurable: true, writable: true, value }); + } }); } -function rawShape(def) { - const desc = Object.getOwnPropertyDescriptor(def, "shape"); - return desc?.get ? desc.get.raw : desc?.value; -} -function sourceShape(schema) { - return rawShape(schema._zod.def) ?? schema._zod.def.shape; -} -function deferProp(target, key, getter) { - Object.defineProperty(target, key, { +function installLazyProp(inst, key, make, enumerable) { + const proto = claim(inst, key); + if (!proto) + return; + Object.defineProperty(proto, key, { + configurable: true, get() { - const value = getter(); - assignProp(this, key, value); - return value; + const desc = { configurable: true, writable: true, enumerable, value: undefined }; + Object.defineProperty(this, key, desc); + desc.value = make(this); + Object.defineProperty(this, key, desc); + return desc.value; }, - enumerable: true, - configurable: true + set(value) { + Object.defineProperty(this, key, { configurable: true, writable: true, enumerable, value }); + } }); } -function putProp(target, key, value) { - if (key in target) - assignProp(target, key, value); - else - target[key] = value; +var CONSTANT_CATCH = "~constantCatch"; +function constantCatch(value) { + const fn = () => value; + fn[CONSTANT_CATCH] = true; + return fn; } -function mirrorShape(target, source, keys, wrap) { - const raw = sourceShape(source); - for (const key of keys) { - const desc = Object.getOwnPropertyDescriptor(raw, key); - if (!desc.enumerable) - continue; - if (desc.get) { - deferProp(target, key, () => { - const value = source._zod.def.shape[key]; - return wrap ? wrap(value, key) : value; - }); - } else - putProp(target, key, wrap ? wrap(desc.value, key) : desc.value); + +// node_modules/zod/v4/core/core.js +var _a; +var _zodDesc = { value: undefined, enumerable: false }; +var _E = "captureStackTrace" in Error ? Error : null; +function newError(Definition) { + const E = _E; + if (E) { + const saved = E.stackTraceLimit; + if (typeof saved === "number") { + try { + E.stackTraceLimit = 0; + } catch { + _E = null; + return new Definition; + } + try { + return new Definition; + } finally { + E.stackTraceLimit = saved; + } + } } + return new Definition; } -function mirrorProps(target, source) { - for (const key of Reflect.ownKeys(source)) { - const desc = Object.getOwnPropertyDescriptor(source, key); - if (!desc.enumerable) - continue; - if (desc.get) - deferProp(target, key, () => source[key]); - else - putProp(target, key, desc.value); +function $constructor(name, initializer, proto, params) { + const zodProto = {}; + function Internals(def) { + this.def = def; + this.constr = _; + this.traits = new Set; } -} -function mergeDefs(...defs) { - const mergedDescriptors = {}; - for (const def of defs) { - const descriptors = Object.getOwnPropertyDescriptors(def); - Object.assign(mergedDescriptors, descriptors); + Internals.prototype = zodProto; + const protoMembers = proto; + const initialized = protoMembers && new WeakSet; + function init(inst, def) { + if (!inst._zod) { + _zodDesc.value = new Internals(def); + try { + Object.defineProperty(inst, "_zod", _zodDesc); + } finally { + _zodDesc.value = undefined; + } + } else if (inst._zod.traits.has(name)) { + return; + } + inst._zod.traits.add(name); + initializer(inst, def); + if (initialized) { + const own = Object.getPrototypeOf(inst); + const ctorProto = inst._zod.constr.prototype; + let up = own; + while (up && up !== ctorProto) + up = Object.getPrototypeOf(up); + const target = up ?? own; + if (!initialized.has(target)) { + initialized.add(target); + members(target, protoMembers); + } + } + const proto = _.prototype; + for (const k in proto) { + if (!Object.prototype.hasOwnProperty.call(proto, k)) + continue; + if (!(k in inst)) { + inst[k] = proto[k].bind(inst); + } + } } - return Object.defineProperties({}, mergedDescriptors); -} -function esc(str) { - return JSON.stringify(str); -} -function slugify(input) { - return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); -} -var captureStackTrace = "captureStackTrace" in Error ? Error.captureStackTrace : (..._args) => {}; -function isObject(data) { - return typeof data === "object" && data !== null && !Array.isArray(data); -} -var allowsEval = /* @__PURE__ */ cached(() => { - if (globalConfig.jitless) { - return false; + const Parent = params?.Parent ?? Object; + + class Definition extends Parent { } - if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) { - return false; + Object.defineProperty(Definition, "name", { value: name }); + function _(def) { + const inst = params?.Parent ? newError(Definition) : this; + init(inst, def); + const deferred = inst._zod.deferred; + if (deferred) { + for (const fn of deferred) { + fn(); + } + inst._zod.deferred = undefined; + } + const pp = globalThis.__zod_globalConfig?.postProcessor; + if (pp) + pp(inst); + return inst; } - try { - const F = Function; - new F(""); - return true; - } catch (_) { - return false; + Object.defineProperty(_, "init", { value: init }); + Object.defineProperty(_, Symbol.hasInstance, { + value: (inst) => { + if (params?.Parent && inst instanceof params.Parent) + return true; + return inst?._zod?.traits?.has(name); + } + }); + Object.defineProperty(_, "name", { value: name }); + return _; +} +class $ZodAsyncError extends Error { + constructor() { + super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`); } -}); -function isPlainObject(o) { - if (isObject(o) === false) - return false; - const ctor = o.constructor; - if (ctor === undefined) - return true; - if (typeof ctor !== "function") - return true; - const prot = ctor.prototype; - if (isObject(prot) === false) - return false; - if (Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf") === false) { - return false; +} + +class $ZodEncodeError extends Error { + constructor(name) { + super(`Encountered unidirectional transform during encode: ${name}`); + this.name = "ZodEncodeError"; } - return true; } -function shallowClone(o) { - if (isPlainObject(o)) - return { ...o }; - if (Array.isArray(o)) - return [...o]; - if (o instanceof Map) - return new Map(o); - if (o instanceof Set) - return new Set(o); - return o; +(_a = globalThis).__zod_globalConfig ?? (_a.__zod_globalConfig = {}); +var globalConfig = globalThis.__zod_globalConfig; +function config(newConfig) { + if (newConfig) + Object.assign(globalConfig, newConfig); + return globalConfig; } -var propertyKeyTypes = /* @__PURE__ */ new Set(["string", "number", "symbol"]); -function escapeRegex(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// node_modules/zod/v4/core/errors.js +function _getMessage() { + const internals = this._zod; + internals.message ?? (internals.message = JSON.stringify(internals.def, jsonStringifyReplacer, 2)); + return internals.message; } -function clone(inst, def, params) { - const cl = new inst._zod.constr(def ?? inst._zod.def); - if (!def || params?.parent) - cl._zod.parent = inst; - return cl; +function _setMessage(value) { + this._zod.message = value; } -function normalizeParams(_params) { - const params = _params; - if (!params) - return {}; - if (typeof params === "string") - return { error: () => params }; - if (params?.message !== undefined) { - if (params?.error !== undefined) - throw new Error("Cannot specify both `message` and `error` params"); - params.error = params.message; +var _messageDesc = { + get: _getMessage, + set: _setMessage, + enumerable: true, + configurable: true +}; +var _issuesDesc = { value: undefined, enumerable: false }; +var _installedToString = /* @__PURE__ */ new WeakSet([Object.prototype, Error.prototype]); +var initializer = (inst, def) => { + inst.name = "$ZodError"; + _issuesDesc.value = def; + Object.defineProperty(inst, "issues", _issuesDesc); + _issuesDesc.value = undefined; + Object.defineProperty(inst, "message", _messageDesc); + const proto = Object.getPrototypeOf(inst); + if (!_installedToString.has(proto)) { + _installedToString.add(proto); + Object.defineProperty(proto, "toString", { + configurable: true, + enumerable: false, + get() { + const value = () => this.message; + Object.defineProperty(this, "toString", { value, configurable: true, writable: true }); + return value; + }, + set(value) { + Object.defineProperty(this, "toString", { value, configurable: true, writable: true }); + } + }); } - delete params.message; - if (typeof params.error === "string") - return { ...params, error: () => params.error }; - return params; +}; +var $ZodError = $constructor("$ZodError", initializer); +var $ZodRealError = $constructor("$ZodError", initializer, undefined, { + Parent: Error +}); +function node(obj, key, make) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) { + if (key === "__proto__") { + Object.defineProperty(obj, key, { value: make(), writable: true, enumerable: true, configurable: true }); + } else { + obj[key] = make(); + } + } + return obj[key]; } -function stringifyPrimitive(value) { - if (typeof value === "bigint") - return value.toString() + "n"; - if (typeof value === "string") - return `"${value}"`; - return `${value}`; -} -function optionalKeys(shape) { - return Object.keys(shape).filter((k) => { - return shape[k]._zod.optin !== undefined && shape[k]._zod.optout === "optional"; - }); -} -var NUMBER_FORMAT_RANGES = /* @__PURE__ */ (() => ({ - safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], - int32: [-2147483648, 2147483647], - uint32: [0, 4294967295], - float32: [-340282346638528860000000000000000000000, 340282346638528860000000000000000000000], - float64: [-Number.MAX_VALUE, Number.MAX_VALUE] -}))(); -var BIGINT_FORMAT_RANGES = { - int64: [/* @__PURE__ */ BigInt("-9223372036854775808"), /* @__PURE__ */ BigInt("9223372036854775807")], - uint64: [/* @__PURE__ */ BigInt(0), /* @__PURE__ */ BigInt("18446744073709551615")] -}; -function pick(schema, mask) { - const currDef = schema._zod.def; - const checks = currDef.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - throw new Error(".pick() cannot be used on object schemas containing refinements"); - } - const newShape = {}; - mirrorShape(newShape, schema, maskedKeys(schema, mask)); - return clone(schema, mergeDefs(currDef, { shape: newShape, checks: [] })); -} -function maskedKeys(schema, mask) { - const raw = sourceShape(schema); - const keys = []; - for (const key of Reflect.ownKeys(mask)) { - if (!Object.getOwnPropertyDescriptor(raw, key)?.enumerable) { - throw new Error(`Unrecognized key: "${String(key)}"`); +function flattenError(error, mapper = (issue) => issue.message) { + const fieldErrors = {}; + const formErrors = []; + for (const sub of error.issues) { + if (sub.path.length > 0) { + node(fieldErrors, sub.path[0], () => []).push(mapper(sub)); + } else { + formErrors.push(mapper(sub)); } - if (mask[key]) - keys.push(key); - } - return keys; -} -function omit(schema, mask) { - const currDef = schema._zod.def; - const checks = currDef.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - throw new Error(".omit() cannot be used on object schemas containing refinements"); } - const omitted = new Set(maskedKeys(schema, mask)); - const newShape = {}; - mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema)).filter((key) => !omitted.has(key))); - return clone(schema, mergeDefs(currDef, { shape: newShape, checks: [] })); + return { formErrors, fieldErrors }; } -function extend(schema, shape) { - if (!isPlainObject(shape)) { - throw new Error("Invalid input to extend: expected a plain object"); - } - const checks = schema._zod.def.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - const existingShape = sourceShape(schema); - for (const key of Reflect.ownKeys(shape)) { - if (Object.getOwnPropertyDescriptor(existingShape, key) !== undefined) { - throw new Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead."); +function formatError(error, mapper = (issue) => issue.message) { + const fieldErrors = { _errors: [] }; + const processError = (error, path = []) => { + for (const issue of error.issues) { + if (issue.code === "invalid_union" && issue.errors.length) { + issue.errors.map((issues) => processError({ issues }, [...path, ...issue.path])); + } else if (issue.code === "invalid_key") { + processError({ issues: issue.issues }, [...path, ...issue.path]); + } else if (issue.code === "invalid_element") { + processError({ issues: issue.issues }, [...path, ...issue.path]); + } else { + const fullpath = [...path, ...issue.path]; + if (fullpath.length === 0) { + fieldErrors._errors.push(mapper(issue)); + } else { + let curr = fieldErrors; + let i = 0; + while (i < fullpath.length) { + const el = fullpath[i]; + const terminal = i === fullpath.length - 1; + if (el === "_errors") { + if (terminal) + curr._errors.push(mapper(issue)); + i++; + continue; + } + if (!Object.prototype.hasOwnProperty.call(curr, el)) { + Object.defineProperty(curr, el, { + value: { _errors: [] }, + enumerable: true, + writable: true, + configurable: true + }); + } + const node = curr[el]; + if (terminal) { + node._errors.push(mapper(issue)); + } + curr = node; + i++; + } + } } } - } - return clone(schema, mergeDefs(schema._zod.def, { shape: extended(schema, shape) })); -} -function extended(schema, shape) { - const newShape = {}; - mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema))); - mirrorProps(newShape, shape); - return newShape; + }; + processError(error); + return fieldErrors; } -function safeExtend(schema, shape) { - if (!isPlainObject(shape)) { - throw new Error("Invalid input to safeExtend: expected a plain object"); - } - return clone(schema, mergeDefs(schema._zod.def, { shape: extended(schema, shape) })); + +// node_modules/zod/v4/core/parse.js +function finalizeParams(callee, params) { + return { callee: params?.callee ?? callee, Err: params?.Err }; } -function merge(a, b) { - if (!b?._zod?.def) { - throw new Error("Invalid input to merge: expected an object schema. To merge a plain shape, use `.extend()`."); - } - if (a._zod.def.checks?.length) { - throw new Error(".merge() cannot be used on object schemas containing refinements. Use .safeExtend() instead."); +var _parse = (_Err) => { + const fn = (schema, value, _ctx, _params) => { + const ctx = _ctx ? { ..._ctx, async: false } : { async: false }; + const result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) { + throw new $ZodAsyncError; + } + if (result.issues.length) { + const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); + captureStackTrace(e, _params?.callee ?? fn); + throw e; + } + return result.value; + }; + return fn; +}; +var _parseAsync = (_Err) => { + const fn = async (schema, value, _ctx, params) => { + const ctx = _ctx ? { ..._ctx, async: true } : { async: true }; + let result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) + result = await result; + if (result.issues.length) { + const e = new (params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); + captureStackTrace(e, params?.callee ?? fn); + throw e; + } + return result.value; + }; + return fn; +}; +var _safeParse = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? { ..._ctx, async: false } : { async: false }; + const result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) { + throw new $ZodAsyncError; } - const newShape = {}; - mirrorShape(newShape, a, Reflect.ownKeys(sourceShape(a))); - mirrorShape(newShape, b, Reflect.ownKeys(sourceShape(b))); - const def = mergeDefs(a._zod.def, { - shape: newShape, - get catchall() { - return b._zod.def.catchall; + return result.issues.length ? failure(_Err, result.issues, ctx) : { success: true, data: result.value }; +}; +function failure(Err, issues, ctx) { + let error; + return { + success: false, + get error() { + if (!error) { + error = new Err(issues.map((iss) => finalizeIssue(iss, ctx, config()))); + issues = undefined; + ctx = undefined; + } + return error; }, - checks: b._zod.def.checks ?? [] - }); - return clone(a, def); -} -function partial(Class, schema, mask, name = "partial") { - const currDef = schema._zod.def; - const checks = currDef.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - throw new Error(`.${name}() cannot be used on object schemas containing refinements`); - } - const selected = mask ? new Set(maskedKeys(schema, mask)) : undefined; - const newShape = {}; - mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema)), Class && ((value, key) => selected && !selected.has(key) ? value : new Class({ type: "optional", innerType: value }))); - return clone(schema, mergeDefs(schema._zod.def, { shape: newShape, checks: [] })); -} -function required(Class, schema, mask) { - const selected = mask ? new Set(maskedKeys(schema, mask)) : undefined; - const newShape = {}; - mirrorShape(newShape, schema, Reflect.ownKeys(sourceShape(schema)), (value, key) => selected && !selected.has(key) ? value : new Class({ type: "nonoptional", innerType: value })); - return clone(schema, mergeDefs(schema._zod.def, { shape: newShape })); -} -function aborted(x, startIndex = 0) { - if (x.aborted === true) - return true; - for (let i = startIndex;i < x.issues.length; i++) { - if (x.issues[i]?.continue !== true) { - return true; + set error(e) { + error = e; + issues = undefined; + ctx = undefined; } - } - return false; + }; } -function explicitlyAborted(x, startIndex = 0) { - if (x.aborted === true) - return true; - for (let i = startIndex;i < x.issues.length; i++) { - if (x.issues[i]?.continue === false) { +var _safeParseAsync = (_Err) => async (schema, value, _ctx) => { + const ctx = _ctx ? { ..._ctx, async: true } : { async: true }; + let result = schema._zod.run({ value, issues: [] }, ctx); + if (result instanceof Promise) + result = await result; + return result.issues.length ? failure(_Err, result.issues, ctx) : { success: true, data: result.value }; +}; +var COMPILE_INVALID = /* @__PURE__ */ Symbol.for("zod.compile.invalid"); +var COMPILE_FALLBACK = /* @__PURE__ */ Symbol.for("zod.compile.fallback"); +var validate = (schema, value, _ctx) => { + const validator = schema._zod.bag.validator; + if (validator !== undefined) { + if (validator(value) !== COMPILE_INVALID) return true; - } - } - return false; -} -function prefixIssues(path, issues) { - return issues.map((iss) => { - var _a; - (_a = iss).path ?? (_a.path = []); - iss.path.unshift(path); - return iss; - }); -} -function unwrapMessage(message) { - return typeof message === "string" ? message : message?.message; -} -function attachSchema(issues, start, inst) { - var _a; - for (let i = start;i < issues.length; i++) { - (_a = issues[i]).schema ?? (_a.schema = inst); - } -} -function finalizeIssue(iss, ctx, config) { - var _a; - const traits = iss.inst?._zod?.traits; - if (traits?.has("$ZodType")) { - if (traits.has("$ZodCheck")) - (_a = iss).schema ?? (_a.schema = iss.inst); - else - iss.schema = iss.inst; - } - const schemaError = iss.schema !== iss.inst ? iss.schema?._zod.def?.error : undefined; - const message = iss.message ? iss.message : unwrapMessage(iss.inst?._zod.def?.error?.(iss)) ?? unwrapMessage(schemaError?.(iss)) ?? unwrapMessage(ctx?.error?.(iss)) ?? unwrapMessage(config.customError?.(iss)) ?? unwrapMessage(config.localeError?.(iss)) ?? "Invalid input"; - const full = {}; - for (const k of Object.keys(iss)) { - if (k === "inst" || k === "schema" || k === "continue" || k === "input" || k === "__proto__") - continue; - full[k] = iss[k]; - } - full.path ?? (full.path = []); - full.message = message; - if (ctx?.reportInput) { - full.input = iss.input; - } - return full; -} -var highSurrogate = /[\uD800-\uDBFF]/; -function codePointLength(str) { - const units = str.length; - if (!highSurrogate.test(str)) - return units; - let count = units; - for (let i = 0;i < units - 1; i++) { - if ((str.charCodeAt(i) & 64512) === 55296 && (str.charCodeAt(i + 1) & 64512) === 56320) { - count--; - i++; - } - } - return count; -} -function getLengthableOrigin(input) { - if (Array.isArray(input)) - return "array"; - if (typeof input === "string") - return "string"; - return "unknown"; -} -function parsedType(data) { - const t = typeof data; - switch (t) { - case "number": { - return Number.isNaN(data) ? "nan" : "number"; - } - case "object": { - if (data === null) { - return "null"; - } - if (Array.isArray(data)) { - return "array"; - } - const obj = data; - if (obj && Object.getPrototypeOf(obj) !== Object.prototype && "constructor" in obj && obj.constructor) { - return obj.constructor.name; - } - } - } - return t; -} -function issue(...args) { - const [iss, input, inst] = args; - if (typeof iss === "string") { - return { - message: iss, - code: "custom", - input, - inst - }; - } - return { ...iss }; -} -function members(proto, table) { - for (const key in table) { - const desc = Object.getOwnPropertyDescriptor(table, key); - if (desc.get) - Object.defineProperty(proto, key, { ...desc, enumerable: false }); - else - defineBound(proto, key, desc.value); - } -} -function own(inst, key, value, enumerable = true) { - Object.defineProperty(inst, key, { configurable: true, writable: true, enumerable, value }); - return value; -} -function hide(inst, key, value) { - return own(inst, key, value, false); -} -function derived(computes, table) { - for (const key in computes) { - const compute = computes[key]; - Object.defineProperty(table, key, { - configurable: true, - enumerable: true, - get() { - return own(this, key, compute(this)); - }, - set(value) { - own(this, key, value); - } - }); - } - return table; -} -function defineBound(proto, key, fn) { - Object.defineProperty(proto, key, { - configurable: true, - get() { - return this == null ? fn : own(this, key, fn.bind(this)); - }, - set(value) { - own(this, key, value); - } - }); -} -function claim(inst, sentinel) { - const proto = Object.getPrototypeOf(inst); - return sentinel in proto ? undefined : proto; -} -var installing; -var broke = false; -var breaker = { - configurable: true, - get() { - broke = true; - return; - } -}; -function defineLazyInternal(inst, key, compute) { - const proto = Object.getPrototypeOf(inst._zod); - if (key in proto && installing !== inst._zod) { - installing = undefined; - return; - } - installing = inst._zod; - Object.defineProperty(proto, key, { - configurable: true, - get() { - Object.defineProperty(this, key, breaker); - const outer = broke; - broke = false; - try { - const value = compute(this); - if (broke) - delete this[key]; - else - Object.defineProperty(this, key, { configurable: true, writable: true, value }); - broke = broke || outer; - return value; - } catch (err) { - delete this[key]; - broke = broke || outer; - throw err; - } - }, - set(value) { - Object.defineProperty(this, key, { configurable: true, writable: true, value }); - } - }); -} -function installLazyProp(inst, key, make, enumerable) { - const proto = claim(inst, key); - if (!proto) - return; - Object.defineProperty(proto, key, { - configurable: true, - get() { - const desc = { configurable: true, writable: true, enumerable, value: undefined }; - Object.defineProperty(this, key, desc); - desc.value = make(this); - Object.defineProperty(this, key, desc); - return desc.value; - }, - set(value) { - Object.defineProperty(this, key, { configurable: true, writable: true, enumerable, value }); - } - }); -} -var CONSTANT_CATCH = "~constantCatch"; -function constantCatch(value) { - const fn = () => value; - fn[CONSTANT_CATCH] = true; - return fn; -} - -// node_modules/zod/v4/core/core.js -var _a; -var _zodDesc = { value: undefined, enumerable: false }; -var _E = "captureStackTrace" in Error ? Error : null; -function newError(Definition) { - const E = _E; - if (E) { - const saved = E.stackTraceLimit; - if (typeof saved === "number") { - try { - E.stackTraceLimit = 0; - } catch { - _E = null; - return new Definition; - } - try { - return new Definition; - } finally { - E.stackTraceLimit = saved; - } - } - } - return new Definition; -} -function $constructor(name, initializer, proto, params) { - const zodProto = {}; - function Internals(def) { - this.def = def; - this.constr = _; - this.traits = new Set; - } - Internals.prototype = zodProto; - const protoMembers = proto; - const initialized = protoMembers && new WeakSet; - function init(inst, def) { - if (!inst._zod) { - _zodDesc.value = new Internals(def); - try { - Object.defineProperty(inst, "_zod", _zodDesc); - } finally { - _zodDesc.value = undefined; - } - } else if (inst._zod.traits.has(name)) { - return; - } - inst._zod.traits.add(name); - initializer(inst, def); - if (initialized) { - const own = Object.getPrototypeOf(inst); - const ctorProto = inst._zod.constr.prototype; - let up = own; - while (up && up !== ctorProto) - up = Object.getPrototypeOf(up); - const target = up ?? own; - if (!initialized.has(target)) { - initialized.add(target); - members(target, protoMembers); - } - } - const proto = _.prototype; - for (const k in proto) { - if (!Object.prototype.hasOwnProperty.call(proto, k)) - continue; - if (!(k in inst)) { - inst[k] = proto[k].bind(inst); - } - } - } - const Parent = params?.Parent ?? Object; - - class Definition extends Parent { - } - Object.defineProperty(Definition, "name", { value: name }); - function _(def) { - const inst = params?.Parent ? newError(Definition) : this; - init(inst, def); - const deferred = inst._zod.deferred; - if (deferred) { - for (const fn of deferred) { - fn(); - } - inst._zod.deferred = undefined; - } - const pp = globalThis.__zod_globalConfig?.postProcessor; - if (pp) - pp(inst); - return inst; - } - Object.defineProperty(_, "init", { value: init }); - Object.defineProperty(_, Symbol.hasInstance, { - value: (inst) => { - if (params?.Parent && inst instanceof params.Parent) - return true; - return inst?._zod?.traits?.has(name); - } - }); - Object.defineProperty(_, "name", { value: name }); - return _; -} -class $ZodAsyncError extends Error { - constructor() { - super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`); - } -} - -class $ZodEncodeError extends Error { - constructor(name) { - super(`Encountered unidirectional transform during encode: ${name}`); - this.name = "ZodEncodeError"; - } -} -(_a = globalThis).__zod_globalConfig ?? (_a.__zod_globalConfig = {}); -var globalConfig = globalThis.__zod_globalConfig; -function config(newConfig) { - if (newConfig) - Object.assign(globalConfig, newConfig); - return globalConfig; -} -// node_modules/zod/v4/core/errors.js -function _getMessage() { - const internals = this._zod; - internals.message ?? (internals.message = JSON.stringify(internals.def, jsonStringifyReplacer, 2)); - return internals.message; -} -function _setMessage(value) { - this._zod.message = value; -} -var _messageDesc = { - get: _getMessage, - set: _setMessage, - enumerable: true, - configurable: true -}; -var _issuesDesc = { value: undefined, enumerable: false }; -var _installedToString = /* @__PURE__ */ new WeakSet([Object.prototype, Error.prototype]); -var initializer = (inst, def) => { - inst.name = "$ZodError"; - _issuesDesc.value = def; - Object.defineProperty(inst, "issues", _issuesDesc); - _issuesDesc.value = undefined; - Object.defineProperty(inst, "message", _messageDesc); - const proto = Object.getPrototypeOf(inst); - if (!_installedToString.has(proto)) { - _installedToString.add(proto); - Object.defineProperty(proto, "toString", { - configurable: true, - enumerable: false, - get() { - const value = () => this.message; - Object.defineProperty(this, "toString", { value, configurable: true, writable: true }); - return value; - }, - set(value) { - Object.defineProperty(this, "toString", { value, configurable: true, writable: true }); - } - }); - } -}; -var $ZodError = $constructor("$ZodError", initializer); -var $ZodRealError = $constructor("$ZodError", initializer, undefined, { - Parent: Error -}); -function node2(obj, key, make) { - if (!Object.prototype.hasOwnProperty.call(obj, key)) { - if (key === "__proto__") { - Object.defineProperty(obj, key, { value: make(), writable: true, enumerable: true, configurable: true }); - } else { - obj[key] = make(); - } - } - return obj[key]; -} -function flattenError(error, mapper = (issue) => issue.message) { - const fieldErrors = {}; - const formErrors = []; - for (const sub of error.issues) { - if (sub.path.length > 0) { - node2(fieldErrors, sub.path[0], () => []).push(mapper(sub)); - } else { - formErrors.push(mapper(sub)); - } - } - return { formErrors, fieldErrors }; -} -function formatError(error, mapper = (issue) => issue.message) { - const fieldErrors = { _errors: [] }; - const processError = (error, path = []) => { - for (const issue of error.issues) { - if (issue.code === "invalid_union" && issue.errors.length) { - issue.errors.map((issues) => processError({ issues }, [...path, ...issue.path])); - } else if (issue.code === "invalid_key") { - processError({ issues: issue.issues }, [...path, ...issue.path]); - } else if (issue.code === "invalid_element") { - processError({ issues: issue.issues }, [...path, ...issue.path]); - } else { - const fullpath = [...path, ...issue.path]; - if (fullpath.length === 0) { - fieldErrors._errors.push(mapper(issue)); - } else { - let curr = fieldErrors; - let i = 0; - while (i < fullpath.length) { - const el = fullpath[i]; - const terminal = i === fullpath.length - 1; - if (el === "_errors") { - if (terminal) - curr._errors.push(mapper(issue)); - i++; - continue; - } - if (!Object.prototype.hasOwnProperty.call(curr, el)) { - Object.defineProperty(curr, el, { - value: { _errors: [] }, - enumerable: true, - writable: true, - configurable: true - }); - } - const node = curr[el]; - if (terminal) { - node._errors.push(mapper(issue)); - } - curr = node; - i++; - } - } - } - } - }; - processError(error); - return fieldErrors; -} - -// node_modules/zod/v4/core/parse.js -function finalizeParams(callee, params) { - return { callee: params?.callee ?? callee, Err: params?.Err }; -} -var _parse = (_Err) => { - const fn = (schema, value, _ctx, _params) => { - const ctx = _ctx ? { ..._ctx, async: false } : { async: false }; - const result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) { - throw new $ZodAsyncError; - } - if (result.issues.length) { - const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); - captureStackTrace(e, _params?.callee ?? fn); - throw e; - } - return result.value; - }; - return fn; -}; -var _parseAsync = (_Err) => { - const fn = async (schema, value, _ctx, params) => { - const ctx = _ctx ? { ..._ctx, async: true } : { async: true }; - let result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) - result = await result; - if (result.issues.length) { - const e = new (params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); - captureStackTrace(e, params?.callee ?? fn); - throw e; - } - return result.value; - }; - return fn; -}; -var _safeParse = (_Err) => (schema, value, _ctx) => { - const ctx = _ctx ? { ..._ctx, async: false } : { async: false }; - const result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) { - throw new $ZodAsyncError; - } - return result.issues.length ? failure(_Err, result.issues, ctx) : { success: true, data: result.value }; -}; -function failure(Err, issues, ctx) { - let error; - return { - success: false, - get error() { - if (!error) { - error = new Err(issues.map((iss) => finalizeIssue(iss, ctx, config()))); - issues = undefined; - ctx = undefined; - } - return error; - }, - set error(e) { - error = e; - issues = undefined; - ctx = undefined; - } - }; -} -var _safeParseAsync = (_Err) => async (schema, value, _ctx) => { - const ctx = _ctx ? { ..._ctx, async: true } : { async: true }; - let result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) - result = await result; - return result.issues.length ? failure(_Err, result.issues, ctx) : { success: true, data: result.value }; -}; -var COMPILE_INVALID = /* @__PURE__ */ Symbol.for("zod.compile.invalid"); -var COMPILE_FALLBACK = /* @__PURE__ */ Symbol.for("zod.compile.fallback"); -var validate = (schema, value, _ctx) => { - const validator = schema._zod.bag.validator; - if (validator !== undefined) { - if (validator(value) !== COMPILE_INVALID) - return true; - if (validator.definite === true && _ctx === undefined) - return false; + if (validator.definite === true && _ctx === undefined) + return false; } return validateFallback(schema, value, _ctx); }; @@ -17709,911 +17005,1640 @@ var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { parseAsync: async function _parseAsync(data, params) { return await parseAsync(this, data, params, { callee: _parseAsync }); }, - safeParse(data, params) { - return safeParse(this, data, params); + safeParse(data, params) { + return safeParse(this, data, params); + }, + async safeParseAsync(data, params) { + return safeParseAsync(this, data, params); + }, + get spa() { + return this?.safeParseAsync; + }, + set spa(value) { + own(this, "spa", value); + }, + validate(data, params) { + return validate(this, data, params); + }, + validateAsync(data, params) { + return validateAsync(this, data, params); + }, + encode: function _encode(data, params) { + return encode(this, data, params, { callee: _encode }); + }, + decode: function _decode(data, params) { + return decode(this, data, params, { callee: _decode }); + }, + encodeAsync: async function _encodeAsync(data, params) { + return await encodeAsync(this, data, params, { callee: _encodeAsync }); + }, + decodeAsync: async function _decodeAsync(data, params) { + return await decodeAsync(this, data, params, { callee: _decodeAsync }); + }, + safeEncode(data, params) { + return safeEncode(this, data, params); + }, + safeDecode(data, params) { + return safeDecode(this, data, params); + }, + async safeEncodeAsync(data, params) { + return safeEncodeAsync(this, data, params); + }, + async safeDecodeAsync(data, params) { + return safeDecodeAsync(this, data, params); + }, + toJSONSchema(params) { + return createToJSONSchemaMethod(this, {})(params); + }, + get description() { + return globalRegistry.get(this)?.description; + }, + get _def() { + return this._zod.def; + } +}); +var _ZodString = /* @__PURE__ */ $constructor("_ZodString", (inst, def) => { + $ZodString.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => stringProcessor(inst, ctx, json, params); +}, /* @__PURE__ */ derived({ + format: (inst) => aggregateChecks(inst).format ?? null, + minLength: (inst) => aggregateChecks(inst).minimum ?? null, + maxLength: (inst) => aggregateChecks(inst).maximum ?? null +}, { + regex(...args) { + return this.check(_regex(...args)); + }, + includes(...args) { + return this.check(_includes(...args)); + }, + startsWith(...args) { + return this.check(_startsWith(...args)); + }, + endsWith(...args) { + return this.check(_endsWith(...args)); + }, + min(...args) { + return this.check(_minLength(...args)); + }, + max(...args) { + return this.check(_maxLength(...args)); + }, + length(...args) { + return this.check(_length(...args)); + }, + nonempty(...args) { + return this.check(_minLength(1, ...args)); + }, + lowercase(params) { + return this.check(_lowercase(params)); + }, + uppercase(params) { + return this.check(_uppercase(params)); + }, + trim() { + return this.check(_trim()); + }, + normalize(...args) { + return this.check(_normalize(...args)); + }, + toLowerCase() { + return this.check(_toLowerCase()); + }, + toUpperCase() { + return this.check(_toUpperCase()); + }, + slugify() { + return this.check(_slugify()); + } +})); +var ZodString = /* @__PURE__ */ $constructor("ZodString", (inst, def) => { + $ZodString.init(inst, def); + _ZodString.init(inst, def); +}, { + email(params) { + return this.check(_email(ZodEmail, params)); + }, + url(params) { + return this.check(_url(ZodURL, params)); + }, + jwt(params) { + return this.check(_jwt(ZodJWT, params)); + }, + emoji(params) { + return this.check(_emoji2(ZodEmoji, params)); + }, + guid(params) { + return this.check(_guid(ZodGUID, params)); + }, + uuid(params) { + return this.check(_uuid(ZodUUID, params)); + }, + uuidv4(params) { + return this.check(_uuidv4(ZodUUID, params)); + }, + uuidv6(params) { + return this.check(_uuidv6(ZodUUID, params)); + }, + uuidv7(params) { + return this.check(_uuidv7(ZodUUID, params)); + }, + nanoid(params) { + return this.check(_nanoid(ZodNanoID, params)); + }, + cuid(params) { + return this.check(_cuid(ZodCUID, params)); + }, + cuid2(params) { + return this.check(_cuid2(ZodCUID2, params)); + }, + ulid(params) { + return this.check(_ulid(ZodULID, params)); + }, + base64(params) { + return this.check(_base64(ZodBase64, params)); + }, + base64url(params) { + return this.check(_base64url(ZodBase64URL, params)); + }, + xid(params) { + return this.check(_xid(ZodXID, params)); + }, + ksuid(params) { + return this.check(_ksuid(ZodKSUID, params)); + }, + ipv4(params) { + return this.check(_ipv4(ZodIPv4, params)); + }, + ipv6(params) { + return this.check(_ipv6(ZodIPv6, params)); + }, + cidrv4(params) { + return this.check(_cidrv4(ZodCIDRv4, params)); + }, + cidrv6(params) { + return this.check(_cidrv6(ZodCIDRv6, params)); + }, + e164(params) { + return this.check(_e164(ZodE164, params)); + }, + datetime(params) { + return this.check(_isoDateTime(ZodISODateTime, params)); + }, + date(params) { + return this.check(_isoDate(ZodISODate, params)); + }, + time(params) { + return this.check(_isoTime(ZodISOTime, params)); + }, + duration(params) { + return this.check(_isoDuration(ZodISODuration, params)); + } +}); +function string2(params) { + return _string(ZodString, params); +} +var ZodStringFormat = /* @__PURE__ */ $constructor("ZodStringFormat", (inst, def) => { + $ZodStringFormat.init(inst, def); + _ZodString.init(inst, def); +}); +var ZodISODateTime = /* @__PURE__ */ $constructor("ZodISODateTime", (inst, def) => { + $ZodISODateTime.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodISODate = /* @__PURE__ */ $constructor("ZodISODate", (inst, def) => { + $ZodISODate.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodISOTime = /* @__PURE__ */ $constructor("ZodISOTime", (inst, def) => { + $ZodISOTime.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodISODuration = /* @__PURE__ */ $constructor("ZodISODuration", (inst, def) => { + $ZodISODuration.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodEmail = /* @__PURE__ */ $constructor("ZodEmail", (inst, def) => { + $ZodEmail.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodGUID = /* @__PURE__ */ $constructor("ZodGUID", (inst, def) => { + $ZodGUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodUUID = /* @__PURE__ */ $constructor("ZodUUID", (inst, def) => { + $ZodUUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodURL = /* @__PURE__ */ $constructor("ZodURL", (inst, def) => { + $ZodURL.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodEmoji = /* @__PURE__ */ $constructor("ZodEmoji", (inst, def) => { + $ZodEmoji.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodNanoID = /* @__PURE__ */ $constructor("ZodNanoID", (inst, def) => { + $ZodNanoID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodCUID = /* @__PURE__ */ $constructor("ZodCUID", (inst, def) => { + $ZodCUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodCUID2 = /* @__PURE__ */ $constructor("ZodCUID2", (inst, def) => { + $ZodCUID2.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodULID = /* @__PURE__ */ $constructor("ZodULID", (inst, def) => { + $ZodULID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodXID = /* @__PURE__ */ $constructor("ZodXID", (inst, def) => { + $ZodXID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodKSUID = /* @__PURE__ */ $constructor("ZodKSUID", (inst, def) => { + $ZodKSUID.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodIPv4 = /* @__PURE__ */ $constructor("ZodIPv4", (inst, def) => { + $ZodIPv4.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodIPv6 = /* @__PURE__ */ $constructor("ZodIPv6", (inst, def) => { + $ZodIPv6.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodCIDRv4 = /* @__PURE__ */ $constructor("ZodCIDRv4", (inst, def) => { + $ZodCIDRv4.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodCIDRv6 = /* @__PURE__ */ $constructor("ZodCIDRv6", (inst, def) => { + $ZodCIDRv6.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodBase64 = /* @__PURE__ */ $constructor("ZodBase64", (inst, def) => { + $ZodBase64.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodBase64URL = /* @__PURE__ */ $constructor("ZodBase64URL", (inst, def) => { + $ZodBase64URL.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodE164 = /* @__PURE__ */ $constructor("ZodE164", (inst, def) => { + $ZodE164.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodJWT = /* @__PURE__ */ $constructor("ZodJWT", (inst, def) => { + $ZodJWT.init(inst, def); + ZodStringFormat.init(inst, def); +}); +var ZodNumber = /* @__PURE__ */ $constructor("ZodNumber", (inst, def) => { + $ZodNumber.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => numberProcessor(inst, ctx, json, params); + inst.isFinite = true; +}, /* @__PURE__ */ derived({ + minValue: (inst) => { + const { minimum, exclusiveMinimum } = aggregateChecks(inst); + return Math.max(minimum ?? Number.NEGATIVE_INFINITY, exclusiveMinimum ?? Number.NEGATIVE_INFINITY); + }, + maxValue: (inst) => { + const { maximum, exclusiveMaximum } = aggregateChecks(inst); + return Math.min(maximum ?? Number.POSITIVE_INFINITY, exclusiveMaximum ?? Number.POSITIVE_INFINITY); }, - async safeParseAsync(data, params) { - return safeParseAsync(this, data, params); + isInt: (inst) => { + const { isInt, multipleOf } = aggregateChecks(inst); + return !!isInt || !!multipleOf?.some(Number.isSafeInteger); }, - get spa() { - return this?.safeParseAsync; + format: (inst) => aggregateChecks(inst).format ?? null +}, { + gt(value, params) { + return this.check(_gt(value, params)); }, - set spa(value) { - own(this, "spa", value); + gte(value, params) { + return this.check(_gte(value, params)); }, - validate(data, params) { - return validate(this, data, params); + min(value, params) { + return this.check(_gte(value, params)); }, - validateAsync(data, params) { - return validateAsync(this, data, params); + lt(value, params) { + return this.check(_lt(value, params)); }, - encode: function _encode(data, params) { - return encode(this, data, params, { callee: _encode }); + lte(value, params) { + return this.check(_lte(value, params)); }, - decode: function _decode(data, params) { - return decode(this, data, params, { callee: _decode }); + max(value, params) { + return this.check(_lte(value, params)); }, - encodeAsync: async function _encodeAsync(data, params) { - return await encodeAsync(this, data, params, { callee: _encodeAsync }); + int(params) { + return this.check(int(params)); }, - decodeAsync: async function _decodeAsync(data, params) { - return await decodeAsync(this, data, params, { callee: _decodeAsync }); + safe(params) { + return this.check(int(params)); }, - safeEncode(data, params) { - return safeEncode(this, data, params); + positive(params) { + return this.check(_gt(0, params)); }, - safeDecode(data, params) { - return safeDecode(this, data, params); + nonnegative(params) { + return this.check(_gte(0, params)); }, - async safeEncodeAsync(data, params) { - return safeEncodeAsync(this, data, params); + negative(params) { + return this.check(_lt(0, params)); }, - async safeDecodeAsync(data, params) { - return safeDecodeAsync(this, data, params); + nonpositive(params) { + return this.check(_lte(0, params)); }, - toJSONSchema(params) { - return createToJSONSchemaMethod(this, {})(params); + multipleOf(value, params) { + return this.check(_multipleOf(value, params)); }, - get description() { - return globalRegistry.get(this)?.description; + step(value, params) { + return this.check(_multipleOf(value, params)); }, - get _def() { - return this._zod.def; + finite() { + return this; } +})); +function number2(params) { + return _number(ZodNumber, params); +} +var ZodNumberFormat = /* @__PURE__ */ $constructor("ZodNumberFormat", (inst, def) => { + $ZodNumberFormat.init(inst, def); + ZodNumber.init(inst, def); }); -var _ZodString = /* @__PURE__ */ $constructor("_ZodString", (inst, def) => { - $ZodString.init(inst, def); +function int(params) { + return _int(ZodNumberFormat, params); +} +var ZodBoolean = /* @__PURE__ */ $constructor("ZodBoolean", (inst, def) => { + $ZodBoolean.init(inst, def); ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => stringProcessor(inst, ctx, json, params); -}, /* @__PURE__ */ derived({ - format: (inst) => aggregateChecks(inst).format ?? null, - minLength: (inst) => aggregateChecks(inst).minimum ?? null, - maxLength: (inst) => aggregateChecks(inst).maximum ?? null + inst._zod.processJSONSchema = (ctx, json, params) => booleanProcessor(inst, ctx, json, params); +}); +function boolean2(params) { + return _boolean(ZodBoolean, params); +} +var ZodUnknown = /* @__PURE__ */ $constructor("ZodUnknown", (inst, def) => { + $ZodUnknown.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => unknownProcessor(inst, ctx, json, params); +}); +function unknown() { + return _unknown(ZodUnknown); +} +var ZodNever = /* @__PURE__ */ $constructor("ZodNever", (inst, def) => { + $ZodNever.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => neverProcessor(inst, ctx, json, params); +}); +function never(params) { + return _never(ZodNever, params); +} +var ZodArray = /* @__PURE__ */ $constructor("ZodArray", (inst, def) => { + _ensureDefaultMemoizer(); + $ZodArray.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => arrayProcessor(inst, ctx, json, params); + inst.element = def.element; }, { - regex(...args) { - return this.check(_regex(...args)); - }, - includes(...args) { - return this.check(_includes(...args)); - }, - startsWith(...args) { - return this.check(_startsWith(...args)); - }, - endsWith(...args) { - return this.check(_endsWith(...args)); - }, - min(...args) { - return this.check(_minLength(...args)); - }, - max(...args) { - return this.check(_maxLength(...args)); - }, - length(...args) { - return this.check(_length(...args)); - }, - nonempty(...args) { - return this.check(_minLength(1, ...args)); - }, - lowercase(params) { - return this.check(_lowercase(params)); - }, - uppercase(params) { - return this.check(_uppercase(params)); - }, - trim() { - return this.check(_trim()); + min(n, params) { + return this.check(_minLength(n, params)); }, - normalize(...args) { - return this.check(_normalize(...args)); + nonempty(params) { + return this.check(_minLength(1, params)); }, - toLowerCase() { - return this.check(_toLowerCase()); + max(n, params) { + return this.check(_maxLength(n, params)); }, - toUpperCase() { - return this.check(_toUpperCase()); + length(n, params) { + return this.check(_length(n, params)); }, - slugify() { - return this.check(_slugify()); + unwrap() { + return this.element; } -})); -var ZodString = /* @__PURE__ */ $constructor("ZodString", (inst, def) => { - $ZodString.init(inst, def); - _ZodString.init(inst, def); -}, { - email(params) { - return this.check(_email(ZodEmail, params)); - }, - url(params) { - return this.check(_url(ZodURL, params)); - }, - jwt(params) { - return this.check(_jwt(ZodJWT, params)); - }, - emoji(params) { - return this.check(_emoji2(ZodEmoji, params)); - }, - guid(params) { - return this.check(_guid(ZodGUID, params)); - }, - uuid(params) { - return this.check(_uuid(ZodUUID, params)); - }, - uuidv4(params) { - return this.check(_uuidv4(ZodUUID, params)); - }, - uuidv6(params) { - return this.check(_uuidv6(ZodUUID, params)); - }, - uuidv7(params) { - return this.check(_uuidv7(ZodUUID, params)); - }, - nanoid(params) { - return this.check(_nanoid(ZodNanoID, params)); - }, - cuid(params) { - return this.check(_cuid(ZodCUID, params)); - }, - cuid2(params) { - return this.check(_cuid2(ZodCUID2, params)); - }, - ulid(params) { - return this.check(_ulid(ZodULID, params)); +}); +function array(element, params) { + return _array(ZodArray, element, params); +} +var ZodObject = /* @__PURE__ */ $constructor("ZodObject", (inst, def) => { + _ensureDefaultMemoizer(); + $ZodObjectJIT.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => objectProcessor(inst, ctx, json, params); + installLazyProp(inst, "shape", (self2) => self2._zod.def.shape, false); +}, { + keyof() { + return _enum(Object.keys(this._zod.def.shape)); }, - base64(params) { - return this.check(_base64(ZodBase64, params)); + catchall(catchall) { + return this.clone(mergeDefs(this._zod.def, { catchall })); }, - base64url(params) { - return this.check(_base64url(ZodBase64URL, params)); + passthrough() { + return this.clone(mergeDefs(this._zod.def, { catchall: unknown() })); }, - xid(params) { - return this.check(_xid(ZodXID, params)); + loose() { + return this.clone(mergeDefs(this._zod.def, { catchall: unknown() })); }, - ksuid(params) { - return this.check(_ksuid(ZodKSUID, params)); + strict() { + return this.clone(mergeDefs(this._zod.def, { catchall: never() })); }, - ipv4(params) { - return this.check(_ipv4(ZodIPv4, params)); + strip() { + return this.clone(mergeDefs(this._zod.def, { catchall: undefined })); }, - ipv6(params) { - return this.check(_ipv6(ZodIPv6, params)); + extend(incoming) { + return extend(this, incoming); }, - cidrv4(params) { - return this.check(_cidrv4(ZodCIDRv4, params)); + safeExtend(incoming) { + return safeExtend(this, incoming); }, - cidrv6(params) { - return this.check(_cidrv6(ZodCIDRv6, params)); + merge(other) { + return merge(this, other); }, - e164(params) { - return this.check(_e164(ZodE164, params)); + pick(mask) { + return pick(this, mask); }, - datetime(params) { - return this.check(_isoDateTime(ZodISODateTime, params)); + omit(mask) { + return omit(this, mask); }, - date(params) { - return this.check(_isoDate(ZodISODate, params)); + partial(...args) { + return partial(ZodOptional, this, args[0]); }, - time(params) { - return this.check(_isoTime(ZodISOTime, params)); + exactPartial(...args) { + return partial(ZodExactOptional, this, args[0], "exactPartial"); }, - duration(params) { - return this.check(_isoDuration(ZodISODuration, params)); + required(...args) { + return required(ZodNonOptional, this, args[0]); } }); -function string2(params) { - return _string(ZodString, params); +function object(shape, params) { + const def = { + type: "object", + shape: shape ?? {}, + ...normalizeParams(params) + }; + return new ZodObject(def); } -var ZodStringFormat = /* @__PURE__ */ $constructor("ZodStringFormat", (inst, def) => { - $ZodStringFormat.init(inst, def); - _ZodString.init(inst, def); +var ZodUnion = /* @__PURE__ */ $constructor("ZodUnion", (inst, def) => { + $ZodUnion.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); + inst.options = def.options; }); -var ZodISODateTime = /* @__PURE__ */ $constructor("ZodISODateTime", (inst, def) => { - $ZodISODateTime.init(inst, def); - ZodStringFormat.init(inst, def); +function union(options, params) { + return new ZodUnion({ + type: "union", + options, + ...normalizeParams(params) + }); +} +var ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("ZodDiscriminatedUnion", (inst, def) => { + ZodUnion.init(inst, def); + $ZodDiscriminatedUnion.init(inst, def); }); -var ZodISODate = /* @__PURE__ */ $constructor("ZodISODate", (inst, def) => { - $ZodISODate.init(inst, def); - ZodStringFormat.init(inst, def); +function discriminatedUnion(discriminator, options, params) { + return new ZodDiscriminatedUnion({ + type: "union", + options, + discriminator, + ...normalizeParams(params) + }); +} +var ZodIntersection = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def) => { + $ZodIntersection.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => intersectionProcessor(inst, ctx, json, params); }); -var ZodISOTime = /* @__PURE__ */ $constructor("ZodISOTime", (inst, def) => { - $ZodISOTime.init(inst, def); - ZodStringFormat.init(inst, def); +function intersection(left, right) { + return new ZodIntersection({ + type: "intersection", + left, + right + }); +} +var ZodEnum = /* @__PURE__ */ $constructor("ZodEnum", (inst, def) => { + $ZodEnum.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => enumProcessor(inst, ctx, json, params); + inst.enum = def.entries; + inst.options = [...inst._zod.values]; + const keys = new Set(Object.keys(def.entries)); + inst.extract = (values, params) => { + const newEntries = {}; + for (const value of values) { + if (keys.has(value)) { + newEntries[value] = def.entries[value]; + } else + throw new Error(`Key ${value} not found in enum`); + } + return new ZodEnum({ + ...def, + checks: [], + ...normalizeParams(params), + entries: newEntries + }); + }; + inst.exclude = (values, params) => { + const newEntries = { ...def.entries }; + for (const value of values) { + if (keys.has(value)) { + delete newEntries[value]; + } else + throw new Error(`Key ${value} not found in enum`); + } + return new ZodEnum({ + ...def, + checks: [], + ...normalizeParams(params), + entries: newEntries + }); + }; }); -var ZodISODuration = /* @__PURE__ */ $constructor("ZodISODuration", (inst, def) => { - $ZodISODuration.init(inst, def); - ZodStringFormat.init(inst, def); +function _enum(values, params) { + const entries = Array.isArray(values) ? Object.fromEntries(values.map((v) => [v, v])) : values; + return new ZodEnum({ + type: "enum", + entries, + ...normalizeParams(params) + }); +} +var ZodLiteral = /* @__PURE__ */ $constructor("ZodLiteral", (inst, def) => { + $ZodLiteral.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => literalProcessor(inst, ctx, json, params); + inst.values = new Set(def.values); + Object.defineProperty(inst, "value", { + get() { + if (def.values.length > 1) { + throw new Error("This schema contains multiple valid literal values. Use `.values` instead."); + } + return def.values[0]; + } + }); +}); +function literal(value, params) { + return new ZodLiteral({ + type: "literal", + values: Array.isArray(value) ? value : [value], + ...normalizeParams(params) + }); +} +var ZodTransform = /* @__PURE__ */ $constructor("ZodTransform", (inst, def) => { + _ensureDefaultMemoizer(); + $ZodTransform.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => transformProcessor(inst, ctx, json, params); + inst._zod.parse = (payload, _ctx) => { + if (_ctx.direction === "backward") { + throw new $ZodEncodeError(inst.constructor.name); + } + payload.addIssue = (issue2) => { + if (typeof issue2 === "string") { + payload.issues.push(issue(issue2, payload.value, def)); + } else { + const _issue = issue2; + if (_issue.fatal) + _issue.continue = false; + _issue.code ?? (_issue.code = "custom"); + if (!("input" in _issue)) + _issue.input = payload.value; + _issue.inst ?? (_issue.inst = inst); + payload.issues.push(issue(_issue)); + } + }; + const output = def.transform(payload.value, payload); + if (output instanceof Promise) { + return output.then((output) => { + payload.value = output; + return payload; + }); + } + payload.value = output; + return payload; + }; +}); +function transform(fn) { + return new ZodTransform({ + type: "transform", + transform: fn + }); +} +var ZodOptional = /* @__PURE__ */ $constructor("ZodOptional", (inst, def) => { + $ZodOptional.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function optional(innerType) { + return new ZodOptional({ + type: "optional", + innerType + }); +} +var ZodExactOptional = /* @__PURE__ */ $constructor("ZodExactOptional", (inst, def) => { + $ZodExactOptional.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; +}); +function exactOptional(innerType) { + return new ZodExactOptional({ + type: "optional", + innerType + }); +} +var ZodNullable = /* @__PURE__ */ $constructor("ZodNullable", (inst, def) => { + $ZodNullable.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => nullableProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; }); -var ZodEmail = /* @__PURE__ */ $constructor("ZodEmail", (inst, def) => { - $ZodEmail.init(inst, def); - ZodStringFormat.init(inst, def); +function nullable(innerType) { + return new ZodNullable({ + type: "nullable", + innerType + }); +} +var ZodDefault = /* @__PURE__ */ $constructor("ZodDefault", (inst, def) => { + $ZodDefault.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => defaultProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + inst.removeDefault = inst.unwrap; }); -var ZodGUID = /* @__PURE__ */ $constructor("ZodGUID", (inst, def) => { - $ZodGUID.init(inst, def); - ZodStringFormat.init(inst, def); +function _default(innerType, defaultValue) { + return new ZodDefault({ + type: "default", + innerType, + get defaultValue() { + return typeof defaultValue === "function" ? defaultValue() : shallowClone(defaultValue); + } + }); +} +var ZodPrefault = /* @__PURE__ */ $constructor("ZodPrefault", (inst, def) => { + $ZodPrefault.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => prefaultProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; }); -var ZodUUID = /* @__PURE__ */ $constructor("ZodUUID", (inst, def) => { - $ZodUUID.init(inst, def); - ZodStringFormat.init(inst, def); +function prefault(innerType, defaultValue) { + return new ZodPrefault({ + type: "prefault", + innerType, + get defaultValue() { + return typeof defaultValue === "function" ? defaultValue() : shallowClone(defaultValue); + } + }); +} +var ZodNonOptional = /* @__PURE__ */ $constructor("ZodNonOptional", (inst, def) => { + $ZodNonOptional.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => nonoptionalProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; }); -var ZodURL = /* @__PURE__ */ $constructor("ZodURL", (inst, def) => { - $ZodURL.init(inst, def); - ZodStringFormat.init(inst, def); +function nonoptional(innerType, params) { + return new ZodNonOptional({ + type: "nonoptional", + innerType, + ...normalizeParams(params) + }); +} +var ZodCatch = /* @__PURE__ */ $constructor("ZodCatch", (inst, def) => { + $ZodCatch.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => catchProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; + inst.removeCatch = inst.unwrap; }); -var ZodEmoji = /* @__PURE__ */ $constructor("ZodEmoji", (inst, def) => { - $ZodEmoji.init(inst, def); - ZodStringFormat.init(inst, def); +function _catch(innerType, catchValue) { + return new ZodCatch({ + type: "catch", + innerType, + catchValue: typeof catchValue === "function" ? catchValue : constantCatch(catchValue) + }); +} +var ZodPipe = /* @__PURE__ */ $constructor("ZodPipe", (inst, def) => { + $ZodPipe.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => pipeProcessor(inst, ctx, json, params); + inst.in = def.in; + inst.out = def.out; }); -var ZodNanoID = /* @__PURE__ */ $constructor("ZodNanoID", (inst, def) => { - $ZodNanoID.init(inst, def); - ZodStringFormat.init(inst, def); +function pipe(in_, out) { + return new ZodPipe({ + type: "pipe", + in: in_, + out + }); +} +var ZodReadonly = /* @__PURE__ */ $constructor("ZodReadonly", (inst, def) => { + $ZodReadonly.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => readonlyProcessor(inst, ctx, json, params); + inst.unwrap = () => inst._zod.def.innerType; }); -var ZodCUID = /* @__PURE__ */ $constructor("ZodCUID", (inst, def) => { - $ZodCUID.init(inst, def); - ZodStringFormat.init(inst, def); +function readonly(innerType) { + return new ZodReadonly({ + type: "readonly", + innerType + }); +} +var ZodCustom = /* @__PURE__ */ $constructor("ZodCustom", (inst, def) => { + $ZodCustom.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => customProcessor(inst, ctx, json, params); }); -var ZodCUID2 = /* @__PURE__ */ $constructor("ZodCUID2", (inst, def) => { - $ZodCUID2.init(inst, def); - ZodStringFormat.init(inst, def); +function refine(fn, _params = {}) { + return _refine(ZodCustom, fn, _params); +} +function superRefine(fn, params) { + return _superRefine(fn, params); +} +// src/shared/lib/quiz/schema.ts +var ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +var languagePattern = /^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/; +var ASSET_FILE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.(?:png|jpe?g|webp|avif|gif|svg)$/; +var remoteImageSrcPattern = /^https:\/\/\S+$/; +var youtubeVideoIdPattern = /^[A-Za-z0-9_-]{11}$/; +function strictObject(shape) { + return object(shape).strict(); +} +function requiredString() { + return string2({ + error: (issue) => issue.input === undefined ? "Required field missing." : "Expected a string." + }); +} +function requiredArray(itemSchema) { + return array(itemSchema, { + error: (issue) => issue.input === undefined ? "Required field missing." : "Expected an array." + }); +} +function requiredBoolean() { + return boolean2({ + error: (issue) => issue.input === undefined ? "Required field missing." : "Expected a boolean (`true` or `false`)." + }); +} +function requiredNumber() { + return number2({ + error: (issue) => issue.input === undefined ? "Required field missing." : "Expected a number." + }); +} +var nonEmptyStringSchema = requiredString().refine((value) => value.trim().length > 0, "Use a non-empty string."); +var idSchema = nonEmptyStringSchema.regex(ID_PATTERN, "Use kebab-case with lowercase latin letters, digits, and single hyphens."); +var tagSchema = idSchema; +var optionSchema = strictObject({ + text: nonEmptyStringSchema, + isCorrect: requiredBoolean() }); -var ZodULID = /* @__PURE__ */ $constructor("ZodULID", (inst, def) => { - $ZodULID.init(inst, def); - ZodStringFormat.init(inst, def); +var choiceOptionsSchema = requiredArray(optionSchema).min(2, "Add at least two Options."); +var placementSchema = _enum(["question", "explanation"], { + error: "Set `placement` to `question` or `explanation`." }); -var ZodXID = /* @__PURE__ */ $constructor("ZodXID", (inst, def) => { - $ZodXID.init(inst, def); - ZodStringFormat.init(inst, def); +var imageSrcSchema = union([ + nonEmptyStringSchema.regex(ASSET_FILE_NAME_PATTERN), + nonEmptyStringSchema.regex(remoteImageSrcPattern) +], { + error: (issue) => { + if (issue.input === undefined) + return "Required field missing."; + return typeof issue.input === "string" && issue.input.startsWith("http://") ? "Use `https`, not `http`." : "Use an `https://` URL or a bare asset filename (kebab-case name plus png/jpg/jpeg/webp/avif/gif/svg)."; + } }); -var ZodKSUID = /* @__PURE__ */ $constructor("ZodKSUID", (inst, def) => { - $ZodKSUID.init(inst, def); - ZodStringFormat.init(inst, def); +var imageDimensionSchema = requiredNumber().int("Use a whole number of pixels, 1 or greater.").min(1, "Use a whole number of pixels, 1 or greater."); +var imageSchema = strictObject({ + src: imageSrcSchema, + alt: nonEmptyStringSchema, + caption: nonEmptyStringSchema.optional(), + placement: placementSchema.optional(), + width: imageDimensionSchema.optional(), + height: imageDimensionSchema.optional() +}).superRefine((image, context) => { + if (image.width === undefined === (image.height === undefined)) + return; + const missingField = image.width === undefined ? "width" : "height"; + context.addIssue({ + code: "custom", + message: `Set \`width\` and \`height\` together, or omit both. \`${missingField}\` is missing.`, + path: [missingField] + }); }); -var ZodIPv4 = /* @__PURE__ */ $constructor("ZodIPv4", (inst, def) => { - $ZodIPv4.init(inst, def); - ZodStringFormat.init(inst, def); +var videoSchema = strictObject({ + provider: literal("youtube", { error: "Set `provider` to `youtube`." }), + id: nonEmptyStringSchema.regex(youtubeVideoIdPattern, "Use the 11-character YouTube video id (for example `dQw4w9WgXcQ`), not a URL."), + start: requiredNumber().int("Use a whole number of seconds, 0 or greater.").min(0, "Use a whole number of seconds, 0 or greater.").optional(), + placement: placementSchema.optional() }); -var ZodIPv6 = /* @__PURE__ */ $constructor("ZodIPv6", (inst, def) => { - $ZodIPv6.init(inst, def); - ZodStringFormat.init(inst, def); +var baseQuestionFields = { + id: idSchema, + title: nonEmptyStringSchema, + description: nonEmptyStringSchema.optional(), + explanation: nonEmptyStringSchema, + references: nonEmptyStringSchema.optional(), + images: requiredArray(imageSchema).min(1, "Add at least one image, or remove the empty `images` array.").optional(), + videos: requiredArray(videoSchema).min(1, "Add at least one video, or remove the empty `videos` array.").optional() +}; +var singleChoiceQuestionSchema = strictObject({ + ...baseQuestionFields, + type: literal("single-choice"), + options: choiceOptionsSchema +}).superRefine((question, context) => { + const correctOptionsCount = question.options.filter((option) => option.isCorrect).length; + if (correctOptionsCount !== 1) { + context.addIssue({ + code: "custom", + message: "A single-choice Question must have exactly one correct Option.", + path: ["options"] + }); + } }); -var ZodCIDRv4 = /* @__PURE__ */ $constructor("ZodCIDRv4", (inst, def) => { - $ZodCIDRv4.init(inst, def); - ZodStringFormat.init(inst, def); +var multipleChoiceQuestionSchema = strictObject({ + ...baseQuestionFields, + type: literal("multiple-choice"), + options: choiceOptionsSchema +}).superRefine((question, context) => { + const correctOptionsCount = question.options.filter((option) => option.isCorrect).length; + if (correctOptionsCount < 1) { + context.addIssue({ + code: "custom", + message: "A multiple-choice Question must have at least one correct Option.", + path: ["options"] + }); + } }); -var ZodCIDRv6 = /* @__PURE__ */ $constructor("ZodCIDRv6", (inst, def) => { - $ZodCIDRv6.init(inst, def); - ZodStringFormat.init(inst, def); +var textInputValidationSchema = strictObject({ + mode: literal("text"), + acceptedAnswers: requiredArray(nonEmptyStringSchema).min(1, "Add at least one Accepted answer."), + caseSensitive: requiredBoolean().optional() }); -var ZodBase64 = /* @__PURE__ */ $constructor("ZodBase64", (inst, def) => { - $ZodBase64.init(inst, def); - ZodStringFormat.init(inst, def); +var numericInputValidationSchema = strictObject({ + mode: literal("numeric"), + acceptedAnswers: requiredArray(requiredNumber().finite("Expected a finite number.")).min(1, "Add at least one Accepted answer."), + tolerance: requiredNumber().finite("Expected a finite number.").min(0, "Use a number greater than or equal to 0.").optional() +}); +var inputValidationSchema = discriminatedUnion("mode", [textInputValidationSchema, numericInputValidationSchema], { + error: "Set `mode` to `text` or `numeric`." }); -var ZodBase64URL = /* @__PURE__ */ $constructor("ZodBase64URL", (inst, def) => { - $ZodBase64URL.init(inst, def); - ZodStringFormat.init(inst, def); +var inputQuestionSchema = strictObject({ + ...baseQuestionFields, + type: literal("input"), + validation: inputValidationSchema }); -var ZodE164 = /* @__PURE__ */ $constructor("ZodE164", (inst, def) => { - $ZodE164.init(inst, def); - ZodStringFormat.init(inst, def); +var questionSchema = discriminatedUnion("type", [singleChoiceQuestionSchema, multipleChoiceQuestionSchema, inputQuestionSchema], { + error: "Set `type` to `single-choice`, `multiple-choice`, or `input`." }); -var ZodJWT = /* @__PURE__ */ $constructor("ZodJWT", (inst, def) => { - $ZodJWT.init(inst, def); - ZodStringFormat.init(inst, def); +var quizSchema = strictObject({ + schemaVersion: literal(1, { + error: "Set `schemaVersion` to the integer `1`." + }), + id: idSchema, + title: nonEmptyStringSchema, + description: nonEmptyStringSchema.optional(), + language: requiredString().regex(languagePattern, "Use a BCP-47 language tag such as `en` or `en-US`.").optional(), + tags: requiredArray(tagSchema).default([]), + author: nonEmptyStringSchema.optional(), + questions: requiredArray(questionSchema).min(1, "Add at least one Question.") +}).superRefine((quiz, context) => { + const seenQuestionIds = new Set; + for (const [index, question] of quiz.questions.entries()) { + if (seenQuestionIds.has(question.id)) { + context.addIssue({ + code: "custom", + message: `Question id "${question.id}" must be unique within the Quiz.`, + path: ["questions", index, "id"] + }); + } + seenQuestionIds.add(question.id); + } }); -var ZodNumber = /* @__PURE__ */ $constructor("ZodNumber", (inst, def) => { - $ZodNumber.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => numberProcessor(inst, ctx, json, params); - inst.isFinite = true; -}, /* @__PURE__ */ derived({ - minValue: (inst) => { - const { minimum, exclusiveMinimum } = aggregateChecks(inst); - return Math.max(minimum ?? Number.NEGATIVE_INFINITY, exclusiveMinimum ?? Number.NEGATIVE_INFINITY); - }, - maxValue: (inst) => { - const { maximum, exclusiveMaximum } = aggregateChecks(inst); - return Math.min(maximum ?? Number.POSITIVE_INFINITY, exclusiveMaximum ?? Number.POSITIVE_INFINITY); - }, - isInt: (inst) => { - const { isInt, multipleOf } = aggregateChecks(inst); - return !!isInt || !!multipleOf?.some(Number.isSafeInteger); - }, - format: (inst) => aggregateChecks(inst).format ?? null -}, { - gt(value, params) { - return this.check(_gt(value, params)); - }, - gte(value, params) { - return this.check(_gte(value, params)); - }, - min(value, params) { - return this.check(_gte(value, params)); - }, - lt(value, params) { - return this.check(_lt(value, params)); - }, - lte(value, params) { - return this.check(_lte(value, params)); - }, - max(value, params) { - return this.check(_lte(value, params)); - }, - int(params) { - return this.check(int(params)); - }, - safe(params) { - return this.check(int(params)); - }, - positive(params) { - return this.check(_gt(0, params)); - }, - nonnegative(params) { - return this.check(_gte(0, params)); +// src/shared/lib/render/markdown.ts +init_dist3(); +init_dist4(); +init_dist6(); + +// stub-prismjs:prismjs +var prismjs_default = { highlight: (code) => code, languages: {} }; + +// src/shared/lib/render/markdown.ts +var import_sanitize_html = __toESM(require_sanitize_html(), 1); +var MARKDOWN_OPTIONS = { + async: false, + breaks: false, + gfm: true +}; +var INLINE_TAGS = ["a", "br", "code", "em", "strong"]; +var BLOCK_TAGS = [ + "blockquote", + "h1", + "h2", + "h3", + "h4", + "li", + "ol", + "p", + "pre", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul" +]; +var PLAIN_TEXT_BLOCK_TAGS = new Set(BLOCK_TAGS); +var LANGUAGE_CLASS_PATTERN = /^language-[a-z0-9-]+$/; +var TOKEN_CLASS_PATTERN = /^[a-z][a-z0-9-]*$/; +var PRISM_LANGUAGE_BY_MARKDOWN_LANGUAGE = { + bash: "bash", + css: "css", + html: "markup", + javascript: "javascript", + js: "javascript", + json: "json", + jsx: "jsx", + py: "python", + python: "python", + sh: "bash", + shell: "bash", + sql: "sql", + ts: "typescript", + tsx: "tsx", + typescript: "typescript" +}; +var transformAnchor = (_tagName, attribs) => { + const href = attribs.href?.trim(); + if (!href) { + return { + attribs: {}, + tagName: "a" + }; + } + const transformedAttribs = { href }; + if (/^https?:\/\//i.test(href)) { + transformedAttribs.rel = "noreferrer"; + } + return { + attribs: transformedAttribs, + tagName: "a" + }; +}; +var INLINE_SANITIZE_OPTIONS = { + allowedAttributes: { + a: ["href", "rel"] }, - negative(params) { - return this.check(_lt(0, params)); + allowedSchemes: ["http", "https", "mailto"], + allowedTags: [...INLINE_TAGS], + allowProtocolRelative: false, + transformTags: { + a: transformAnchor + } +}; +var SANITIZE_OPTIONS = { + allowedAttributes: { + a: ["href", "rel"], + code: ["class"] }, - nonpositive(params) { - return this.check(_lte(0, params)); + allowedClasses: { + code: [LANGUAGE_CLASS_PATTERN] }, - multipleOf(value, params) { - return this.check(_multipleOf(value, params)); + allowedSchemes: ["http", "https", "mailto"], + allowedTags: [...INLINE_TAGS, ...BLOCK_TAGS], + allowProtocolRelative: false, + transformTags: { + a: transformAnchor, + h1: (_tagName, attribs) => ({ + attribs, + tagName: "h2" + }) + } +}; +var HIGHLIGHTED_SANITIZE_OPTIONS = { + ...SANITIZE_OPTIONS, + allowedAttributes: { + a: ["href", "rel"], + code: ["class"], + pre: ["class"], + span: ["class"] }, - step(value, params) { - return this.check(_multipleOf(value, params)); + allowedClasses: { + code: [LANGUAGE_CLASS_PATTERN], + pre: [LANGUAGE_CLASS_PATTERN], + span: [TOKEN_CLASS_PATTERN] }, - finite() { - return this; + allowedTags: [...INLINE_TAGS, "span", ...BLOCK_TAGS] +}; +function markdownToSafeHtml(markdown) { + if (markdown.trim().length === 0) { + return ""; + } + const safeHtml = import_sanitize_html.default(k.parse(markdown, MARKDOWN_OPTIONS), SANITIZE_OPTIONS); + return highlightCodeBlocks(safeHtml); +} +function renderMarkdown(markdown) { + return addHeadingIds(markdownToSafeHtml(markdown)); +} +function renderInlineMarkdown(markdown) { + if (markdown.trim().length === 0) { + return ""; + } + return import_sanitize_html.default(k.parseInline(markdown, MARKDOWN_OPTIONS), INLINE_SANITIZE_OPTIONS); +} +function highlightCodeBlocks(html) { + if (!html.includes(" isTag2(child) && child.name === "code"); + if (code === undefined) { + return; + } + const markdownLanguage = getMarkdownCodeLanguage(code.attribs.class); + if (markdownLanguage === undefined) { + return; + } + const prismLanguage = PRISM_LANGUAGE_BY_MARKDOWN_LANGUAGE[markdownLanguage]; + const grammar = prismjs_default.languages[prismLanguage]; + if (grammar === undefined) { + return; } -})); -function number2(params) { - return _number(ZodNumber, params); + const languageClass = `language-${markdownLanguage}`; + const highlightedHtml = prismjs_default.highlight(extractText(code.children, { separateBlocks: false }), grammar, prismLanguage); + pre.attribs.class = languageClass; + code.attribs.class = languageClass; + code.children = parseHtmlFragment(highlightedHtml); } -var ZodNumberFormat = /* @__PURE__ */ $constructor("ZodNumberFormat", (inst, def) => { - $ZodNumberFormat.init(inst, def); - ZodNumber.init(inst, def); -}); -function int(params) { - return _int(ZodNumberFormat, params); +function getMarkdownCodeLanguage(className) { + const language = className?.split(/\s+/).map((part) => part.match(/^language-([a-z0-9-]+)$/)?.[1]).find((part) => part !== undefined && (part in PRISM_LANGUAGE_BY_MARKDOWN_LANGUAGE)); + return language; } -var ZodBoolean = /* @__PURE__ */ $constructor("ZodBoolean", (inst, def) => { - $ZodBoolean.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => booleanProcessor(inst, ctx, json, params); -}); -function boolean2(params) { - return _boolean(ZodBoolean, params); +function createHeadingSlug(text) { + const baseSlug = text.replace(/<[^<>]+>/g, " ").toLowerCase().trim().replace(/&[a-z0-9#]+;/gi, " ").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return baseSlug.length > 0 ? baseSlug : "section"; } -var ZodUnknown = /* @__PURE__ */ $constructor("ZodUnknown", (inst, def) => { - $ZodUnknown.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => unknownProcessor(inst, ctx, json, params); -}); -function unknown() { - return _unknown(ZodUnknown); +function addHeadingIds(html) { + const seenHeadings = new Map; + return html.replace(/(.*?)<\/h\1>/g, (_match, level, content) => { + const baseSlug = createHeadingSlug(content); + const duplicateCount = seenHeadings.get(baseSlug) ?? 0; + const nextCount = duplicateCount + 1; + seenHeadings.set(baseSlug, nextCount); + const slug = nextCount === 1 ? baseSlug : `${baseSlug}-${nextCount}`; + return `${content}`; + }); } -var ZodNever = /* @__PURE__ */ $constructor("ZodNever", (inst, def) => { - $ZodNever.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => neverProcessor(inst, ctx, json, params); -}); -function never(params) { - return _never(ZodNever, params); +function parseHtmlFragment(html) { + return parseDocument(html, { decodeEntities: true }).children; } -var ZodArray = /* @__PURE__ */ $constructor("ZodArray", (inst, def) => { - _ensureDefaultMemoizer(); - $ZodArray.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => arrayProcessor(inst, ctx, json, params); - inst.element = def.element; -}, { - min(n, params) { - return this.check(_minLength(n, params)); - }, - nonempty(params) { - return this.check(_minLength(1, params)); - }, - max(n, params) { - return this.check(_maxLength(n, params)); - }, - length(n, params) { - return this.check(_length(n, params)); - }, - unwrap() { - return this.element; - } -}); -function array(element, params) { - return _array(ZodArray, element, params); +function renderHtmlFragment(nodes) { + return dist_default(nodes, { encodeEntities: "utf8" }); } -var ZodObject = /* @__PURE__ */ $constructor("ZodObject", (inst, def) => { - _ensureDefaultMemoizer(); - $ZodObjectJIT.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => objectProcessor(inst, ctx, json, params); - installLazyProp(inst, "shape", (self2) => self2._zod.def.shape, false); -}, { - keyof() { - return _enum(Object.keys(this._zod.def.shape)); - }, - catchall(catchall) { - return this.clone(mergeDefs(this._zod.def, { catchall })); - }, - passthrough() { - return this.clone(mergeDefs(this._zod.def, { catchall: unknown() })); - }, - loose() { - return this.clone(mergeDefs(this._zod.def, { catchall: unknown() })); - }, - strict() { - return this.clone(mergeDefs(this._zod.def, { catchall: never() })); - }, - strip() { - return this.clone(mergeDefs(this._zod.def, { catchall: undefined })); - }, - extend(incoming) { - return extend(this, incoming); - }, - safeExtend(incoming) { - return safeExtend(this, incoming); - }, - merge(other) { - return merge(this, other); - }, - pick(mask) { - return pick(this, mask); - }, - omit(mask) { - return omit(this, mask); - }, - partial(...args) { - return partial(ZodOptional, this, args[0]); - }, - exactPartial(...args) { - return partial(ZodExactOptional, this, args[0], "exactPartial"); - }, - required(...args) { - return required(ZodNonOptional, this, args[0]); +function extractText(nodes, options) { + let text = ""; + for (const node of nodes) { + if (isText(node)) { + text += node.data; + continue; + } + if (isTag2(node) && node.name === "br") { + text += ` +`; + continue; + } + if (!hasChildren(node)) { + continue; + } + const inner = extractText(node.children, options); + text += options.separateBlocks && isTag2(node) && PLAIN_TEXT_BLOCK_TAGS.has(node.name) ? ` +${inner} +` : inner; } -}); -function object(shape, params) { - const def = { - type: "object", - shape: shape ?? {}, - ...normalizeParams(params) - }; - return new ZodObject(def); + return text; } -var ZodUnion = /* @__PURE__ */ $constructor("ZodUnion", (inst, def) => { - $ZodUnion.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); - inst.options = def.options; -}); -function union(options, params) { - return new ZodUnion({ - type: "union", - options, - ...normalizeParams(params) - }); +// src/shared/lib/render/tiers.ts +var MARKDOWN_FIELD_TIERS = { + acceptedAnswerDisplay: "inline", + explanation: "full", + imageCaption: "inline", + optionText: "inline", + questionDescription: "full", + questionReferences: "full", + questionTitle: "inline", + quizDescription: "full", + quizTitle: "inline", + tag: "plain" +}; +function escapeHtml(text) { + return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); } -var ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("ZodDiscriminatedUnion", (inst, def) => { - ZodUnion.init(inst, def); - $ZodDiscriminatedUnion.init(inst, def); -}); -function discriminatedUnion(discriminator, options, params) { - return new ZodDiscriminatedUnion({ - type: "union", - options, - discriminator, - ...normalizeParams(params) - }); +function renderMarkdownField(field, value) { + switch (MARKDOWN_FIELD_TIERS[field]) { + case "full": + return renderMarkdown(value); + case "inline": + return renderInlineMarkdown(value); + case "plain": + return escapeHtml(value); + } } -var ZodIntersection = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def) => { - $ZodIntersection.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => intersectionProcessor(inst, ctx, json, params); -}); -function intersection(left, right) { - return new ZodIntersection({ - type: "intersection", - left, - right - }); +// src/shared/lib/content/catalog-profile.ts +function checkCatalogProfile(quiz) { + const issues = []; + if (quiz.description === undefined) { + issues.push({ + severity: "error", + path: "description", + problem: "The Public catalog profile requires a `description`.", + fix: "Add a short `description` explaining what the quiz covers and who it is for." + }); + } + if (quiz.language === undefined) { + issues.push({ + severity: "error", + path: "language", + problem: "The Public catalog profile requires a `language`.", + fix: 'Add a BCP-47 `language` tag such as `"en"`.' + }); + } + if (quiz.tags.length === 0) { + issues.push({ + severity: "error", + path: "tags", + problem: "The Public catalog profile requires at least one Tag.", + fix: "Add one or more kebab-case Tags so the quiz is discoverable in the Catalog filter." + }); + } + issues.push(...checkImportLimits(quiz), ...checkRepeatedImageCaptions(quiz)); + for (const entry of listMarkdownFields(quiz)) { + issues.push(...checkMarkdownField(entry)); + } + return issues; +} +function checkImportLimits(quiz) { + const issues = []; + if (quiz.questions.length > MAX_IMPORT_QUESTIONS) { + issues.push({ + severity: "error", + path: "questions", + problem: `The Public catalog profile enforces Quizbun's Import limits: ${quiz.questions.length} Questions; at most ${MAX_IMPORT_QUESTIONS}.`, + fix: "Split the Quiz into two or more Quizzes, for example by subtopic." + }); + } + const bytes = utf8ByteLength(JSON.stringify(quiz, null, 2)); + if (bytes > MAX_IMPORT_BYTES) { + issues.push({ + severity: "error", + path: "root", + problem: `The Public catalog profile enforces Quizbun's Import limits: ${bytes} bytes of JSON; at most ${MAX_IMPORT_BYTES}.`, + fix: "Split the Quiz into smaller Quizzes or shorten long Explanations." + }); + } + return issues; } -var ZodEnum = /* @__PURE__ */ $constructor("ZodEnum", (inst, def) => { - $ZodEnum.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => enumProcessor(inst, ctx, json, params); - inst.enum = def.entries; - inst.options = [...inst._zod.values]; - const keys = new Set(Object.keys(def.entries)); - inst.extract = (values, params) => { - const newEntries = {}; - for (const value of values) { - if (keys.has(value)) { - newEntries[value] = def.entries[value]; - } else - throw new Error(`Key ${value} not found in enum`); +function checkRepeatedImageCaptions(quiz) { + const issues = []; + const firstPathByCaption = new Map; + for (const [questionIndex, question] of quiz.questions.entries()) { + for (const [imageIndex, image] of (question.images ?? []).entries()) { + if (image.caption === undefined) + continue; + const path = `questions[${questionIndex}].images[${imageIndex}].caption`; + const firstPath = firstPathByCaption.get(image.caption); + if (firstPath === undefined) { + firstPathByCaption.set(image.caption, path); + continue; + } + issues.push({ + severity: "error", + path, + problem: `The Image caption repeats the caption at \`${firstPath}\`.`, + fix: "Use a different Image, or rewrite this caption to explain what the learner should notice here." + }); } - return new ZodEnum({ - ...def, - checks: [], - ...normalizeParams(params), - entries: newEntries + } + return issues; +} +function formatProfileIssues(fileLabel, issues) { + const reports = issues.map((issue, index) => [ + `${index + 1}. ${issue.severity === "warning" ? "Warning" : "Problem"} at path: \`${issue.path}\``, + ` Problem: ${issue.problem}`, + ` Fix: ${issue.fix}` + ].join(` +`)); + return [ + `Public quiz does not satisfy the Public catalog profile in ${fileLabel}:`, + "", + ...reports + ].join(` +`); +} +function checkMarkdownField(entry) { + const issues = []; + const tokens = k.lexer(entry.value); + if (containsRawHtml(tokens)) { + issues.push({ + severity: "error", + path: entry.path, + problem: "Raw HTML in the source text. The renderer always strips it.", + fix: "Rewrite the HTML as Markdown (e.g. `**bold**`, `` `code` ``, `[text](url)`)." }); - }; - inst.exclude = (values, params) => { - const newEntries = { ...def.entries }; - for (const value of values) { - if (keys.has(value)) { - delete newEntries[value]; - } else - throw new Error(`Key ${value} not found in enum`); - } - return new ZodEnum({ - ...def, - checks: [], - ...normalizeParams(params), - entries: newEntries + } + if (MARKDOWN_FIELD_TIERS[entry.field] === "inline" && containsBlockMarkdown(tokens)) { + issues.push({ + severity: "warning", + path: entry.path, + problem: "Block Markdown (headings, lists, code blocks, or multiple paragraphs) in a short field. It degrades to literal text.", + fix: "Keep short fields to one line of inline Markdown, or move the long content to `description` / `explanation`." }); - }; -}); -function _enum(values, params) { - const entries = Array.isArray(values) ? Object.fromEntries(values.map((v) => [v, v])) : values; - return new ZodEnum({ - type: "enum", - entries, - ...normalizeParams(params) - }); + } + if (renderMarkdownField(entry.field, entry.value).trim().length === 0) { + issues.push({ + severity: "error", + path: entry.path, + problem: "The field is empty after Markdown rendering and sanitization.", + fix: "Replace the content with text that survives sanitization (no raw-HTML-only values)." + }); + } + return issues; } -var ZodLiteral = /* @__PURE__ */ $constructor("ZodLiteral", (inst, def) => { - $ZodLiteral.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => literalProcessor(inst, ctx, json, params); - inst.values = new Set(def.values); - Object.defineProperty(inst, "value", { - get() { - if (def.values.length > 1) { - throw new Error("This schema contains multiple valid literal values. Use `.values` instead."); +function* listMarkdownFields(quiz) { + yield { field: "quizTitle", path: "title", value: quiz.title }; + if (quiz.description !== undefined) { + yield { field: "quizDescription", path: "description", value: quiz.description }; + } + for (const [index, question] of quiz.questions.entries()) { + const questionPath = `questions[${index}]`; + yield { field: "questionTitle", path: `${questionPath}.title`, value: question.title }; + if (question.description !== undefined) { + yield { + field: "questionDescription", + path: `${questionPath}.description`, + value: question.description + }; + } + yield { + field: "explanation", + path: `${questionPath}.explanation`, + value: question.explanation + }; + if (question.type === "single-choice" || question.type === "multiple-choice") { + for (const [optionIndex, option] of question.options.entries()) { + yield { + field: "optionText", + path: `${questionPath}.options[${optionIndex}].text`, + value: option.text + }; } - return def.values[0]; } - }); -}); -function literal(value, params) { - return new ZodLiteral({ - type: "literal", - values: Array.isArray(value) ? value : [value], - ...normalizeParams(params) - }); + if (question.type === "input" && question.validation.mode === "text") { + for (const [answerIndex, answer] of question.validation.acceptedAnswers.entries()) { + yield { + field: "acceptedAnswerDisplay", + path: `${questionPath}.validation.acceptedAnswers[${answerIndex}]`, + value: answer + }; + } + } + } } -var ZodTransform = /* @__PURE__ */ $constructor("ZodTransform", (inst, def) => { - _ensureDefaultMemoizer(); - $ZodTransform.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => transformProcessor(inst, ctx, json, params); - inst._zod.parse = (payload, _ctx) => { - if (_ctx.direction === "backward") { - throw new $ZodEncodeError(inst.constructor.name); +function containsRawHtml(tokens) { + return tokens.some((token) => { + if (token.type === "html") { + return true; } - payload.addIssue = (issue2) => { - if (typeof issue2 === "string") { - payload.issues.push(issue(issue2, payload.value, def)); - } else { - const _issue = issue2; - if (_issue.fatal) - _issue.continue = false; - _issue.code ?? (_issue.code = "custom"); - if (!("input" in _issue)) - _issue.input = payload.value; - _issue.inst ?? (_issue.inst = inst); - payload.issues.push(issue(_issue)); - } - }; - const output = def.transform(payload.value, payload); - if (output instanceof Promise) { - return output.then((output) => { - payload.value = output; - return payload; - }); + if ("tokens" in token && token.tokens !== undefined && containsRawHtml(token.tokens)) { + return true; + } + return "items" in token && containsRawHtml(token.items); + }); +} +var INLINE_SAFE_BLOCK_TOKEN_TYPES = new Set(["paragraph", "space", "text"]); +function containsBlockMarkdown(tokens) { + const blockTokens = tokens.filter((token) => token.type !== "space"); + if (blockTokens.length > 1) { + return true; + } + return blockTokens.some((token) => !INLINE_SAFE_BLOCK_TOKEN_TYPES.has(token.type)); +} +// src/shared/lib/content/image-dimensions.ts +import { readFileSync } from "node:fs"; +import { extname } from "node:path"; +var SUPPORTED_EXTENSIONS = [".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]; +var PIXELS_PER_UNIT = { + "": 1, + cm: 96 / 2.54, + in: 96, + mm: 96 / 25.4, + pc: 16, + pt: 96 / 72, + px: 1, + q: 96 / 25.4 / 4 +}; +function isSupportedImageExtension(fileName) { + return SUPPORTED_EXTENSIONS.includes(extname(fileName).toLowerCase()); +} +function readImageDimensions(filePath) { + const extension = extname(filePath).toLowerCase(); + if (!isSupportedImageExtension(filePath)) { + throw new Error(`Cannot measure "${extension || filePath}": supported formats are ${SUPPORTED_EXTENSIONS.join(", ")}.`); + } + const bytes = readFileSync(filePath); + if (extension === ".svg") { + return parseSvgDimensions(bytes.toString("utf8")); + } + if (extension === ".png") + return parsePngDimensions(bytes); + if (extension === ".gif") + return parseGifDimensions(bytes); + if (extension === ".webp") + return parseWebpDimensions(bytes); + if (extension === ".avif") + return parseAvifDimensions(bytes); + return parseJpegDimensions(bytes); +} +function parseSvgDimensions(source) { + const rootTag = findSvgRootTag(source); + if (rootTag === undefined) { + throw new Error("SVG has no root `` element."); + } + const width = parseSvgLength(readAttribute(rootTag, "width")); + const height = parseSvgLength(readAttribute(rootTag, "height")); + if (width !== undefined && height !== undefined) { + return toDimensions(width, height); + } + const viewBox = readAttribute(rootTag, "viewBox"); + const viewBoxNumbers = viewBox?.trim().split(/[\s,]+/).map(Number); + if (viewBoxNumbers?.length === 4 && viewBoxNumbers.every((value) => Number.isFinite(value))) { + const [, , viewBoxWidth, viewBoxHeight] = viewBoxNumbers; + if (viewBoxWidth > 0 && viewBoxHeight > 0) { + return toDimensions(viewBoxWidth, viewBoxHeight); } - payload.value = output; - return payload; - }; -}); -function transform(fn) { - return new ZodTransform({ - type: "transform", - transform: fn - }); + } + throw new Error("SVG has no intrinsic size: add a `viewBox`, or `width` and `height` in absolute units."); } -var ZodOptional = /* @__PURE__ */ $constructor("ZodOptional", (inst, def) => { - $ZodOptional.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function optional(innerType) { - return new ZodOptional({ - type: "optional", - innerType - }); +var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +function parsePngDimensions(bytes) { + const hasSignature = bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE); + if (!hasSignature || bytes.subarray(12, 16).toString("latin1") !== "IHDR") { + throw new Error("PNG is missing its signature or IHDR header chunk."); + } + return toDimensions(bytes.readUInt32BE(16), bytes.readUInt32BE(20)); } -var ZodExactOptional = /* @__PURE__ */ $constructor("ZodExactOptional", (inst, def) => { - $ZodExactOptional.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function exactOptional(innerType) { - return new ZodExactOptional({ - type: "optional", - innerType - }); +function parseGifDimensions(bytes) { + const header = bytes.length >= 10 ? bytes.subarray(0, 6).toString("latin1") : ""; + if (header !== "GIF87a" && header !== "GIF89a") { + throw new Error("GIF is missing its `GIF87a`/`GIF89a` header."); + } + return toDimensions(bytes.readUInt16LE(6), bytes.readUInt16LE(8)); } -var ZodNullable = /* @__PURE__ */ $constructor("ZodNullable", (inst, def) => { - $ZodNullable.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => nullableProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function nullable(innerType) { - return new ZodNullable({ - type: "nullable", - innerType - }); +function parseJpegDimensions(bytes) { + if (bytes.length < 4 || bytes.readUInt16BE(0) !== 65496) { + throw new Error("JPEG is missing its Start-of-Image marker."); + } + let offset = 2; + while (offset < bytes.length) { + const next = readNextMarker(bytes, offset); + if (next === undefined) + break; + const { marker } = next; + offset = next.offset; + if (isStandaloneMarker(marker)) + continue; + if (offset + 2 > bytes.length) + break; + if (marker === 218) + break; + if (isStartOfFrameMarker(marker)) + return readStartOfFrame(bytes, offset); + offset += bytes.readUInt16BE(offset); + } + throw new Error("JPEG has no Start-of-Frame segment."); } -var ZodDefault = /* @__PURE__ */ $constructor("ZodDefault", (inst, def) => { - $ZodDefault.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => defaultProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; - inst.removeDefault = inst.unwrap; -}); -function _default(innerType, defaultValue) { - return new ZodDefault({ - type: "default", - innerType, - get defaultValue() { - return typeof defaultValue === "function" ? defaultValue() : shallowClone(defaultValue); - } - }); +function parseWebpDimensions(bytes) { + const isRiffWebp = bytes.length >= 16 && bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP"; + if (!isRiffWebp) { + throw new Error("WebP is missing its RIFF/WEBP header."); + } + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkType = bytes.subarray(offset, offset + 4).toString("latin1"); + const chunkSize = bytes.readUInt32LE(offset + 4); + const data = offset + 8; + const dimensions = readWebpImageChunk(bytes, chunkType, data); + if (dimensions !== undefined) + return dimensions; + offset = data + chunkSize + chunkSize % 2; + } + throw new Error("WebP has no `VP8X`, `VP8 `, or `VP8L` chunk."); } -var ZodPrefault = /* @__PURE__ */ $constructor("ZodPrefault", (inst, def) => { - $ZodPrefault.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => prefaultProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function prefault(innerType, defaultValue) { - return new ZodPrefault({ - type: "prefault", - innerType, - get defaultValue() { - return typeof defaultValue === "function" ? defaultValue() : shallowClone(defaultValue); +function readWebpImageChunk(bytes, chunkType, data) { + if (chunkType === "VP8X" && data + 10 <= bytes.length) { + return toDimensions(readUInt24LE(bytes, data + 4) + 1, readUInt24LE(bytes, data + 7) + 1); + } + if (chunkType === "VP8 " && data + 10 <= bytes.length) { + const hasKeyFrameSignature = bytes[data + 3] === 157 && bytes[data + 4] === 1 && bytes[data + 5] === 42; + if (!hasKeyFrameSignature) { + throw new Error("WebP `VP8 ` chunk has no key-frame header."); } - }); + return toDimensions(bytes.readUInt16LE(data + 6) & 16383, bytes.readUInt16LE(data + 8) & 16383); + } + if (chunkType === "VP8L" && data + 5 <= bytes.length) { + if (bytes[data] !== 47) { + throw new Error("WebP `VP8L` chunk has no lossless signature byte."); + } + const header = bytes.readUInt32LE(data + 1); + return toDimensions((header & 16383) + 1, (header >>> 14 & 16383) + 1); + } + return; } -var ZodNonOptional = /* @__PURE__ */ $constructor("ZodNonOptional", (inst, def) => { - $ZodNonOptional.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => nonoptionalProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function nonoptional(innerType, params) { - return new ZodNonOptional({ - type: "nonoptional", - innerType, - ...normalizeParams(params) - }); +function parseAvifDimensions(bytes) { + const meta = findBox(bytes, 0, bytes.length, "meta"); + if (meta === undefined) { + throw new Error("AVIF has no `meta` box."); + } + const metaChildrenStart = meta.contentStart + 4; + const properties = findBox(bytes, metaChildrenStart, meta.contentEnd, "iprp"); + const propertyContainer = properties && findBox(bytes, properties.contentStart, properties.contentEnd, "ipco"); + if (!properties || !propertyContainer) { + throw new Error("AVIF has no `iprp`/`ipco` property boxes."); + } + const propertyBoxes = listBoxes(bytes, propertyContainer.contentStart, propertyContainer.contentEnd); + const primaryItemId = readPrimaryItemId(bytes, metaChildrenStart, meta.contentEnd); + const association = findBox(bytes, properties.contentStart, properties.contentEnd, "ipma"); + const associatedIndexes = association && primaryItemId !== undefined ? readPropertyIndexes(bytes, association, primaryItemId) : undefined; + const spatialExtents = associatedIndexes?.map((index) => propertyBoxes[index - 1]).find((box) => box?.type === "ispe") ?? propertyBoxes.find((box) => box.type === "ispe"); + if (spatialExtents === undefined || spatialExtents.contentStart + 12 > bytes.length) { + throw new Error("AVIF has no `ispe` box for its primary item."); + } + return toDimensions(bytes.readUInt32BE(spatialExtents.contentStart + 4), bytes.readUInt32BE(spatialExtents.contentStart + 8)); } -var ZodCatch = /* @__PURE__ */ $constructor("ZodCatch", (inst, def) => { - $ZodCatch.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => catchProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; - inst.removeCatch = inst.unwrap; -}); -function _catch(innerType, catchValue) { - return new ZodCatch({ - type: "catch", - innerType, - catchValue: typeof catchValue === "function" ? catchValue : constantCatch(catchValue) - }); +function listBoxes(bytes, start, end) { + const boxes = []; + let offset = start; + while (offset + 8 <= end) { + const box = readBox(bytes, offset, end); + if (box === undefined) + break; + boxes.push(box); + offset = box.end; + } + return boxes; +} +function findBox(bytes, start, end, type) { + return listBoxes(bytes, start, end).find((box) => box.type === type); +} +function readBox(bytes, offset, end) { + const declaredSize = bytes.readUInt32BE(offset); + const type = bytes.subarray(offset + 4, offset + 8).toString("latin1"); + let contentStart = offset + 8; + let size = declaredSize; + if (declaredSize === 1) { + if (offset + 16 > end) + return; + const high = bytes.readUInt32BE(offset + 8); + if (high !== 0) + return; + size = bytes.readUInt32BE(offset + 12); + contentStart = offset + 16; + } else if (declaredSize === 0) { + size = end - offset; + } + const boxEnd = offset + size; + if (size < contentStart - offset || boxEnd > end) + return; + return { contentEnd: boxEnd, contentStart, end: boxEnd, type }; +} +function readPrimaryItemId(bytes, start, end) { + const primaryItem = findBox(bytes, start, end, "pitm"); + if (primaryItem === undefined) + return; + const version = bytes[primaryItem.contentStart]; + const idStart = primaryItem.contentStart + 4; + if (version === 0) { + return idStart + 2 <= end ? bytes.readUInt16BE(idStart) : undefined; + } + return idStart + 4 <= end ? bytes.readUInt32BE(idStart) : undefined; +} +function readPropertyIndexes(bytes, association, itemId) { + const version = bytes[association.contentStart] ?? 0; + const flags = readUInt24BE(bytes, association.contentStart + 1); + const usesWideIndexes = (flags & 1) === 1; + let offset = association.contentStart + 4; + if (offset + 4 > association.contentEnd) + return; + const entryCount = bytes.readUInt32BE(offset); + offset += 4; + for (let entry = 0;entry < entryCount; entry += 1) { + if (offset + 3 > association.contentEnd) + return; + const entryItemId = version < 1 ? bytes.readUInt16BE(offset) : bytes.readUInt32BE(offset); + offset += version < 1 ? 2 : 4; + const associationCount = bytes[offset] ?? 0; + offset += 1; + const entryIndexes = readAssociationIndexes(bytes, association.contentEnd, offset, associationCount, usesWideIndexes); + if (entryIndexes === undefined) + return; + offset = entryIndexes.end; + if (entryItemId === itemId) + return entryIndexes.indexes; + } + return; } -var ZodPipe = /* @__PURE__ */ $constructor("ZodPipe", (inst, def) => { - $ZodPipe.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => pipeProcessor(inst, ctx, json, params); - inst.in = def.in; - inst.out = def.out; -}); -function pipe(in_, out) { - return new ZodPipe({ - type: "pipe", - in: in_, - out - }); +function readAssociationIndexes(bytes, contentEnd, start, count, usesWideIndexes) { + const indexes = []; + let offset = start; + for (let index = 0;index < count; index += 1) { + if (offset >= contentEnd) + return; + if (usesWideIndexes) { + indexes.push(bytes.readUInt16BE(offset) & 32767); + offset += 2; + } else { + indexes.push((bytes[offset] ?? 0) & 127); + offset += 1; + } + } + return { end: offset, indexes }; } -var ZodReadonly = /* @__PURE__ */ $constructor("ZodReadonly", (inst, def) => { - $ZodReadonly.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => readonlyProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function readonly(innerType) { - return new ZodReadonly({ - type: "readonly", - innerType - }); +function readNextMarker(bytes, start) { + if (bytes[start] !== 255) { + throw new Error(`JPEG marker segment expected at byte ${start}.`); + } + let offset = start; + while (offset < bytes.length && bytes[offset] === 255) + offset += 1; + const marker = bytes[offset]; + return marker === undefined ? undefined : { marker, offset: offset + 1 }; } -var ZodCustom = /* @__PURE__ */ $constructor("ZodCustom", (inst, def) => { - $ZodCustom.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => customProcessor(inst, ctx, json, params); -}); -function refine(fn, _params = {}) { - return _refine(ZodCustom, fn, _params); +function isStandaloneMarker(marker) { + return marker === 1 || marker >= 208 && marker <= 217; } -function superRefine(fn, params) { - return _superRefine(fn, params); +function readStartOfFrame(bytes, offset) { + const segmentLength = bytes.readUInt16BE(offset); + if (segmentLength < 7 || offset + 7 > bytes.length) { + throw new Error("JPEG Start-of-Frame segment is truncated."); + } + return toDimensions(bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3)); } -// src/shared/lib/quiz/schema.ts -var ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -var languagePattern = /^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/; -var ASSET_FILE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.(?:png|jpe?g|webp|avif|gif|svg)$/; -var remoteImageSrcPattern = /^https:\/\/\S+$/; -var youtubeVideoIdPattern = /^[A-Za-z0-9_-]{11}$/; -function strictObject(shape) { - return object(shape).strict(); +function isStartOfFrameMarker(marker) { + return marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204; } -function requiredString() { - return string2({ - error: (issue) => issue.input === undefined ? "Required field missing." : "Expected a string." - }); +function readUInt24LE(bytes, offset) { + return (bytes[offset] ?? 0) | (bytes[offset + 1] ?? 0) << 8 | (bytes[offset + 2] ?? 0) << 16; } -function requiredArray(itemSchema) { - return array(itemSchema, { - error: (issue) => issue.input === undefined ? "Required field missing." : "Expected an array." - }); +function readUInt24BE(bytes, offset) { + return (bytes[offset] ?? 0) << 16 | (bytes[offset + 1] ?? 0) << 8 | (bytes[offset + 2] ?? 0); } -function requiredBoolean() { - return boolean2({ - error: (issue) => issue.input === undefined ? "Required field missing." : "Expected a boolean (`true` or `false`)." - }); +function findSvgRootTag(source) { + const withoutComments = source.replaceAll(//g, ""); + return /]*>/i.exec(withoutComments)?.[0]; } -function requiredNumber() { - return number2({ - error: (issue) => issue.input === undefined ? "Required field missing." : "Expected a number." - }); +function readAttribute(tag, name) { + const pattern = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i"); + const match = pattern.exec(tag); + return match?.[2] ?? match?.[3]; } -var nonEmptyStringSchema = requiredString().refine((value) => value.trim().length > 0, "Use a non-empty string."); -var idSchema = nonEmptyStringSchema.regex(ID_PATTERN, "Use kebab-case with lowercase latin letters, digits, and single hyphens."); -var tagSchema = idSchema; -var optionSchema = strictObject({ - text: nonEmptyStringSchema, - isCorrect: requiredBoolean() -}); -var choiceOptionsSchema = requiredArray(optionSchema).min(2, "Add at least two Options."); -var placementSchema = _enum(["question", "explanation"], { - error: "Set `placement` to `question` or `explanation`." -}); -var imageSrcSchema = union([ - nonEmptyStringSchema.regex(ASSET_FILE_NAME_PATTERN), - nonEmptyStringSchema.regex(remoteImageSrcPattern) -], { - error: (issue) => { - if (issue.input === undefined) - return "Required field missing."; - return typeof issue.input === "string" && issue.input.startsWith("http://") ? "Use `https`, not `http`." : "Use an `https://` URL or a bare asset filename (kebab-case name plus png/jpg/jpeg/webp/avif/gif/svg)."; - } -}); -var imageDimensionSchema = requiredNumber().int("Use a whole number of pixels, 1 or greater.").min(1, "Use a whole number of pixels, 1 or greater."); -var imageSchema = strictObject({ - src: imageSrcSchema, - alt: nonEmptyStringSchema, - caption: nonEmptyStringSchema.optional(), - placement: placementSchema.optional(), - width: imageDimensionSchema.optional(), - height: imageDimensionSchema.optional() -}).superRefine((image, context) => { - if (image.width === undefined === (image.height === undefined)) +function parseSvgLength(value) { + if (value === undefined) return; - const missingField = image.width === undefined ? "width" : "height"; - context.addIssue({ - code: "custom", - message: `Set \`width\` and \`height\` together, or omit both. \`${missingField}\` is missing.`, - path: [missingField] - }); -}); -var videoSchema = strictObject({ - provider: literal("youtube", { error: "Set `provider` to `youtube`." }), - id: nonEmptyStringSchema.regex(youtubeVideoIdPattern, "Use the 11-character YouTube video id (for example `dQw4w9WgXcQ`), not a URL."), - start: requiredNumber().int("Use a whole number of seconds, 0 or greater.").min(0, "Use a whole number of seconds, 0 or greater.").optional(), - placement: placementSchema.optional() -}); -var baseQuestionFields = { - id: idSchema, - title: nonEmptyStringSchema, - description: nonEmptyStringSchema.optional(), - explanation: nonEmptyStringSchema, - references: nonEmptyStringSchema.optional(), - images: requiredArray(imageSchema).min(1, "Add at least one image, or remove the empty `images` array.").optional(), - videos: requiredArray(videoSchema).min(1, "Add at least one video, or remove the empty `videos` array.").optional() -}; -var singleChoiceQuestionSchema = strictObject({ - ...baseQuestionFields, - type: literal("single-choice"), - options: choiceOptionsSchema -}).superRefine((question, context) => { - const correctOptionsCount = question.options.filter((option) => option.isCorrect).length; - if (correctOptionsCount !== 1) { - context.addIssue({ - code: "custom", - message: "A single-choice Question must have exactly one correct Option.", - path: ["options"] - }); - } -}); -var multipleChoiceQuestionSchema = strictObject({ - ...baseQuestionFields, - type: literal("multiple-choice"), - options: choiceOptionsSchema -}).superRefine((question, context) => { - const correctOptionsCount = question.options.filter((option) => option.isCorrect).length; - if (correctOptionsCount < 1) { - context.addIssue({ - code: "custom", - message: "A multiple-choice Question must have at least one correct Option.", - path: ["options"] - }); - } -}); -var textInputValidationSchema = strictObject({ - mode: literal("text"), - acceptedAnswers: requiredArray(nonEmptyStringSchema).min(1, "Add at least one Accepted answer."), - caseSensitive: requiredBoolean().optional() -}); -var numericInputValidationSchema = strictObject({ - mode: literal("numeric"), - acceptedAnswers: requiredArray(requiredNumber().finite("Expected a finite number.")).min(1, "Add at least one Accepted answer."), - tolerance: requiredNumber().finite("Expected a finite number.").min(0, "Use a number greater than or equal to 0.").optional() -}); -var inputValidationSchema = discriminatedUnion("mode", [textInputValidationSchema, numericInputValidationSchema], { - error: "Set `mode` to `text` or `numeric`." -}); -var inputQuestionSchema = strictObject({ - ...baseQuestionFields, - type: literal("input"), - validation: inputValidationSchema -}); -var questionSchema = discriminatedUnion("type", [singleChoiceQuestionSchema, multipleChoiceQuestionSchema, inputQuestionSchema], { - error: "Set `type` to `single-choice`, `multiple-choice`, or `input`." -}); -var quizSchema = strictObject({ - schemaVersion: literal(1, { - error: "Set `schemaVersion` to the integer `1`." - }), - id: idSchema, - title: nonEmptyStringSchema, - description: nonEmptyStringSchema.optional(), - language: requiredString().regex(languagePattern, "Use a BCP-47 language tag such as `en` or `en-US`.").optional(), - tags: requiredArray(tagSchema).default([]), - author: nonEmptyStringSchema.optional(), - questions: requiredArray(questionSchema).min(1, "Add at least one Question.") -}).superRefine((quiz, context) => { - const seenQuestionIds = new Set; - for (const [index, question] of quiz.questions.entries()) { - if (seenQuestionIds.has(question.id)) { - context.addIssue({ - code: "custom", - message: `Question id "${question.id}" must be unique within the Quiz.`, - path: ["questions", index, "id"] - }); - } - seenQuestionIds.add(question.id); + const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)$/i.exec(value.trim()); + if (match === null) + return; + const amount = Number(match[1]); + const pixelsPerUnit = PIXELS_PER_UNIT[(match[2] ?? "").toLowerCase()]; + if (!Number.isFinite(amount) || amount <= 0 || pixelsPerUnit === undefined) + return; + return amount * pixelsPerUnit; +} +function toDimensions(width, height) { + const rounded = { + height: Math.max(1, Math.round(height)), + width: Math.max(1, Math.round(width)) + }; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error(`Read a non-positive image size (${width}×${height}).`); } -}); + return rounded; +} // src/shared/lib/content/public-quizzes.ts +import { execFileSync } from "node:child_process"; +import { lstatSync, readdirSync, readFileSync as readFileSync2 } from "node:fs"; +import { basename, relative, resolve } from "node:path"; var PUBLIC_QUIZZES_DIR = "content/quizzes"; var MAX_ASSET_FILE_SIZE_BYTES = 512000; function loadPublicQuizzes(contentDir = PUBLIC_QUIZZES_DIR, options = {}) { diff --git a/src/features/import-quiz/model/use-quiz-link.ts b/src/features/import-quiz/model/use-quiz-link.ts new file mode 100644 index 0000000..2f7e518 --- /dev/null +++ b/src/features/import-quiz/model/use-quiz-link.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from "react"; + +import { decodeQuizLink, readQuizLinkFragment } from "@/shared/lib/quiz-link"; + +interface QuizLinkHandlers { + onFailed: (report: string) => void; + onLoaded: (json: string) => void; +} + +/** + * Opens a Quiz link from `location.hash`, once after hydration and again on + * `hashchange`, so pasting a second link into the address bar also loads it. + * The fragment is cleared before decoding (a large URL must not stay in + * history, session restore, or a reload). Never saves anything. + */ +export function useQuizLink({ onFailed, onLoaded }: QuizLinkHandlers): { isOpening: boolean } { + const [isOpening, setIsOpening] = useState(false); + const handlersRef = useRef({ onFailed, onLoaded }); + const latestRequestRef = useRef(0); + + useEffect(() => { + handlersRef.current = { onFailed, onLoaded }; + }); + + useEffect(() => { + async function openFromHash() { + const payload = readQuizLinkFragment(window.location.hash); + + if (payload === undefined) return; + + window.history.replaceState(null, "", window.location.pathname + window.location.search); + + const request = latestRequestRef.current + 1; + latestRequestRef.current = request; + setIsOpening(true); + + const result = await decodeQuizLink(payload); + + // A newer link superseded this one while it was decoding. + if (request !== latestRequestRef.current) return; + + setIsOpening(false); + + if (result.status === "decoded") { + handlersRef.current.onLoaded(result.json); + } else { + handlersRef.current.onFailed(result.report); + } + } + + void openFromHash(); + window.addEventListener("hashchange", openFromHash); + + return () => window.removeEventListener("hashchange", openFromHash); + }, []); + + return { isOpening }; +} diff --git a/src/features/import-quiz/model/validate-quiz-json.spec.ts b/src/features/import-quiz/model/validate-quiz-json.spec.ts index 73d579f..fe10ec7 100644 --- a/src/features/import-quiz/model/validate-quiz-json.spec.ts +++ b/src/features/import-quiz/model/validate-quiz-json.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import validQuiz from "@/shared/lib/quiz/fixtures/valid/all-question-types.json"; @@ -76,4 +76,35 @@ describe("validateQuizJson", () => { expect(result.report).toContain("Path: `title`"); expect(result.report).toContain("`bogus`"); }); + + test("rejects a Quiz over the Import size limit without parsing it", () => { + const parse = vi.spyOn(JSON, "parse"); + const result = validateQuizJson(" ".repeat(1_048_577)); + + assertInvalidResult(result); + + expect(result.report).toContain("This is a Quizbun limit, not a Standard error."); + expect(parse).not.toHaveBeenCalled(); + parse.mockRestore(); + }); + + test("rejects 201 Questions before schema validation, accepts 200", () => { + const [question] = validQuiz.questions; + const withCount = (count: number) => + JSON.stringify({ + ...validQuiz, + questions: Array.from({ length: count }, (_, index) => ({ + ...question, + id: `question-${index}`, + })), + }); + + expect(validateQuizJson(withCount(200)).status).toBe("valid"); + + const result = validateQuizJson(withCount(201)); + + assertInvalidResult(result); + + expect(result.report).toContain("201 Questions; Quizbun imports up to 200"); + }); }); diff --git a/src/features/import-quiz/model/validate-quiz-json.ts b/src/features/import-quiz/model/validate-quiz-json.ts index 52ceb4a..d3aee05 100644 --- a/src/features/import-quiz/model/validate-quiz-json.ts +++ b/src/features/import-quiz/model/validate-quiz-json.ts @@ -1,5 +1,10 @@ import { messageFromError } from "@/shared/lib/errors"; -import { formatQuizValidationErrors, quizSchema } from "@/shared/lib/quiz"; +import { + checkImportQuestionCount, + checkImportSize, + formatQuizValidationErrors, + quizSchema, +} from "@/shared/lib/quiz"; import type { Quiz } from "@/shared/lib/quiz"; /** @@ -11,6 +16,10 @@ export type QuizJsonValidationResult = | { status: "invalid"; report: string }; export function validateQuizJson(text: string): QuizJsonValidationResult { + const sizeReport = checkImportSize(text); + + if (sizeReport !== undefined) return { status: "invalid", report: sizeReport }; + let parsed: unknown; try { @@ -19,6 +28,10 @@ export function validateQuizJson(text: string): QuizJsonValidationResult { return { status: "invalid", report: formatJsonSyntaxError(text, error) }; } + const countReport = checkImportQuestionCount(parsed); + + if (countReport !== undefined) return { status: "invalid", report: countReport }; + const result = quizSchema.safeParse(parsed); if (!result.success) { diff --git a/src/features/import-quiz/ui/import-quiz-form.test.tsx b/src/features/import-quiz/ui/import-quiz-form.test.tsx new file mode 100644 index 0000000..9c8d3a2 --- /dev/null +++ b/src/features/import-quiz/ui/import-quiz-form.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; + +import type { Quiz } from "@/shared/lib/quiz"; +import { encodeQuizLink } from "@/shared/lib/quiz-link"; +import { saveQuiz } from "@/shared/lib/storage"; + +import { ImportQuizForm } from "./import-quiz-form"; + +function makeQuiz(id: string, questionCount = 1): Quiz { + return { + schemaVersion: 1, + id, + title: `Linked quiz ${id}`, + tags: ["test"], + questions: Array.from({ length: questionCount }, (_, index) => ({ + id: `question-${index}`, + type: "single-choice" as const, + title: `Question ${index}?`, + explanation: "Because.", + options: [ + { text: "Yes", isCorrect: true }, + { text: "No", isCorrect: false }, + ], + })), + }; +} + +async function linkTo(quiz: Quiz) { + return `#qos=${await encodeQuizLink(JSON.stringify(quiz))}`; +} + +const originalUrl = window.location.href; + +beforeEach(() => window.history.replaceState(null, "", originalUrl.split("#")[0])); +afterEach(() => window.history.replaceState(null, "", originalUrl.split("#")[0])); + +describe("ImportQuizForm Quiz link", () => { + it("shows the preview for a valid link and removes the fragment from the URL", async () => { + window.location.hash = await linkTo(makeQuiz("link-valid")); + + const screen = await page.render(); + + await expect + .element(screen.getByRole("article", { name: "Linked quiz link-valid" })) + .toBeVisible(); + await expect.element(screen.getByRole("button", { name: "Save to Library" })).toBeVisible(); + expect(window.location.hash).toBe(""); + // The textarea shows pretty-printed JSON, as a paste would. + await expect + .element(screen.getByRole("textbox")) + .toHaveValue(JSON.stringify(makeQuiz("link-valid"), null, 2)); + }); + + it("reports a damaged link and leaves the textarea empty", async () => { + window.location.hash = "#qos=1.AAAAAAAAAAAAAAAA"; + + const screen = await page.render(); + + await expect + .poll(() => screen.getByRole("alert").element().textContent) + .toMatch(/damaged or incomplete/); + await expect.element(screen.getByRole("textbox")).toHaveValue(""); + }); + + it("tells the Creator to update when the link version is unknown", async () => { + window.location.hash = "#qos=2.abc"; + + const screen = await page.render(); + + await expect + .poll(() => screen.getByRole("alert").element().textContent) + .toMatch(/Update the quizbun skill or Import the file/); + }); + + it("shows the Question-count report for a link to 201 Questions, like a paste", async () => { + window.location.hash = await linkTo(makeQuiz("link-too-many", 201)); + + const screen = await page.render(); + + await expect + .poll(() => screen.getByRole("alert").element().textContent) + .toMatch(/201 Questions; Quizbun imports up to 200/); + }); + + it("loads a second Quiz when the hash changes on the page", async () => { + const screen = await page.render(); + + window.location.hash = await linkTo(makeQuiz("link-first")); + await expect + .element(screen.getByRole("article", { name: "Linked quiz link-first" })) + .toBeVisible(); + + window.location.hash = await linkTo(makeQuiz("link-second")); + await expect + .element(screen.getByRole("article", { name: "Linked quiz link-second" })) + .toBeVisible(); + }); + + it("opens the collision dialog on Save when the id already exists", async () => { + await saveQuiz(makeQuiz("link-collision")); + window.location.hash = await linkTo(makeQuiz("link-collision")); + + const screen = await page.render(); + + await screen.getByRole("button", { name: "Save to Library" }).click(); + + await expect + .element(page.getByRole("dialog", { name: "A quiz with this id already exists" })) + .toBeVisible(); + }); +}); diff --git a/src/features/import-quiz/ui/import-quiz-form.tsx b/src/features/import-quiz/ui/import-quiz-form.tsx index 783258f..fe77d4b 100644 --- a/src/features/import-quiz/ui/import-quiz-form.tsx +++ b/src/features/import-quiz/ui/import-quiz-form.tsx @@ -12,6 +12,7 @@ import { CodeTextarea } from "@/shared/ui/textarea"; import { QuizCard } from "@/entities/quiz"; +import { useQuizLink } from "../model/use-quiz-link"; import { validateQuizJson } from "../model/validate-quiz-json"; import type { QuizJsonValidationResult } from "../model/validate-quiz-json"; @@ -34,6 +35,21 @@ export function ImportQuizForm() { setResult(validateQuizJson(text)); } + // Show the linked Quiz as a paste would look, so the Creator can repair the + // JSON in place, then check it right away. Saving stays an explicit click. + const { isOpening } = useQuizLink({ + onLoaded(json) { + const pretty = prettyPrintJson(json); + + updateText(pretty); + setResult(validateQuizJson(pretty)); + }, + onFailed(report) { + updateText(""); + setResult({ status: "invalid", report }); + }, + }); + async function fillFromFile(file: File | undefined) { if (file === undefined) return; @@ -111,6 +127,7 @@ export function ImportQuizForm() { Validate + {isOpening ? "Opening the quiz from the link…" : ""}
{result?.status === "invalid" && } {result?.status === "valid" && ( @@ -221,6 +238,15 @@ function ValidationReport({ report }: { report: string }) { ); } +/** Falls back to the raw text so Validate reports the syntax error with a line and column. */ +function prettyPrintJson(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return json; + } +} + function openQuizDetail(id: string) { window.location.assign(withBase(`library/quiz/?id=${encodeURIComponent(id)}`)); } diff --git a/src/shared/lib/content/catalog-profile.spec.ts b/src/shared/lib/content/catalog-profile.spec.ts index 01c1d06..e7aeb3e 100644 --- a/src/shared/lib/content/catalog-profile.spec.ts +++ b/src/shared/lib/content/catalog-profile.spec.ts @@ -29,6 +29,17 @@ function makeQuiz(overrides: Partial = {}): Quiz { } describe("checkCatalogProfile", () => { + test("enforces Quizbun's Import limits as errors", () => { + const [question] = makeQuiz().questions; + const quiz = makeQuiz({ + questions: Array.from({ length: 201 }, (_, index) => ({ ...question, id: `q${index}` })), + }); + + expect(checkCatalogProfile(quiz)).toEqual([ + expect.objectContaining({ severity: "error", path: "questions" }), + ]); + }); + test("passes a conforming quiz with no issues", () => { expect(checkCatalogProfile(makeQuiz())).toEqual([]); }); diff --git a/src/shared/lib/content/catalog-profile.ts b/src/shared/lib/content/catalog-profile.ts index 2bbfd03..d444f45 100644 --- a/src/shared/lib/content/catalog-profile.ts +++ b/src/shared/lib/content/catalog-profile.ts @@ -1,5 +1,6 @@ import { marked, type Token } from "marked"; +import { MAX_IMPORT_BYTES, MAX_IMPORT_QUESTIONS, utf8ByteLength } from "../quiz"; import type { Quiz } from "../quiz"; import { MARKDOWN_FIELD_TIERS, type MarkdownField, renderMarkdownField } from "../render"; @@ -54,7 +55,7 @@ export function checkCatalogProfile(quiz: Quiz): ProfileIssue[] { }); } - issues.push(...checkRepeatedImageCaptions(quiz)); + issues.push(...checkImportLimits(quiz), ...checkRepeatedImageCaptions(quiz)); for (const entry of listMarkdownFields(quiz)) { issues.push(...checkMarkdownField(entry)); @@ -63,6 +64,34 @@ export function checkCatalogProfile(quiz: Quiz): ProfileIssue[] { return issues; } +/** A Catalog Quiz must be importable by Quizbun itself, so the Import limits are errors here. */ +function checkImportLimits(quiz: Quiz): ProfileIssue[] { + const issues: ProfileIssue[] = []; + + if (quiz.questions.length > MAX_IMPORT_QUESTIONS) { + issues.push({ + severity: "error", + path: "questions", + problem: `The Public catalog profile enforces Quizbun's Import limits: ${quiz.questions.length} Questions; at most ${MAX_IMPORT_QUESTIONS}.`, + fix: "Split the Quiz into two or more Quizzes, for example by subtopic.", + }); + } + + // The pretty-printed form is what a contributor commits and Import receives. + const bytes = utf8ByteLength(JSON.stringify(quiz, null, 2)); + + if (bytes > MAX_IMPORT_BYTES) { + issues.push({ + severity: "error", + path: "root", + problem: `The Public catalog profile enforces Quizbun's Import limits: ${bytes} bytes of JSON; at most ${MAX_IMPORT_BYTES}.`, + fix: "Split the Quiz into smaller Quizzes or shorten long Explanations.", + }); + } + + return issues; +} + function checkRepeatedImageCaptions(quiz: Quiz): ProfileIssue[] { const issues: ProfileIssue[] = []; const firstPathByCaption = new Map(); diff --git a/src/shared/lib/quiz-link/index.ts b/src/shared/lib/quiz-link/index.ts new file mode 100644 index 0000000..c6e6641 --- /dev/null +++ b/src/shared/lib/quiz-link/index.ts @@ -0,0 +1,9 @@ +export { + decodeQuizLink, + encodeQuizLink, + MAX_QUIZ_LINK_LENGTH, + QUIZ_LINK_PARAM, + QUIZ_LINK_VERSION, + readQuizLinkFragment, +} from "./quiz-link"; +export type { QuizLinkResult } from "./quiz-link"; diff --git a/src/shared/lib/quiz-link/quiz-link.spec.ts b/src/shared/lib/quiz-link/quiz-link.spec.ts new file mode 100644 index 0000000..e15fb75 --- /dev/null +++ b/src/shared/lib/quiz-link/quiz-link.spec.ts @@ -0,0 +1,185 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { MAX_IMPORT_BYTES } from "../quiz"; +import { + decodeQuizLink, + encodeQuizLink, + MAX_QUIZ_LINK_LENGTH, + readQuizLinkFragment, +} from "./quiz-link"; + +const examplesDirectory = resolve("docs/examples"); +const largestCatalogQuizPath = resolve( + "content/quizzes/master-javascript-promise-async-execution.json", +); + +async function roundTrip(json: string) { + return decodeQuizLink(await encodeQuizLink(json)); +} + +function toBase64Url(bytes: Uint8Array) { + return Buffer.from(bytes).toString("base64url"); +} + +async function deflate(bytes: Uint8Array) { + const stream = new Blob([bytes as BlobPart]) + .stream() + .pipeThrough(new CompressionStream("deflate")); + + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +/** The report of an invalid result; a decoded one yields an empty string so the match fails loudly. */ +function reportOf(result: Awaited>): string { + return result.status === "invalid" ? result.report : `decoded: ${result.json}`; +} + +describe("encodeQuizLink / decodeQuizLink round trips", () => { + const exampleFiles = readdirSync(examplesDirectory).filter((name) => name.endsWith(".json")); + + test.each(exampleFiles)("round-trips docs/examples/%s", async (name) => { + const json = JSON.stringify(JSON.parse(readFileSync(join(examplesDirectory, name), "utf8"))); + + expect(await roundTrip(json)).toEqual({ status: "decoded", json }); + }); + + test("round-trips the largest Catalog Quiz", async () => { + const json = JSON.stringify(JSON.parse(readFileSync(largestCatalogQuizPath, "utf8"))); + const payload = await encodeQuizLink(json); + + expect(payload.startsWith("1.")).toBe(true); + expect(await decodeQuizLink(payload)).toEqual({ status: "decoded", json }); + }); + + test("round-trips a 200-Question Quiz built from Catalog Questions", async () => { + const quiz = JSON.parse(readFileSync(largestCatalogQuizPath, "utf8")) as { + questions: Array<{ id: string }>; + }; + const source = quiz.questions; + + quiz.questions = Array.from({ length: 200 }, (_, index) => ({ + ...source[index % source.length]!, + id: `question-${index}`, + })); + + const json = JSON.stringify(quiz); + const payload = await encodeQuizLink(json); + + expect(payload.length).toBeLessThan(MAX_QUIZ_LINK_LENGTH); + expect(await decodeQuizLink(payload)).toEqual({ status: "decoded", json }); + }); + + test("round-trips non-ASCII text", async () => { + const json = JSON.stringify({ title: "Привет, мир — 日本語 🎉" }); + + expect(await roundTrip(json)).toEqual({ status: "decoded", json }); + }); + + test("produces only base64url characters after the version prefix", async () => { + expect(await encodeQuizLink("a".repeat(5000) + "?>~")).toMatch(/^1\.[A-Za-z0-9_-]+$/); + }); +}); + +describe("decodeQuizLink failures", () => { + test("requires the version prefix", async () => { + const payload = await encodeQuizLink("{}"); + + expect(reportOf(await decodeQuizLink(payload.slice(2)))).toMatch( + /newer Quizbun, or is not a Quiz link/, + ); + }); + + test("reports an unknown version with the update advice", async () => { + const payload = await encodeQuizLink("{}"); + + expect(reportOf(await decodeQuizLink(`2.${payload.slice(2)}`))).toMatch( + /made by a newer Quizbun[\s\S]*Update the quizbun skill or Import the file/, + ); + }); + + test("refuses a payload longer than the link limit", async () => { + expect(reportOf(await decodeQuizLink(`1.${"A".repeat(MAX_QUIZ_LINK_LENGTH)}`))).toMatch( + /characters; Quizbun opens links up to 1048576/, + ); + }); + + test("accepts a payload of exactly the link limit as far as length goes", async () => { + const result = await decodeQuizLink(`1.${"A".repeat(MAX_QUIZ_LINK_LENGTH - 2)}`); + + expect(result.status === "invalid" && result.report).not.toMatch(/characters; Quizbun/); + }); + + test("reports bad base64url", async () => { + expect(reportOf(await decodeQuizLink("1.not base64!"))).toMatch(/bad base64url/); + expect(reportOf(await decodeQuizLink("1.abcde"))).toMatch(/bad base64url/); + }); + + test("reports a payload with random bytes as damaged", async () => { + expect(reportOf(await decodeQuizLink("1.AAAAAAAAAAAAAAAA"))).toMatch( + /checksum or deflate error/, + ); + }); + + test("catches a truncated payload", async () => { + const payload = await encodeQuizLink(JSON.stringify({ text: "x".repeat(2000) })); + + expect(reportOf(await decodeQuizLink(payload.slice(0, payload.length - 8)))).toMatch( + /damaged or incomplete/, + ); + }); + + test("catches a flipped byte with the checksum", async () => { + const compressed = await deflate(new TextEncoder().encode("hello world".repeat(40))); + compressed[compressed.length - 1] ^= 0xff; + + expect(reportOf(await decodeQuizLink(`1.${toBase64Url(compressed)}`))).toMatch( + /checksum or deflate/, + ); + }); + + test("reports invalid UTF-8", async () => { + const compressed = await deflate(new Uint8Array([0x7b, 0xff, 0xfe, 0x7d])); + + expect(reportOf(await decodeQuizLink(`1.${toBase64Url(compressed)}`))).toMatch(/valid UTF-8/); + }); + + test("accepts JSON of exactly the Import size limit", async () => { + const json = " ".repeat(MAX_IMPORT_BYTES); + + expect(await roundTrip(json)).toEqual({ status: "decoded", json }); + }); + + test("stops at the Import size limit and reports it like a paste does", async () => { + expect(reportOf(await roundTrip(" ".repeat(MAX_IMPORT_BYTES + 1)))).toMatch( + /This is a Quizbun limit, not a Standard error\.[\s\S]*over 1 MB; Quizbun imports up to 1 MB/, + ); + }); + + test("stops a decompression bomb right after the cap instead of unpacking it", async () => { + // 20 MB of zeros deflates to about 20 KB: a tiny link with a huge payload. + const bomb = await deflate(new Uint8Array(20 * 1024 * 1024)); + const result = await decodeQuizLink(`1.${toBase64Url(bomb)}`); + + expect(bomb.byteLength).toBeLessThan(40_000); + expect(reportOf(result)).toMatch(/Quizbun limit/); + }); +}); + +describe("readQuizLinkFragment", () => { + test("reads the payload of the qos key", () => { + expect(readQuizLinkFragment("#qos=1.abc_-")).toBe("1.abc_-"); + }); + + test("ignores unrelated keys and works without the leading #", () => { + expect(readQuizLinkFragment("#utm=x&qos=1.abc&other=y")).toBe("1.abc"); + expect(readQuizLinkFragment("qos=1.abc")).toBe("1.abc"); + }); + + test("returns undefined when there is no qos key", () => { + expect(readQuizLinkFragment("")).toBeUndefined(); + expect(readQuizLinkFragment("#section")).toBeUndefined(); + expect(readQuizLinkFragment("#other=1")).toBeUndefined(); + }); +}); diff --git a/src/shared/lib/quiz-link/quiz-link.ts b/src/shared/lib/quiz-link/quiz-link.ts new file mode 100644 index 0000000..b1e6ba2 --- /dev/null +++ b/src/shared/lib/quiz-link/quiz-link.ts @@ -0,0 +1,166 @@ +import { formatImportSizeReport, MAX_IMPORT_BYTES } from "../quiz"; + +/** + * The Quiz link codec: one Quiz in a URL fragment, `qos=1.`. The `1.` versions this encoding, not the Standard. + * It uses only web-platform APIs, so the site, CI and the `quizbun` skill all + * run this one implementation. + */ + +export const QUIZ_LINK_PARAM = "qos"; +export const QUIZ_LINK_VERSION = 1; +/** The smallest browser URL limit (Firefox, 1 MB) bounds the payload itself. */ +export const MAX_QUIZ_LINK_LENGTH = 1_048_576; + +export type QuizLinkResult = + | { status: "decoded"; json: string } + | { status: "invalid"; report: string }; + +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/; +const BASE64_CHUNK_SIZE = 0x8000; + +export async function encodeQuizLink(json: string): Promise { + const compressed = await collect( + new Blob([new TextEncoder().encode(json)]) + .stream() + .pipeThrough(new CompressionStream("deflate")), + ); + + return `${QUIZ_LINK_VERSION}.${toBase64Url(compressed)}`; +} + +/** The `qos` payload of a `location.hash`, or `undefined` when the hash carries none. */ +export function readQuizLinkFragment(hash: string): string | undefined { + const payload = new URLSearchParams(hash.replace(/^#/, "")).get(QUIZ_LINK_PARAM); + + return payload === null ? undefined : payload; +} + +export async function decodeQuizLink(payload: string): Promise { + if (payload.length > MAX_QUIZ_LINK_LENGTH) { + return invalid( + `The link is ${payload.length} characters; Quizbun opens links up to ${MAX_QUIZ_LINK_LENGTH}.`, + "Import the Quiz `.json` file instead.", + ); + } + + const separatorIndex = payload.indexOf("."); + const version = separatorIndex === -1 ? payload : payload.slice(0, separatorIndex); + + if (version !== String(QUIZ_LINK_VERSION)) { + return invalid( + "This link was made by a newer Quizbun, or is not a Quiz link.", + "Update the quizbun skill or Import the file.", + ); + } + + const encoded = payload.slice(separatorIndex + 1); + + if (separatorIndex === -1 || !BASE64URL_PATTERN.test(encoded) || encoded.length % 4 === 1) { + return invalid("The link is damaged or incomplete (bad base64url).", REOPEN_FIX); + } + + return inflate(fromBase64Url(encoded)); +} + +const REOPEN_FIX = "Open the launcher file again, or Import the Quiz `.json` file."; + +async function inflate(compressed: Uint8Array): Promise { + const reader = new Blob([compressed]) + .stream() + .pipeThrough(new DecompressionStream("deflate")) + .getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + try { + for (;;) { + const { done, value } = await reader.read(); + + if (done) break; + + total += value.byteLength; + + if (total > MAX_IMPORT_BYTES) { + // Stop here: never unpack a decompression bomb past the cap. + await reader.cancel(); + + return { status: "invalid", report: formatImportSizeReport() }; + } + + chunks.push(value); + } + } catch { + return invalid("The link is damaged or incomplete (checksum or deflate error).", REOPEN_FIX); + } + + try { + return { + status: "decoded", + json: new TextDecoder("utf-8", { fatal: true }).decode(join(chunks, total)), + }; + } catch { + return invalid("The link does not contain valid UTF-8 text.", REOPEN_FIX); + } +} + +function invalid(problem: string, fix: string): QuizLinkResult { + return { + status: "invalid", + report: [ + "This Quiz link cannot be opened.", + "", + "1. Path: `link`", + ` Problem: ${problem}`, + ` Fix: ${fix}`, + ].join("\n"), + }; +} + +async function collect(stream: ReadableStream): Promise> { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + for (;;) { + const { done, value } = await reader.read(); + + if (done) return join(chunks, total); + + chunks.push(value); + total += value.byteLength; + } +} + +function join(chunks: Uint8Array[], total: number): Uint8Array { + const joined = new Uint8Array(total); + let offset = 0; + + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + + return joined; +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + + for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(index, index + BASE64_CHUNK_SIZE)); + } + + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function fromBase64Url(encoded: string): Uint8Array { + const binary = atob(encoded.replaceAll("-", "+").replaceAll("_", "/")); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} diff --git a/src/shared/lib/quiz/import-limits.spec.ts b/src/shared/lib/quiz/import-limits.spec.ts new file mode 100644 index 0000000..140d332 --- /dev/null +++ b/src/shared/lib/quiz/import-limits.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + checkImportQuestionCount, + checkImportSize, + MAX_IMPORT_BYTES, + MAX_IMPORT_QUESTIONS, +} from "./import-limits"; + +describe("checkImportQuestionCount", () => { + test("accepts exactly the maximum number of Questions", () => { + expect( + checkImportQuestionCount({ + questions: Array.from({ length: MAX_IMPORT_QUESTIONS }, () => ({})), + }), + ).toBeUndefined(); + }); + + test("rejects one Question over the maximum with a Quizbun-limit report", () => { + const report = checkImportQuestionCount({ questions: Array.from({ length: 201 }, () => ({})) }); + + expect(report).toContain("This is a Quizbun limit, not a Standard error."); + expect(report).toContain("Path: `questions`"); + expect(report).toContain("201 Questions; Quizbun imports up to 200"); + expect(report).toContain("Fix: split the Quiz"); + }); + + test.each([undefined, null, "text", {}, { questions: "many" }])( + "leaves non-array input %j to the schema", + (input) => { + expect(checkImportQuestionCount(input)).toBeUndefined(); + }, + ); +}); + +describe("checkImportSize", () => { + test("accepts exactly the maximum number of bytes", () => { + expect(checkImportSize("a".repeat(MAX_IMPORT_BYTES))).toBeUndefined(); + }); + + test("rejects one byte over the maximum", () => { + const report = checkImportSize("a".repeat(MAX_IMPORT_BYTES + 1)); + + expect(report).toContain("This is a Quizbun limit, not a Standard error."); + expect(report).toContain("Path: `root`"); + expect(report).toContain("Quizbun imports up to 1 MB"); + }); + + test("counts bytes, not string length, for multi-byte characters", () => { + // 2 bytes per character: half the characters already fill the limit. + const fits = "é".repeat(MAX_IMPORT_BYTES / 2); + + expect(fits.length).toBeLessThan(MAX_IMPORT_BYTES); + expect(checkImportSize(fits)).toBeUndefined(); + expect(checkImportSize(`${fits}a`)).toBeDefined(); + }); + + test("reports the actual size in megabytes", () => { + expect(checkImportSize("a".repeat(1_468_006))).toContain("the Quiz JSON is 1.4 MB"); + }); + + test("never calls JSON.parse itself", () => { + const parse = vi.spyOn(JSON, "parse"); + + checkImportSize("a".repeat(MAX_IMPORT_BYTES + 1)); + + expect(parse).not.toHaveBeenCalled(); + parse.mockRestore(); + }); +}); diff --git a/src/shared/lib/quiz/import-limits.ts b/src/shared/lib/quiz/import-limits.ts new file mode 100644 index 0000000..91752b0 --- /dev/null +++ b/src/shared/lib/quiz/import-limits.ts @@ -0,0 +1,75 @@ +/** + * The Import limits: the largest Quiz Quizbun, as a Renderer, imports. They + * belong to this site, not to the Quiz Object Standard: another Renderer may + * accept larger Quizzes, and the Standard stays at `schemaVersion: 1`. Keep + * both constants together and change them with the docs. + */ +export const MAX_IMPORT_QUESTIONS = 200; +export const MAX_IMPORT_BYTES = 1_048_576; + +const LIMIT_PREAMBLE = "This is a Quizbun limit, not a Standard error."; + +/** + * The report for a Quiz text over `MAX_IMPORT_BYTES`; shared by paste, file and + * link. A link decoder stops reading at the limit, so it passes no size. + */ +export function formatImportSizeReport(actualBytes?: number): string { + const size = actualBytes === undefined ? "over 1 MB" : formatMegabytes(actualBytes); + + return formatLimitReport({ + path: "root", + problem: `the Quiz JSON is ${size}; Quizbun imports up to ${formatMegabytes(MAX_IMPORT_BYTES)}`, + fix: "split the Quiz into smaller Quizzes or shorten long Explanations.", + }); +} + +export function formatImportQuestionCountReport(count: number): string { + return formatLimitReport({ + path: "questions", + problem: `${count} Questions; Quizbun imports up to ${MAX_IMPORT_QUESTIONS}`, + fix: "split the Quiz into two or more Quizzes, for example by subtopic.", + }); +} + +/** Measures the UTF-8 bytes of the text Import received, before it is parsed. */ +export function checkImportSize(text: string): string | undefined { + // A UTF-8 byte is at least one UTF-16 code unit, and at most three per unit, + // so only text in the ambiguous band needs the real encoder. + if (text.length > MAX_IMPORT_BYTES) return formatImportSizeReport(utf8ByteLength(text)); + if (text.length * 3 <= MAX_IMPORT_BYTES) return undefined; + + const bytes = utf8ByteLength(text); + + return bytes > MAX_IMPORT_BYTES ? formatImportSizeReport(bytes) : undefined; +} + +/** Runs on parsed JSON, before schema validation, so a huge Quiz skips full validation. */ +export function checkImportQuestionCount(quiz: unknown): string | undefined { + if (typeof quiz !== "object" || quiz === null || !("questions" in quiz)) return undefined; + + const { questions } = quiz; + + if (!Array.isArray(questions) || questions.length <= MAX_IMPORT_QUESTIONS) return undefined; + + return formatImportQuestionCountReport(questions.length); +} + +export function utf8ByteLength(text: string): number { + return new TextEncoder().encode(text).byteLength; +} + +function formatLimitReport(report: { path: string; problem: string; fix: string }): string { + return [ + LIMIT_PREAMBLE, + "", + `1. Path: \`${report.path}\``, + ` Problem: ${report.problem}`, + ` Fix: ${report.fix}`, + ].join("\n"); +} + +function formatMegabytes(bytes: number): string { + const megabytes = bytes / 1_048_576; + + return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)} MB`; +} diff --git a/src/shared/lib/quiz/index.ts b/src/shared/lib/quiz/index.ts index 682f8dd..effae1d 100644 --- a/src/shared/lib/quiz/index.ts +++ b/src/shared/lib/quiz/index.ts @@ -2,6 +2,15 @@ export { checkAnswer, parseNumericInput } from "./check-answer"; export { computeContentHash } from "./content-hash"; export { downloadQuizJson } from "./export-quiz"; export { formatQuizValidationErrors } from "./format-errors"; +export { + checkImportQuestionCount, + checkImportSize, + formatImportQuestionCountReport, + formatImportSizeReport, + MAX_IMPORT_BYTES, + MAX_IMPORT_QUESTIONS, + utf8ByteLength, +} from "./import-limits"; export { parseQuizJson } from "./parse-quiz-json"; export { From 69c25c4e09099d3207ff894d79034b1d95924f34 Mon Sep 17 00:00:00 2001 From: a-dev <1537796+a-dev@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:42:56 +0400 Subject: [PATCH 2/5] feat: add quizbun skill for opening quizzes in Quizbun --- .gitattributes | 1 + .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/ci.yml | 3 + .oxfmtrc.json | 3 +- .oxlintrc.json | 1 + CONTEXT.md | 8 + README.md | 8 + SPEC.md | 14 +- docs/contributing.md | 1 + docs/create-quiz-skill.md | 10 +- docs/description.md | 8 +- docs/quizbun-skill.md | 30 +++ docs/standard.md | 2 +- package.json | 2 + scripts/full-check.sh | 1 + scripts/generate-quizbun-skill.ts | 72 ++++++ scripts/quizbun-skill-bundle.spec.ts | 142 +++++++++++ scripts/quizbun-skill-cli.ts | 182 ++++++++++++++ skills/create-quiz/SKILL.md | 10 +- skills/quizbun/SKILL.md | 41 ++++ skills/quizbun/package.json | 11 + skills/quizbun/scripts/open-quiz.mjs | 228 ++++++++++++++++++ .../import-quiz/ui/import-quiz-form.tsx | 3 +- src/pages/docs/examples/index.astro | 12 +- src/pages/docs/prompt.astro | 2 + src/shared/lib/quiz-link/quiz-link.ts | 2 +- 26 files changed, 783 insertions(+), 15 deletions(-) create mode 100644 docs/quizbun-skill.md create mode 100644 scripts/generate-quizbun-skill.ts create mode 100644 scripts/quizbun-skill-bundle.spec.ts create mode 100644 scripts/quizbun-skill-cli.ts create mode 100644 skills/quizbun/SKILL.md create mode 100644 skills/quizbun/package.json create mode 100644 skills/quizbun/scripts/open-quiz.mjs diff --git a/.gitattributes b/.gitattributes index 96046ac..0a6d67d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,5 @@ # out of the repository language statistics. Review the sources instead — # scripts/create-quiz-validator*.ts and src/shared/lib/quiz/schema.ts. skills/create-quiz/scripts/validate-quiz.mjs linguist-generated=true +skills/quizbun/scripts/open-quiz.mjs linguist-generated=true skills/create-quiz/references/quiz.v1.schema.json linguist-generated=true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index afb9561..37c6a56 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,6 +12,7 @@ Full walkthrough: https://quizbun.fyi/docs/contributing/ ## Checklist - [ ] The quiz validates on the [import page](https://quizbun.fyi/import/) and I completed a full run of it there. +- [ ] The quiz has at most 200 questions and 1 MB of JSON. - [ ] `description` and `language` are set, and there is at least one tag. - [ ] The file is `content/quizzes/{id}.json`, where `{id}` is the quiz's `id`. - [ ] Every Explanation teaches the concept — none of them just restate the correct answer. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abba0a0..4cab986 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,9 @@ jobs: - name: Check create-quiz skill drift run: bun run skill:create-quiz:check + - name: Check quizbun skill drift + run: bun run skill:quizbun:check + - name: Build static site run: bun run build diff --git a/.oxfmtrc.json b/.oxfmtrc.json index e78967c..51ef847 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -4,7 +4,8 @@ "public/schema/**", "content/quizzes/**", "**/create-quiz/references/quiz.v1.schema.json", - "**/create-quiz/scripts/validate-quiz.mjs" + "**/create-quiz/scripts/validate-quiz.mjs", + "**/quizbun/scripts/open-quiz.mjs" ], "overrides": [ { diff --git a/.oxlintrc.json b/.oxlintrc.json index f6018c9..72fe078 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -2,6 +2,7 @@ "$schema": "./node_modules/oxlint/configuration_schema.json", "ignorePatterns": [ "**/create-quiz/scripts/validate-quiz.mjs", + "**/quizbun/scripts/open-quiz.mjs", "**/*.d.ts", ".claude/**", ".agents/**" diff --git a/CONTEXT.md b/CONTEXT.md index 3db055a..8f80656 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -76,6 +76,14 @@ _Avoid_: my quizzes, collection, private catalog Bringing Quiz JSON into the Library after it passes validation. _Avoid_: upload, load, add +**Import limits**: +The maximum Quiz size Quizbun imports: 200 Questions and 1 MB of JSON. A Renderer limit, not part of the Standard. +_Avoid_: schema limits, max length (when referring to the Standard) + +**Quiz link**: +A URL that carries one Quiz in its fragment and opens it in Import. +_Avoid_: share link, deep link (deep links already mean player routes) + **Export**: Saving a Catalog or Library Quiz as JSON. Export never includes Progress. _Avoid_: download, backup diff --git a/README.md b/README.md index f6a0ce0..2338fc7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ npx skills add a-dev/quizbun --skill create-quiz The installed skill includes the Quiz Object Standard authoring contract and a dependency-free Node.js validator. It does not require the Quizbun application or Bun. +Add the `quizbun` skill to open a finished Quiz in Quizbun. It writes a launcher file that loads the Quiz on the Import page, and it requires `create-quiz`: + +```sh +npx skills add a-dev/quizbun --skill create-quiz --skill quizbun +``` + +Quizbun imports Quizzes of at most 200 Questions and 1 MB of JSON. See [the quizbun skill](docs/quizbun-skill.md). + ## Check Explanations against marked answers Validation proves that a Question is well-formed, never that its Explanation diff --git a/SPEC.md b/SPEC.md index d005771..7559727 100644 --- a/SPEC.md +++ b/SPEC.md @@ -134,9 +134,12 @@ Shuffling, Option labels, pagination, Page size, keyboard controls, and layout n ### Published artifacts and Catalog profile - `z.toJSONSchema()` generates [public/schema/quiz.v1.json](public/schema/quiz.v1.json). CI rejects drift from the Zod schema. Zod remains the final authority for cross-field rules that JSON Schema cannot fully express. +- Two Agent Skills ship from `skills/`. `create-quiz` writes and validates a Quiz in the Standard and stays Renderer-neutral. Its generated files are `scripts/validate-quiz.mjs` and `references/quiz.v1.schema.json`. `quizbun` opens a Quiz in Quizbun and requires `create-quiz`. Its generated file is `scripts/open-quiz.mjs`. `bun run skill:create-quiz:check` and `bun run skill:quizbun:check` reject drift in CI. - The `create-quiz` skill owns the [AI generation prompt](skills/create-quiz/references/quiz-generation-prompt.md). The site renders that source with a short introduction. - CI validates every file in [docs/examples](docs/examples) with the Zod schema. - The Public catalog profile adds repository-only rules. It requires `description`, `language`, at least one Tag, repository-wide Quiz id uniqueness, and a filename that matches the Quiz id. Every Catalog Image also requires generated `width` and `height` that match the vendored file. +- The Public catalog profile also enforces the Import limits: at most 200 Questions and 1 MB of JSON. +- The Import limits and the Quiz link are Renderer behavior. They never belong in the schema, and the Standard stays at `schemaVersion: 1`. - Catalog titles use sentence case: only the first word and words that are always capitalized, such as names and acronyms, start with a capital. Human review and the PR checklist enforce it. ## 3. Architecture @@ -158,6 +161,10 @@ The code follows Feature-Sliced Design: There is no `widgets/` layer. Add it only for a reusable multi-feature block that is not a page. +### Import limits and Quiz link code + +`src/shared/lib/quiz/import-limits.ts` holds the Import limits (`MAX_IMPORT_QUESTIONS`, `MAX_IMPORT_BYTES`) and their reports. `src/shared/lib/quiz-link/` holds the Quiz link codec. Both use only web-platform APIs. `scripts/quizbun-skill-cli.ts` bundles them into `skills/quizbun/scripts/open-quiz.mjs`, so the site, CI and the skill share one implementation. + ### Catalog Images Each Catalog Quiz stores vendored Images in `content/quizzes/{id}/` and refers to them by filename. [astro-quiz-assets.ts](astro-quiz-assets.ts) serves validated asset requests during development and copies assets to `dist/quiz-assets/{id}/` during a build. `withBase` produces the public URL. @@ -264,7 +271,9 @@ The Catalog and Library use the same player with different Quiz sources. ### Import, Library, and Quiz detail -Import uses one textarea. Paste, file selection, and drag and drop all fill it. The app parses and validates JSON, shows an editable error report or a preview, then saves the Quiz. An id collision requires an explicit replace or cancel choice. +Import uses one textarea. Paste, file selection, drag and drop, and a Quiz link all fill it. Import runs its checks in this order: text size (1 MB of UTF-8), JSON parse, Question count (200), then the schema. It then shows an editable error report or a preview, and saves the Quiz only after an explicit click. An id collision requires an explicit replace or cancel choice. + +A Quiz link is `/import/#qos=1.`. The `1.` versions the encoding, not the Standard. Import reads the fragment once and calls `history.replaceState` to clear it, so the large URL stays out of history and a reload is safe. The decoder stops reading once the unpacked JSON passes 1 MB, which also defends against decompression bombs. A link never saves a Quiz by itself. Library lists, opens, exports, and deletes Quizzes from IndexedDB. Deleting a Quiz also deletes its Run. The Library and Catalog use the same Tag-filter feature with different data sources. @@ -340,8 +349,7 @@ When the repository becomes public, restore automatic CI, deploy `main`, publish - Offline routing and caching. - Interface localization. - Option ids, presentation hints, and a richer taxonomy. -- Hard field-length limits. -- Documentation links that load an example directly into the player. +- Per-field length limits. The whole-Quiz Import limits already exist. - Read aloud for non-English Voices. - More media sources, presentation fields, and Video providers. - Direct AI loading through an explicit local integration and permission model. diff --git a/docs/contributing.md b/docs/contributing.md index c297bb6..e16b92d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -56,6 +56,7 @@ The Catalog adds these CI-enforced publishing requirements to the Standard: - Add at least one kebab-case `tags` entry for the Catalog filter. Reuse broad subjects such as `javascript`, `system-design`, or `science`. Avoid Tags specific to one Quiz. Add a new Tag only for a new subject area. - Name the file after the Quiz id. A Quiz with `"id": "git-basics"` belongs in `content/quizzes/git-basics.json`. - The Quiz `id` must be unique across the repository. +- The Quiz has at most 200 Questions and 1 MB of JSON, the same [Import limits](./quizbun-skill.md#import-limits) the Import page applies. ### If your Quiz uses Images diff --git a/docs/create-quiz-skill.md b/docs/create-quiz-skill.md index f4933c6..8c5fe44 100644 --- a/docs/create-quiz-skill.md +++ b/docs/create-quiz-skill.md @@ -1,4 +1,4 @@ -## 📥 Install the create-quiz skill +## Install the create-quiz skill The skill includes the authoring rules, JSON Schema, canonical example, and a dependency-free Node.js validator. The validator checks cross-field rules that JSON Schema cannot express. It runs without Quizbun or Bun. @@ -15,3 +15,11 @@ The AI asks only for missing details. It can suggest a narrower topic, recommend Both the skill and copied prompt include an editing pass for clear Questions and natural Explanations. You do not need a separate writing skill. If you use [unslop](https://www.skills.sh/?q=unslop), or [humanizer](https://www.skills.sh/?q=humanizer) or another improvement tool afterward, ask it to preserve facts, accepted answers, and Option correctness, then validate the edited Quiz again. The AI delivers `.json` when it can create files. Save the file, then choose or drop it on Quizbun's Import page. If the AI cannot create files, it provides a JSON code block with a filename and save instructions. You can also paste that JSON into the Import page. + +To open the Quiz in Quizbun without saving the file first, also install the [quizbun](./quizbun-skill.md) skill. Install both with one command: + +```sh +npx skills add a-dev/quizbun --skill create-quiz --skill quizbun +``` + +If your version of the CLI accepts only one skill per command, run the command once for each skill. diff --git a/docs/description.md b/docs/description.md index d18a715..c623d8f 100644 --- a/docs/description.md +++ b/docs/description.md @@ -15,7 +15,7 @@ Quizbun helps people learn rather than merely testing them. After submitting an The main workflow is short: 1. Ask an AI tool to generate a Quiz from the published prompt and JSON Schema. -2. Save the generated JSON file and choose or drop it on the Import page, or paste the JSON from the chat. +2. Save the generated JSON file and choose or drop it on the Import page, paste the JSON from the chat, or open the Quiz link the AI made. 3. Fix any validation errors or save the Quiz. 4. Start learning. @@ -91,9 +91,13 @@ The Library stores imported Quizzes on the current device. Learners can Import, Importing a Quiz with an `id` already present in the Library requires the Learner to replace it or cancel. Replacing a Quiz preserves Progress only for Questions whose Content hashes still match. +The Import limits apply on Import and on Replace. Quizzes already in the Library are not checked again. + ### Import page -The Import page centers on one large textarea. Pasting JSON, choosing a file, and dropping a file all feed the same flow: parse, validate, preview, then save. +The Import page centers on one large textarea. Pasting JSON, choosing a file, dropping a file, and opening a Quiz link all feed the same flow: parse, validate, preview, then save. A Quiz link carries the Quiz in the URL fragment, so the server never receives it. Opening a link fills the textarea and validates it. The Learner still saves explicitly. + +Quizbun imports Quizzes of at most 200 Questions and 1 MB of JSON. A Quiz should stay a learnable unit, and a larger topic works better as several Quizzes. These Import limits belong to Quizbun as a Renderer, not to the Standard. The textarea supports the main AI workflow and lets Creators repair invalid JSON in place. Validation errors must help both humans and AI tools correct the file. diff --git a/docs/quizbun-skill.md b/docs/quizbun-skill.md new file mode 100644 index 0000000..08717b0 --- /dev/null +++ b/docs/quizbun-skill.md @@ -0,0 +1,30 @@ +## Open a Quiz in Quizbun with the quizbun skill + +The `quizbun` skill takes a Quiz JSON file and writes a launcher file named `.open.html`. Open the launcher in a browser and Quizbun's Import page loads the Quiz, validates it, and shows the preview. You still click **Save to Library** yourself. + +The skill requires the [create-quiz](./create-quiz-skill.md) skill. It runs that skill's validator before it builds anything. Install both: + +```sh +npx skills add a-dev/quizbun --skill create-quiz --skill quizbun +``` + +Then invoke `quizbun` with a Quiz file, or ask it to make a Quiz about a topic. In the second case it creates the Quiz through `create-quiz` first. + +### Launcher file and exit codes + +The launcher holds the link. The agent never prints the link, because it contains the whole Quiz and is too long to copy by hand. The script `open-quiz.mjs` exits with one of these codes: + +| Code | Meaning | +| ---- | ----------------------------------------------------------------- | +| 0 | The launcher file was written. | +| 1 | The Quiz is invalid. The validator's report is printed. | +| 2 | The Quiz is over Quizbun's limits, or its link would be too long. | +| 3 | The `create-quiz` skill is missing. | + +### Import limits + +Quizbun imports Quizzes of at most 200 Questions and 1 MB of JSON. These limits belong to Quizbun, not to the Quiz Object Standard, so a larger Quiz is still valid and other Renderers may accept it. If your topic is bigger, split it into several Quizzes by subtopic. The skill offers to do this when it hits the limit. + +### Privacy + +The Quiz sits in the link's fragment, the part after `#`. Browsers never send the fragment to a server, so Quizbun's host does not receive your Quiz. The launcher file and your browser history do hold the whole Quiz until you delete them. Remote Images in a Quiz still load from their own servers when you preview it, the same as with a paste. diff --git a/docs/standard.md b/docs/standard.md index ed20742..146c7eb 100644 --- a/docs/standard.md +++ b/docs/standard.md @@ -252,7 +252,7 @@ A Renderer may prevent empty submissions for usability. The Standard defines cor ## Renderer rules -Renderer behavior must preserve the Standard's content identity rules. +Renderer behavior must preserve the Standard's content identity rules. A Renderer may refuse Quizzes above its own size limits; this does not make the Quiz invalid. 1. Saved choice answers reference Option indexes in the original JSON order, never the displayed order. If a Renderer shuffles Options, it must translate displayed positions back to original indexes before saving or checking answers. 2. Progress is keyed by Quiz `id` and Question `id`, and each saved Question answer is invalidated by a Content hash. If a Question's content changes during re-import, the saved answer for that Question is discarded while unchanged Questions may keep Progress. diff --git a/package.json b/package.json index 155bdb8..9ffbba8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "schema:generate": "bun scripts/generate-json-schema.ts", "skill:create-quiz:check": "bun scripts/generate-create-quiz-skill.ts --check", "skill:create-quiz:generate": "bun scripts/generate-create-quiz-skill.ts", + "skill:quizbun:check": "bun scripts/generate-quizbun-skill.ts --check", + "skill:quizbun:generate": "bun scripts/generate-quizbun-skill.ts", "storybook": "storybook dev -p 6006", "stylelint": "stylelint \"**/*.{css,astro}\"", "stylelint:fix": "stylelint \"**/*.{css,astro}\" --fix", diff --git a/scripts/full-check.sh b/scripts/full-check.sh index a118c61..4889e3b 100755 --- a/scripts/full-check.sh +++ b/scripts/full-check.sh @@ -33,6 +33,7 @@ STEPS=( "quiz:sizes:check ci Check Quiz Image dimensions bun run quiz:sizes:check" "schema:check ci Check JSON Schema drift bun run schema:check" "skill:create-quiz:check ci Check create-quiz skill drift bun run skill:create-quiz:check" + "skill:quizbun:check ci Check quizbun skill drift bun run skill:quizbun:check" "build ci Build static site bun run build" "dist:base-paths ci Check dist for base-path regressions bash scripts/check-dist-base-paths.sh" "e2e ci E2E tests bun run e2e" diff --git a/scripts/generate-quizbun-skill.ts b/scripts/generate-quizbun-skill.ts new file mode 100644 index 0000000..f149278 --- /dev/null +++ b/scripts/generate-quizbun-skill.ts @@ -0,0 +1,72 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; + +const workspaceRoot = process.cwd(); +const skillDirectory = resolve(workspaceRoot, "skills/quizbun"); +const checkOnly = process.argv.includes("--check"); +const temporaryDirectory = mkdtempSync(join(tmpdir(), "quizbun-skill-")); + +try { + const build = await Bun.build({ + entrypoints: [resolve(workspaceRoot, "scripts/quizbun-skill-cli.ts")], + format: "esm", + minify: false, + naming: "open-quiz.mjs", + outdir: temporaryDirectory, + sourcemap: "none", + target: "node", + }); + + if (!build.success) { + const messages = build.logs.map((log) => log.message).join("\n"); + throw new Error(`Could not build the quizbun skill script:\n${messages}`); + } + + const generatedFiles = new Map([ + [ + "scripts/open-quiz.mjs", + [ + "// Generated by scripts/generate-quizbun-skill.ts. Do not edit by hand.", + readFileSync(resolve(temporaryDirectory, "open-quiz.mjs"), "utf8"), + ].join("\n"), + ], + ]); + + const driftedFiles: string[] = []; + + for (const [relativePath, expectedContents] of generatedFiles) { + const destinationPath = resolve(skillDirectory, relativePath); + + if (checkOnly) { + if ( + !existsSync(destinationPath) || + readFileSync(destinationPath, "utf8") !== expectedContents + ) { + driftedFiles.push(relative(workspaceRoot, destinationPath)); + } + continue; + } + + mkdirSync(dirname(destinationPath), { recursive: true }); + writeFileSync(destinationPath, expectedContents); + } + + if (driftedFiles.length > 0) { + throw new Error( + [ + "The generated quizbun skill artifacts have drifted:", + ...driftedFiles.map((filePath) => `- ${filePath}`), + "Run `bun run skill:quizbun:generate` and commit the result.", + ].join("\n"), + ); + } + + console.log( + checkOnly + ? "The generated quizbun skill artifacts are current." + : `Generated ${generatedFiles.size} quizbun skill artifact(s).`, + ); +} finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); +} diff --git a/scripts/quizbun-skill-bundle.spec.ts b/scripts/quizbun-skill-bundle.spec.ts new file mode 100644 index 0000000..c9b650c --- /dev/null +++ b/scripts/quizbun-skill-bundle.spec.ts @@ -0,0 +1,142 @@ +import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { MAX_IMPORT_QUESTIONS } from "../src/shared/lib/quiz/import-limits"; +import { decodeQuizLink, readQuizLinkFragment } from "../src/shared/lib/quiz-link"; + +/** + * Guards the committed `quizbun` skill bundle. Both skills are installed as + * siblings in a temporary folder, the way the Skills CLI lays them out, and + * the generated script runs with plain `node`. + */ + +const fixturePath = resolve("docs/examples/public-quiz-single-choice.json"); +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } +}); + +interface Fixture { + id: string; + title: string; + questions: Array>; +} + +describe("quizbun skill bundle", () => { + test("ships only the CLI, the Import limits and the codec", () => { + const source = readFileSync("skills/quizbun/scripts/open-quiz.mjs", "utf8"); + + expect(source).not.toContain("node_modules"); + expect(source.length).toBeLessThan(40_000); + }); + + test("writes a launcher whose link decodes to the same Quiz", async () => { + const { directory, quizPath } = installSkills(readFixture()); + const run = runOpenQuiz(directory, [quizPath, "--print-url"]); + + expect(run.status).toBe(0); + + const url = run.stdout.trim().split("\n").at(-1) ?? ""; + const payload = readQuizLinkFragment(new URL(url).hash); + const decoded = await decodeQuizLink(payload ?? ""); + + expect(url.startsWith("https://quizbun.fyi/import/#qos=1.")).toBe(true); + expect(decoded).toEqual({ + status: "decoded", + json: JSON.stringify(JSON.parse(readFileSync(quizPath, "utf8"))), + }); + expect(readFileSync(join(directory, `${readFixture().id}.open.html`), "utf8")).toContain( + "location.replace(", + ); + }); + + test("does not print the link unless asked", () => { + const { directory, quizPath } = installSkills(readFixture()); + + expect(runOpenQuiz(directory, [quizPath]).stdout).not.toContain("#qos="); + }); + + test("exits with 1 and the validator's report for an invalid Quiz", () => { + const quiz = readFixture() as Fixture & { extra?: boolean }; + quiz.extra = true; + const { directory, quizPath } = installSkills(quiz); + const run = runOpenQuiz(directory, [quizPath]); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("extra"); + }); + + test("exits with 2 and writes no launcher for a Quiz over the Question limit", () => { + const quiz = readFixture(); + const [question] = quiz.questions; + quiz.questions = Array.from({ length: MAX_IMPORT_QUESTIONS + 1 }, (_, index) => ({ + ...question, + id: `question-${index}`, + })); + const { directory, quizPath } = installSkills(quiz); + const run = runOpenQuiz(directory, [quizPath]); + + expect(run.status).toBe(2); + expect(run.stderr).toContain("This is a Quizbun limit, not a Standard error."); + expect(run.stderr).toContain("201 Questions"); + expect(() => readFileSync(join(directory, `${quiz.id}.open.html`))).toThrow(/ENOENT/); + }); + + test("exits with 3 and the install command when create-quiz is missing", () => { + const { directory, quizPath } = installSkills(readFixture(), { withCreateQuiz: false }); + const run = runOpenQuiz(directory, [quizPath]); + + expect(run.status).toBe(3); + expect(run.stderr).toContain("npx skills add a-dev/quizbun --skill create-quiz"); + }); + + test("escapes a Quiz title that would close the script element", () => { + const quiz = readFixture(); + quiz.title = ` 'quoted' & more`; + const { directory, quizPath } = installSkills(quiz); + + expect(runOpenQuiz(directory, [quizPath]).status).toBe(0); + + const html = readFileSync(join(directory, `${quiz.id}.open.html`), "utf8"); + + expect(html).not.toContain(" + + +`; +} + +async function main(): Promise { + const options = parseOpenQuizArguments(process.argv.slice(2)); + + if (options.help) { + console.log(USAGE); + return; + } + + if (options.target === undefined) throw new Error(`Missing the Quiz file.\n\n${USAGE}`); + + const quizPath = resolve(options.target); + const validatorPath = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../create-quiz/scripts/validate-quiz.mjs", + ); + + if (!existsSync(validatorPath)) { + console.error( + "The quizbun skill needs the create-quiz skill. Install it with `npx skills add a-dev/quizbun --skill create-quiz`.", + ); + process.exitCode = EXIT_MISSING_CREATE_QUIZ; + return; + } + + const validation = spawnSync(process.execPath, [validatorPath, quizPath], { encoding: "utf8" }); + + if (validation.status !== 0) { + process.stdout.write(validation.stdout); + process.stderr.write(validation.stderr); + process.exitCode = validation.status ?? EXIT_INVALID_QUIZ; + return; + } + + const text = readFileSync(quizPath, "utf8"); + const quiz: unknown = JSON.parse(text); + const limitReport = checkImportSize(text) ?? checkImportQuestionCount(quiz); + + if (limitReport !== undefined) { + console.error(limitReport); + process.exitCode = EXIT_OVER_LIMITS; + return; + } + + const payload = await encodeQuizLink(JSON.stringify(quiz)); + + if (payload.length > MAX_QUIZ_LINK_LENGTH) { + console.error( + `The link would be ${payload.length} characters; browsers open links up to ${MAX_QUIZ_LINK_LENGTH}. Import the \`.json\` file instead.`, + ); + process.exitCode = EXIT_OVER_LIMITS; + return; + } + + const { id, title } = quiz as { id: string; title: string }; + const url = buildQuizUrl(options.base, payload); + const launcherPath = join(resolve(options.out ?? dirname(quizPath)), `${id}.open.html`); + + writeFileSync(launcherPath, buildLauncherHtml(title, url)); + console.log(`Wrote ${launcherPath}`); + + if (options.printUrl) console.log(url); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = EXIT_INVALID_QUIZ; +} diff --git a/skills/create-quiz/SKILL.md b/skills/create-quiz/SKILL.md index cb828e2..5c97594 100644 --- a/skills/create-quiz/SKILL.md +++ b/skills/create-quiz/SKILL.md @@ -2,10 +2,10 @@ name: create-quiz description: > Create, validate, repair, or review one explanation-first Quiz as strict JSON - in the Quiz Object Standard v1. Use when the user explicitly invokes this - skill to author Quizbun-compatible quiz content. Topic- and repo-agnostic - unless the user asks for a Quiz about the surrounding project. -disable-model-invocation: true + in the Quiz Object Standard v1. Use only when the user explicitly asks for a + Quiz in the Quiz Object Standard or invokes create-quiz, or when the quizbun + skill delegates to it. Never start it for general questions about quizzes, + tests, or learning. --- # Create a Quiz @@ -66,3 +66,5 @@ The machine-readable schema is also available at [references/quiz.v1.schema.json ## Quality review Schema validity is the floor. Before returning the Quiz, verify each answer, make every Explanation teach why the answer is correct, keep distractors plausible, and remove references to Option positions because Renderers may shuffle Options. Check the bundled authoring contract for the complete quality bar. + +To open the finished Quiz in Quizbun, use the `quizbun` skill. diff --git a/skills/quizbun/SKILL.md b/skills/quizbun/SKILL.md new file mode 100644 index 0000000..7f296b5 --- /dev/null +++ b/skills/quizbun/SKILL.md @@ -0,0 +1,41 @@ +--- +name: quizbun +description: > + Open a Quiz Object Standard Quiz in Quizbun (quizbun.fyi): check Quizbun's + Import limits and make a launcher file that opens the Quiz on the Import page. + Creates the Quiz first through the create-quiz skill when no file is given. + Use only when the user invokes quizbun or explicitly asks to open a Quiz in Quizbun. +compatibility: Requires the create-quiz skill (a-dev/quizbun) and Node.js >= 22.12. +--- + +# Open a Quiz in Quizbun + +This skill turns a Quiz JSON file into a launcher file. Opening the launcher in a browser loads the Quiz into Quizbun's Import page, where the user previews it and saves it to their Library. The skill never saves anything for the user. + +## Steps + +1. **Check that `create-quiz` is installed.** If the `create-quiz` skill is not available, stop. Tell the user to run `npx skills add a-dev/quizbun --skill create-quiz`, and do nothing else. +2. **Get a Quiz file.** Use the file the user gave. Otherwise, follow the `create-quiz` skill to create one. Tell it the Quizbun limit up front: at most 200 Questions. Suggest splitting larger topics into several Quizzes. +3. **Open it.** Run the script, then give the user the path of the launcher file it prints: + + ```sh + node /scripts/open-quiz.mjs quiz.json + ``` + + Options: `--out ` sets the launcher's directory (default: next to the Quiz file), and `--base ` sets the Quizbun address (default `https://quizbun.fyi/`, for example `http://localhost:4398/` when testing a local build). + +4. **On exit code 2**, explain the Quizbun limit and offer to split the Quiz into parts by subtopic, using `create-quiz` for the parts. The `.json` file stays valid and can still be used with other Renderers. +5. **Never print, retype or summarize the link.** It holds the whole Quiz and is far too long to copy by hand. Give the user the launcher file instead. `--print-url` exists for scripts only. + +## Exit codes + +| Code | Meaning | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | The launcher file was written. | +| 1 | The Quiz is invalid. The script prints the `create-quiz` validator's report unchanged. Fix the Quiz with `create-quiz` and run again. | +| 2 | The Quiz is over Quizbun's limits (200 Questions or 1 MB of JSON), or its link would be too long. | +| 3 | The `create-quiz` skill is missing. The script prints the install command. | + +## Privacy + +The Quiz travels in the link's fragment, which the browser never sends to a server. The launcher file and the browser history do hold the whole Quiz until the user deletes them. diff --git a/skills/quizbun/package.json b/skills/quizbun/package.json new file mode 100644 index 0000000..27e62a3 --- /dev/null +++ b/skills/quizbun/package.json @@ -0,0 +1,11 @@ +{ + "name": "quizbun-skill", + "private": true, + "type": "module", + "scripts": { + "open": "node scripts/open-quiz.mjs" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/skills/quizbun/scripts/open-quiz.mjs b/skills/quizbun/scripts/open-quiz.mjs new file mode 100644 index 0000000..4492da8 --- /dev/null +++ b/skills/quizbun/scripts/open-quiz.mjs @@ -0,0 +1,228 @@ +// Generated by scripts/generate-quizbun-skill.ts. Do not edit by hand. +// scripts/quizbun-skill-cli.ts +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join as join2, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// src/shared/lib/quiz/import-limits.ts +var MAX_IMPORT_QUESTIONS = 200; +var MAX_IMPORT_BYTES = 1048576; +var LIMIT_PREAMBLE = "This is a Quizbun limit, not a Standard error."; +function formatImportSizeReport(actualBytes) { + const size = actualBytes === undefined ? "over 1 MB" : formatMegabytes(actualBytes); + return formatLimitReport({ + path: "root", + problem: `the Quiz JSON is ${size}; Quizbun imports up to ${formatMegabytes(MAX_IMPORT_BYTES)}`, + fix: "split the Quiz into smaller Quizzes or shorten long Explanations." + }); +} +function formatImportQuestionCountReport(count) { + return formatLimitReport({ + path: "questions", + problem: `${count} Questions; Quizbun imports up to ${MAX_IMPORT_QUESTIONS}`, + fix: "split the Quiz into two or more Quizzes, for example by subtopic." + }); +} +function checkImportSize(text) { + if (text.length > MAX_IMPORT_BYTES) + return formatImportSizeReport(utf8ByteLength(text)); + if (text.length * 3 <= MAX_IMPORT_BYTES) + return; + const bytes = utf8ByteLength(text); + return bytes > MAX_IMPORT_BYTES ? formatImportSizeReport(bytes) : undefined; +} +function checkImportQuestionCount(quiz) { + if (typeof quiz !== "object" || quiz === null || !("questions" in quiz)) + return; + const { questions } = quiz; + if (!Array.isArray(questions) || questions.length <= MAX_IMPORT_QUESTIONS) + return; + return formatImportQuestionCountReport(questions.length); +} +function utf8ByteLength(text) { + return new TextEncoder().encode(text).byteLength; +} +function formatLimitReport(report) { + return [ + LIMIT_PREAMBLE, + "", + `1. Path: \`${report.path}\``, + ` Problem: ${report.problem}`, + ` Fix: ${report.fix}` + ].join(` +`); +} +function formatMegabytes(bytes) { + const megabytes = bytes / 1048576; + return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)} MB`; +} + +// src/shared/lib/quiz-link/quiz-link.ts +var QUIZ_LINK_PARAM = "qos"; +var QUIZ_LINK_VERSION = 1; +var MAX_QUIZ_LINK_LENGTH = 1048576; +var BASE64_CHUNK_SIZE = 32768; +async function encodeQuizLink(json) { + const compressed = await collect(new Blob([new TextEncoder().encode(json)]).stream().pipeThrough(new CompressionStream("deflate"))); + return `${QUIZ_LINK_VERSION}.${toBase64Url(compressed)}`; +} +async function collect(stream) { + const reader = stream.getReader(); + const chunks = []; + let total = 0; + for (;; ) { + const { done, value } = await reader.read(); + if (done) + return join(chunks, total); + chunks.push(value); + total += value.byteLength; + } +} +function join(chunks, total) { + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} +function toBase64Url(bytes) { + let binary = ""; + for (let index = 0;index < bytes.length; index += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(index, index + BASE64_CHUNK_SIZE)); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +// scripts/quizbun-skill-cli.ts +var EXIT_INVALID_QUIZ = 1; +var EXIT_OVER_LIMITS = 2; +var EXIT_MISSING_CREATE_QUIZ = 3; +var DEFAULT_BASE = "https://quizbun.fyi/"; +var USAGE = `Usage: + open-quiz [--out ] [--base ] [--print-url] + +Options: + --out Directory for the launcher file (default: next to the Quiz file) + --base Quizbun site address (default: ${DEFAULT_BASE}) + --print-url Print the raw link. For scripts only; never paste it into a chat. + -h, --help Show this help + +Exit codes: + 0 The launcher file was written + 1 The Quiz is invalid; the create-quiz validator's report is printed + 2 The Quiz is over Quizbun's Import limits or the link limit + 3 The create-quiz skill is missing`; +function parseOpenQuizArguments(args) { + const options = { base: DEFAULT_BASE, help: false, printUrl: false }; + for (let index = 0;index < args.length; index += 1) { + const arg = args[index] ?? ""; + if (arg === "-h" || arg === "--help") + options.help = true; + else if (arg === "--print-url") + options.printUrl = true; + else if (arg === "--out" || arg === "--base") { + const value = args[index + 1]; + if (value === undefined) + throw new Error(`${arg} needs a value. + +${USAGE}`); + if (arg === "--out") + options.out = value; + else + options.base = value; + index += 1; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown option ${arg}. + +${USAGE}`); + } else if (options.target === undefined) { + options.target = arg; + } else { + throw new Error(`Expected one Quiz file, got more than one. + +${USAGE}`); + } + } + return options; +} +function buildQuizUrl(base, payload) { + const root = base.endsWith("/") ? base : `${base}/`; + return `${root}import/#${QUIZ_LINK_PARAM}=${payload}`; +} +function escapeHtml(text) { + return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} +function buildLauncherHtml(title, url) { + const safeTitle = escapeHtml(title); + const scriptUrl = JSON.stringify(url).replaceAll("<", "\\u003c"); + return ` + + + + +${safeTitle} + + +

Opening ${safeTitle} in Quizbun.

+

Open the Quiz in Quizbun if nothing happens.

+ + + + +`; +} +async function main() { + const options = parseOpenQuizArguments(process.argv.slice(2)); + if (options.help) { + console.log(USAGE); + return; + } + if (options.target === undefined) + throw new Error(`Missing the Quiz file. + +${USAGE}`); + const quizPath = resolve(options.target); + const validatorPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../create-quiz/scripts/validate-quiz.mjs"); + if (!existsSync(validatorPath)) { + console.error("The quizbun skill needs the create-quiz skill. Install it with `npx skills add a-dev/quizbun --skill create-quiz`."); + process.exitCode = EXIT_MISSING_CREATE_QUIZ; + return; + } + const validation = spawnSync(process.execPath, [validatorPath, quizPath], { encoding: "utf8" }); + if (validation.status !== 0) { + process.stdout.write(validation.stdout); + process.stderr.write(validation.stderr); + process.exitCode = validation.status ?? EXIT_INVALID_QUIZ; + return; + } + const text = readFileSync(quizPath, "utf8"); + const quiz = JSON.parse(text); + const limitReport = checkImportSize(text) ?? checkImportQuestionCount(quiz); + if (limitReport !== undefined) { + console.error(limitReport); + process.exitCode = EXIT_OVER_LIMITS; + return; + } + const payload = await encodeQuizLink(JSON.stringify(quiz)); + if (payload.length > MAX_QUIZ_LINK_LENGTH) { + console.error(`The link would be ${payload.length} characters; browsers open links up to ${MAX_QUIZ_LINK_LENGTH}. Import the \`.json\` file instead.`); + process.exitCode = EXIT_OVER_LIMITS; + return; + } + const { id, title } = quiz; + const url = buildQuizUrl(options.base, payload); + const launcherPath = join2(resolve(options.out ?? dirname(quizPath)), `${id}.open.html`); + writeFileSync(launcherPath, buildLauncherHtml(title, url)); + console.log(`Wrote ${launcherPath}`); + if (options.printUrl) + console.log(url); +} +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = EXIT_INVALID_QUIZ; +} diff --git a/src/features/import-quiz/ui/import-quiz-form.tsx b/src/features/import-quiz/ui/import-quiz-form.tsx index fe77d4b..60a0005 100644 --- a/src/features/import-quiz/ui/import-quiz-form.tsx +++ b/src/features/import-quiz/ui/import-quiz-form.tsx @@ -105,7 +105,8 @@ export function ImportQuizForm() {