@numueg/theme-sdk 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/validation/index.ts","../src/verify/index.ts"],"names":[],"mappings":"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","file":"verify.cjs","sourcesContent":["/**\n * Shared theme-contract validators — the single source of truth.\n *\n * This module is PURE: it imports only types (erased at compile time) and\n * has no React/DOM/fs dependencies, so the same logic runs in the CLI, the\n * Vite plugin, and (re-implemented) the backend. Callers parse JSON files\n * themselves and pass plain objects in; these functions never touch disk.\n *\n * The goal is that \"builds green\" implies \"matches the contract\": every\n * place a theme can drift from what the storefront expects is checked here\n * once, instead of being re-derived (and drifting) in each tool.\n */\n\nimport type { SectionSchema, SettingDefinition } from \"../types/theme\";\n\n/**\n * The theme CONTRACT version — bumped only on a breaking change to what a\n * built theme must look like (manifest shape, required emitted artifacts,\n * section/setting semantics). The plugin stamps this into the built\n * manifest/import-map; the host refuses bundles whose contract version it\n * doesn't support. Distinct from the npm SDK version (`SDK_VERSION`), which\n * moves on every release including non-breaking ones.\n */\nexport const THEME_CONTRACT_VERSION = 1;\n\n/** npm version of this SDK build (inlined by tsup `define`). Informational. */\ndeclare const __SDK_VERSION__: string | undefined;\nexport const SDK_VERSION: string =\n typeof __SDK_VERSION__ === \"string\" ? __SDK_VERSION__ : \"0.0.0\";\n\nexport interface ValidationIssue {\n level: \"error\" | \"warning\";\n /** Stable machine code, e.g. \"manifest.version.invalid\". */\n code: string;\n message: string;\n /** Dotted path or file hint, e.g. \"presets.templates.home\" or \"hero.json\". */\n path?: string;\n}\n\nexport interface ValidationResult {\n /** True when there are zero error-level issues. */\n valid: boolean;\n issues: ValidationIssue[];\n}\n\n/**\n * Page templates the storefront routes to. A theme that omits a REQUIRED\n * template still works (the host falls back to its built-in), so a miss is\n * a warning, not an error — but tooling can elevate it with --strict.\n */\nexport const REQUIRED_TEMPLATES = [\n \"home\",\n \"product\",\n \"collection\",\n \"cart\",\n \"page\",\n \"search\",\n \"404\",\n] as const;\n\nexport const KNOWN_TEMPLATES: ReadonlySet<string> = new Set([\n ...REQUIRED_TEMPLATES,\n \"blog\",\n \"article\",\n \"policies\",\n \"password\",\n \"account\",\n \"checkout\",\n]);\n\n/** Setting types the V3 customizer renders. Unknown types warn (forward-compat). */\nexport const KNOWN_SETTING_TYPES: ReadonlySet<string> = new Set([\n \"text\", \"textarea\", \"richtext\", \"number\", \"range\", \"color\", \"checkbox\",\n \"select\", \"radio\", \"font\", \"image_picker\", \"url\", \"product\", \"product_list\",\n \"collection\", \"collection_list\", \"header\", \"paragraph\", \"html\", \"date\",\n \"time\", \"video_picker\", \"color_scheme\", \"page_picker\", \"blog_picker\",\n \"link_list_picker\", \"variant_picker\", \"file_upload\", \"icon_picker\", \"icon\",\n]);\n\n/** Setting types that are presentational (no value) — `id`/`label` optional. */\nconst PRESENTATIONAL_SETTING_TYPES: ReadonlySet<string> = new Set([\n \"header\", \"paragraph\", \"html\",\n]);\n\n// ── Primitives ────────────────────────────────────────────────────\n\nconst SECTION_TYPE_RE = /^[a-z][a-z0-9_-]*$/;\nconst THEME_ID_RE = /^[a-z0-9][a-z0-9_-]*[a-z0-9]$/;\n// Pragmatic semver (major.minor.patch + optional -prerelease / +build).\nconst SEMVER_RE =\n /^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-.]+)?(?:\\+[0-9A-Za-z-.]+)?$/;\n\nfunction isObject(v: unknown): v is Record<string, unknown> {\n return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\nfunction isNonEmptyString(v: unknown): v is string {\n return typeof v === \"string\" && v.trim().length > 0;\n}\n\nclass IssueBag {\n readonly issues: ValidationIssue[] = [];\n err(code: string, message: string, path?: string) {\n this.issues.push({ level: \"error\", code, message, path });\n }\n warn(code: string, message: string, path?: string) {\n this.issues.push({ level: \"warning\", code, message, path });\n }\n result(): ValidationResult {\n return {\n valid: !this.issues.some((i) => i.level === \"error\"),\n issues: this.issues,\n };\n }\n}\n\n/** Pull the section types referenced by a manifest's presets. */\nfunction collectPresetSectionTypes(manifest: Record<string, unknown>): Set<string> {\n const types = new Set<string>();\n const presets = manifest.presets;\n if (!isObject(presets)) return types;\n const buckets = [presets.templates, presets.section_groups];\n for (const bucket of buckets) {\n if (!isObject(bucket)) continue;\n for (const entry of Object.values(bucket)) {\n if (!isObject(entry)) continue;\n const sections = entry.sections;\n // sections may be an array of instances OR a map id -> instance.\n const instances = Array.isArray(sections)\n ? sections\n : isObject(sections)\n ? Object.values(sections)\n : [];\n for (const inst of instances) {\n if (isObject(inst) && isNonEmptyString(inst.type)) types.add(inst.type);\n }\n }\n }\n return types;\n}\n\n// ── Setting / schema validation ───────────────────────────────────\n\nfunction validateSettingInto(\n bag: IssueBag,\n setting: unknown,\n pathPrefix: string,\n seenIds: Set<string>,\n) {\n if (!isObject(setting)) {\n bag.err(\"schema.setting.invalid\", \"Setting must be an object.\", pathPrefix);\n return;\n }\n const type = setting.type;\n if (!isNonEmptyString(type)) {\n bag.err(\"schema.setting.type.missing\", \"Setting is missing a `type`.\", pathPrefix);\n } else if (!KNOWN_SETTING_TYPES.has(type)) {\n bag.warn(\n \"schema.setting.type.unknown\",\n `Unknown setting type \"${type}\" — the customizer will fall back to a text input.`,\n pathPrefix,\n );\n }\n const presentational =\n isNonEmptyString(type) && PRESENTATIONAL_SETTING_TYPES.has(type);\n if (!presentational) {\n if (!isNonEmptyString(setting.id)) {\n bag.err(\"schema.setting.id.missing\", \"Setting is missing an `id`.\", pathPrefix);\n } else {\n if (seenIds.has(setting.id)) {\n bag.err(\n \"schema.setting.id.duplicate\",\n `Duplicate setting id \"${setting.id}\".`,\n pathPrefix,\n );\n }\n seenIds.add(setting.id);\n }\n if (!isNonEmptyString(setting.label)) {\n bag.warn(\"schema.setting.label.missing\", \"Setting has no `label`.\", pathPrefix);\n }\n }\n // select / radio need options.\n if (type === \"select\" || type === \"radio\") {\n const options = setting.options;\n if (!Array.isArray(options) || options.length === 0) {\n bag.err(\n \"schema.setting.options.missing\",\n `\"${type}\" setting \"${String(setting.id)}\" must declare a non-empty \\`options\\` array.`,\n pathPrefix,\n );\n }\n }\n // range needs bounds.\n if (type === \"range\") {\n if (typeof setting.min !== \"number\" || typeof setting.max !== \"number\") {\n bag.err(\n \"schema.setting.range.bounds\",\n `\"range\" setting \"${String(setting.id)}\" must declare numeric \\`min\\` and \\`max\\`.`,\n pathPrefix,\n );\n } else if (setting.min >= setting.max) {\n bag.err(\n \"schema.setting.range.order\",\n `\"range\" setting \"${String(setting.id)}\" has min >= max.`,\n pathPrefix,\n );\n }\n }\n}\n\n/**\n * Validate one section schema (the parsed schemas/sections/<type>.json).\n * When `filenameType` is given, enforces the component-filename = schema-type\n * convention by requiring `schema.type` to equal it.\n */\nexport function validateSectionSchema(\n schema: unknown,\n opts: { filenameType?: string } = {},\n): ValidationResult {\n const bag = new IssueBag();\n const where = opts.filenameType ? `${opts.filenameType}.json` : \"section schema\";\n if (!isObject(schema)) {\n bag.err(\"schema.invalid\", \"Section schema must be a JSON object.\", where);\n return bag.result();\n }\n const type = schema.type;\n if (!isNonEmptyString(type)) {\n bag.err(\"schema.type.missing\", \"Section schema is missing a `type`.\", where);\n } else {\n if (!SECTION_TYPE_RE.test(type)) {\n bag.err(\n \"schema.type.format\",\n `Section type \"${type}\" must match ${SECTION_TYPE_RE} (lowercase, start with a letter).`,\n where,\n );\n }\n if (opts.filenameType && type !== opts.filenameType) {\n bag.err(\n \"schema.type.filename_mismatch\",\n `Schema type \"${type}\" must equal its filename \"${opts.filenameType}\" (component-filename = schema-type convention).`,\n where,\n );\n }\n }\n if (!isNonEmptyString(schema.name)) {\n bag.err(\"schema.name.missing\", \"Section schema is missing a `name`.\", where);\n }\n if (schema.settings === undefined) {\n bag.warn(\"schema.settings.missing\", \"Section schema has no `settings`.\", where);\n } else if (!Array.isArray(schema.settings)) {\n bag.err(\"schema.settings.invalid\", \"`settings` must be an array.\", where);\n } else {\n const seen = new Set<string>();\n schema.settings.forEach((s, i) =>\n validateSettingInto(bag, s, `${where}.settings[${i}]`, seen),\n );\n }\n // Nested block schemas (shallow — type + settings shape).\n if (schema.blocks !== undefined) {\n if (!Array.isArray(schema.blocks)) {\n bag.err(\"schema.blocks.invalid\", \"`blocks` must be an array.\", where);\n } else {\n schema.blocks.forEach((b, i) => {\n if (!isObject(b) || !isNonEmptyString(b.type)) {\n bag.err(\"schema.block.type.missing\", `Block[${i}] is missing a \\`type\\`.`, where);\n return;\n }\n if (!SECTION_TYPE_RE.test(b.type)) {\n bag.err(\n \"schema.block.type.format\",\n `Block type \"${b.type}\" must match ${SECTION_TYPE_RE}.`,\n where,\n );\n }\n if (Array.isArray(b.settings)) {\n const seen = new Set<string>();\n b.settings.forEach((s, j) =>\n validateSettingInto(bag, s, `${where}.blocks[${i}].settings[${j}]`, seen),\n );\n }\n });\n }\n }\n return bag.result();\n}\n\n/**\n * Instance conformance: check a settings object (from a preset or a merchant\n * customization) against its section schema. Unknown keys warn (harmless at\n * runtime — ignored); invalid values (bad select option, out-of-range,\n * wrong primitive) error.\n */\nexport function validateSettingsAgainstSchema(\n settings: unknown,\n schema: Pick<SectionSchema, \"settings\" | \"type\">,\n pathPrefix = \"settings\",\n): ValidationResult {\n const bag = new IssueBag();\n if (!isObject(settings)) {\n bag.err(\"instance.settings.invalid\", \"Section settings must be an object.\", pathPrefix);\n return bag.result();\n }\n const defs = Array.isArray(schema.settings) ? schema.settings : [];\n const byId = new Map<string, SettingDefinition>();\n for (const d of defs) if (isNonEmptyString(d?.id)) byId.set(d.id, d);\n\n for (const [key, value] of Object.entries(settings)) {\n const def = byId.get(key);\n if (!def) {\n bag.warn(\n \"instance.setting.unknown\",\n `Setting \"${key}\" is not declared in the \"${String(schema.type)}\" schema.`,\n `${pathPrefix}.${key}`,\n );\n continue;\n }\n if (value === null || value === undefined) continue;\n const p = `${pathPrefix}.${key}`;\n if ((def.type === \"select\" || def.type === \"radio\") && Array.isArray(def.options)) {\n const allowed = def.options.map((o) => o.value);\n if (typeof value === \"string\" && !allowed.includes(value)) {\n bag.err(\n \"instance.setting.option.invalid\",\n `\"${key}\" = \"${value}\" is not one of: ${allowed.join(\", \")}.`,\n p,\n );\n }\n }\n if (def.type === \"range\" && typeof value === \"number\") {\n if (typeof def.min === \"number\" && value < def.min) {\n bag.err(\"instance.setting.range.under\", `\"${key}\" = ${value} is below min ${def.min}.`, p);\n }\n if (typeof def.max === \"number\" && value > def.max) {\n bag.err(\"instance.setting.range.over\", `\"${key}\" = ${value} is above max ${def.max}.`, p);\n }\n }\n if ((def.type === \"checkbox\") && typeof value !== \"boolean\") {\n bag.warn(\"instance.setting.type.mismatch\", `\"${key}\" should be a boolean.`, p);\n }\n if ((def.type === \"number\" || def.type === \"range\") && typeof value !== \"number\") {\n bag.warn(\"instance.setting.type.mismatch\", `\"${key}\" should be a number.`, p);\n }\n }\n return bag.result();\n}\n\n// ── Manifest validation ───────────────────────────────────────────\n\n/** Core manifest field checks shared by source (theme.json) and built manifests. */\nfunction validateManifestCore(bag: IssueBag, m: Record<string, unknown>) {\n if (!isNonEmptyString(m.id)) {\n bag.err(\"manifest.id.missing\", \"theme.json is missing `id`.\", \"id\");\n } else if (!THEME_ID_RE.test(m.id)) {\n bag.err(\n \"manifest.id.format\",\n `Theme id \"${m.id}\" must be lowercase alphanumeric with dashes/underscores (no leading/trailing separator).`,\n \"id\",\n );\n }\n // name: string or bilingual object { en|default, ... }.\n const name = m.name;\n const nameOk =\n isNonEmptyString(name) ||\n (isObject(name) && Object.values(name).some((v) => isNonEmptyString(v)));\n if (!nameOk) {\n bag.err(\"manifest.name.missing\", \"theme.json is missing a non-empty `name`.\", \"name\");\n }\n if (!isNonEmptyString(m.version)) {\n bag.err(\"manifest.version.missing\", \"theme.json is missing `version`.\", \"version\");\n } else if (!SEMVER_RE.test(m.version)) {\n bag.err(\"manifest.version.invalid\", `Version \"${m.version}\" is not valid semver.`, \"version\");\n }\n if (!isNonEmptyString(m.author)) {\n bag.err(\"manifest.author.missing\", \"theme.json is missing `author`.\", \"author\");\n }\n if (m.min_sdk_version !== undefined && !SEMVER_RE.test(String(m.min_sdk_version))) {\n bag.warn(\"manifest.min_sdk_version.invalid\", \"`min_sdk_version` is not valid semver.\", \"min_sdk_version\");\n }\n}\n\n/**\n * Validate a source theme manifest (parsed theme.json).\n *\n * `ctx.sectionTypes` — the set of section types the theme actually ships\n * (from schemas/sections/*.json). When provided, every section type\n * referenced by a preset must be present, and missing required templates\n * are surfaced as warnings.\n */\nexport function validateManifest(\n manifest: unknown,\n ctx: { sectionTypes?: ReadonlySet<string> } = {},\n): ValidationResult {\n const bag = new IssueBag();\n if (!isObject(manifest)) {\n bag.err(\"manifest.invalid\", \"theme.json must be a JSON object.\", \"theme.json\");\n return bag.result();\n }\n validateManifestCore(bag, manifest);\n\n const presets = manifest.presets;\n if (!isObject(presets) || Object.keys(presets).length === 0) {\n bag.warn(\"manifest.presets.empty\", \"theme.json has no presets — merchants start with an empty page.\", \"presets\");\n } else {\n // Every referenced section type must resolve to a shipped schema.\n if (ctx.sectionTypes) {\n for (const type of collectPresetSectionTypes(manifest)) {\n if (!ctx.sectionTypes.has(type)) {\n bag.err(\n \"manifest.preset.unknown_section\",\n `Preset references section type \"${type}\" with no schemas/sections/${type}.json.`,\n \"presets\",\n );\n }\n }\n }\n // Required-template coverage (warning).\n const templates = isObject(presets.templates) ? presets.templates : {};\n for (const t of REQUIRED_TEMPLATES) {\n if (!(t in templates)) {\n bag.warn(\n \"manifest.template.missing\",\n `No preset for required template \"${t}\" — the storefront will fall back to its built-in.`,\n `presets.templates.${t}`,\n );\n }\n }\n }\n return bag.result();\n}\n\n/**\n * Server-side gate: validate the artifacts the plugin emits — dist/manifest.json\n * (the embedded built manifest, including section_schemas) and dist/import-map.json\n * (the federation + contract-version descriptor). This is what the backend should\n * run on upload/submit, since it can't run the source build.\n *\n * `opts.hostContractVersion` — when given, refuses bundles whose\n * `contract_version` the host doesn't support.\n */\nexport function validateBuiltManifest(\n builtManifest: unknown,\n importMap?: unknown,\n opts: { hostContractVersion?: number } = {},\n): ValidationResult {\n const bag = new IssueBag();\n if (!isObject(builtManifest)) {\n bag.err(\"built.invalid\", \"manifest.json must be a JSON object.\", \"manifest.json\");\n return bag.result();\n }\n validateManifestCore(bag, builtManifest);\n\n // section_schemas must cover every type the presets reference.\n const schemas = builtManifest.section_schemas;\n const shipped = new Set<string>(isObject(schemas) ? Object.keys(schemas) : []);\n if (!isObject(schemas)) {\n bag.warn(\"built.section_schemas.missing\", \"manifest.json has no `section_schemas`.\", \"section_schemas\");\n }\n for (const type of collectPresetSectionTypes(builtManifest)) {\n if (!shipped.has(type)) {\n bag.err(\n \"built.preset.unknown_section\",\n `Preset references section type \"${type}\" not present in section_schemas.`,\n \"presets\",\n );\n }\n }\n // Each shipped schema must itself be valid.\n if (isObject(schemas)) {\n for (const [type, schema] of Object.entries(schemas)) {\n const r = validateSectionSchema(schema, { filenameType: type });\n for (const issue of r.issues) bag.issues.push(issue);\n }\n }\n if (!isNonEmptyString(builtManifest.plugin_version)) {\n bag.warn(\"built.plugin_version.missing\", \"manifest.json has no `plugin_version`.\", \"plugin_version\");\n }\n\n // Contract-version compatibility from the import map.\n if (importMap !== undefined) {\n if (!isObject(importMap)) {\n bag.err(\"importmap.invalid\", \"import-map.json must be a JSON object.\", \"import-map.json\");\n } else {\n const cv = importMap.contract_version;\n if (typeof cv !== \"number\") {\n bag.warn(\n \"importmap.contract_version.missing\",\n \"import-map.json has no numeric `contract_version` — built by an older plugin.\",\n \"contract_version\",\n );\n } else if (\n typeof opts.hostContractVersion === \"number\" &&\n cv > opts.hostContractVersion\n ) {\n bag.err(\n \"importmap.contract_version.incompatible\",\n `Theme built for contract v${cv}; this platform supports up to v${opts.hostContractVersion}.`,\n \"contract_version\",\n );\n }\n }\n }\n return bag.result();\n}\n\n/** Convenience: merge several results into one. */\nexport function mergeResults(...results: ValidationResult[]): ValidationResult {\n const issues = results.flatMap((r) => r.issues);\n return { valid: !issues.some((i) => i.level === \"error\"), issues };\n}\n","/**\n * Behavioral render-verification harness — theme-enforcement Phase 2.\n *\n * Phase 1 proves a theme MATCHES the contract (structure/manifest/schemas).\n * This proves it TRULY WORKS: it server-renders the built theme against\n * representative fixture data for every required template and fails on any\n * template that throws or renders empty — catching the bugs the structural\n * gate can't (a section that reads `settings.x.y` when x is undefined, an SDK\n * hook used outside its provider, a template that produces no output).\n *\n * This module is React-free at import time: it imports only types and pulls\n * `react-dom/server` dynamically at call time. It runs in Node (the CLI's\n * `verify` command / the build worker) against the theme's `dist/theme.server.js`\n * `createApp(ctx)` export. The theme's own React/SDK (its node_modules) satisfy\n * the bundle's externals.\n */\n\nimport type { ReactElement } from \"react\";\nimport type { Store, Product, Collection, Cart, Customer } from \"../types/entities\";\nimport type { ThemeSettingsV3 } from \"../types/theme\";\nimport type { ThemeMountContext } from \"../mount\";\nimport { REQUIRED_TEMPLATES } from \"../validation\";\n\nexport { REQUIRED_TEMPLATES };\n\n// ── Fixtures ──────────────────────────────────────────────────────\n\nexport function makeFixtureStore(overrides: Partial<Store> = {}): Store {\n return {\n id: \"00000000-0000-0000-0000-0000000000fx\",\n name: \"Fixture Store\",\n slug: \"fixture-store\",\n subdomain: \"fixture-store\",\n currency: \"EGP\",\n default_language: \"en\",\n use_nextjs_storefront: true,\n logo_url: \"https://cdn.example.com/logo.png\",\n description: \"A fixture store for render verification.\",\n settings: {},\n ...overrides,\n };\n}\n\nexport function makeFixtureProduct(i = 1): Product {\n const id = `00000000-0000-0000-0000-00000000p${String(i).padStart(3, \"0\")}`;\n return {\n id,\n name: `Fixture Product ${i}`,\n slug: `fixture-product-${i}`,\n description: \"A representative product for render verification.\",\n price: 19900,\n compare_at_price: 24900,\n currency: \"EGP\",\n images: [\n { id: `${id}-img`, url: \"https://cdn.example.com/p.jpg\", alt: \"Product\", position: 0 },\n ],\n options: [{ name: \"Size\", position: 0, values: [\"S\", \"M\", \"L\"] }],\n variants: [\n {\n id: `${id}-v1`,\n position: 0,\n option_values: { Size: \"M\" },\n price: 19900,\n compare_at_price: 24900,\n sku: \"FX-M\",\n inventory_quantity: 25,\n is_in_stock: true,\n },\n ],\n category: \"Fixtures\",\n tags: [\"fixture\"],\n in_stock: true,\n attributes: {},\n };\n}\n\nexport function makeFixtureCollection(): Collection {\n const products = [makeFixtureProduct(1), makeFixtureProduct(2), makeFixtureProduct(3)];\n return {\n id: \"00000000-0000-0000-0000-00000000c001\",\n name: \"Fixture Collection\",\n slug: \"fixture-collection\",\n description: \"A representative collection.\",\n image_url: \"https://cdn.example.com/c.jpg\",\n product_count: products.length,\n products,\n };\n}\n\nexport function makeFixtureCart(): Cart {\n const p = makeFixtureProduct(1);\n return {\n id: \"00000000-0000-0000-0000-00000000cart\",\n items: [\n {\n id: \"ci-1\",\n product_id: p.id,\n variant_id: p.variants[0].id,\n name: p.name,\n image_url: p.images[0]?.url,\n price: p.price,\n quantity: 2,\n variant_name: \"M\",\n },\n ],\n subtotal: p.price * 2,\n total: p.price * 2,\n currency: \"EGP\",\n };\n}\n\nexport function makeFixtureCustomer(): Customer {\n return {\n id: \"00000000-0000-0000-0000-0000000cust1\",\n email: \"fixture@example.com\",\n first_name: \"Fixture\",\n last_name: \"Buyer\",\n phone: \"+201000000000\",\n orders_count: 3,\n total_spent: 59700,\n };\n}\n\nfunction fixtureThemeSettings(storeId: string): ThemeSettingsV3 {\n // Empty templates → demo mode → the theme renders its own bundled presets\n // (from theme.json), which is exactly what we want to exercise.\n return {\n schema_version: 3,\n theme_id: storeId,\n global_settings: {},\n templates: {},\n section_groups: {},\n } as ThemeSettingsV3;\n}\n\n/**\n * Build a complete ThemeMountContext for a template. `page.data` is populated\n * generously (products + collections + a single product + collection) so a\n * section's hooks always have data regardless of which template reads what.\n */\nexport function makeFixtureContext(\n template: string,\n overrides: Partial<ThemeMountContext> = {},\n): ThemeMountContext {\n const store = makeFixtureStore();\n const product = makeFixtureProduct(1);\n const products = [product, makeFixtureProduct(2), makeFixtureProduct(3)];\n const collection = makeFixtureCollection();\n return {\n storeData: store,\n currentTemplate: template,\n page: {\n type: template,\n handle: `${template}-fixture`,\n data: { products, collections: [collection], product, collection },\n },\n themeSettings: fixtureThemeSettings(store.id),\n initialCart: makeFixtureCart(),\n customer: makeFixtureCustomer(),\n locale: \"en\",\n translations: {},\n navigation: {},\n demo: true,\n ...overrides,\n };\n}\n\n// ── Harness ───────────────────────────────────────────────────────\n\nexport interface TemplateRenderResult {\n template: string;\n ok: boolean;\n /** Trimmed HTML length produced by the SSR render. */\n htmlLength: number;\n /** Failure reason (throw stack/message, or \"empty output\"). */\n error?: string;\n}\n\nexport interface VerifyRenderResult {\n ok: boolean;\n results: TemplateRenderResult[];\n}\n\n/** The shape we need from a theme's built server bundle. */\nexport interface ThemeServerModule {\n createApp?: (ctx: ThemeMountContext) => ReactElement;\n}\n\nexport interface VerifyRenderOptions {\n /** Templates to render. Defaults to REQUIRED_TEMPLATES. */\n templates?: readonly string[];\n /** Locale to render under (e.g. \"ar\" to catch RTL/translation crashes). */\n locale?: string;\n}\n\n/**\n * Server-render every requested template through the theme's `createApp` and\n * report which ones throw or render empty. Never throws — a render crash is\n * captured as a failed result. `ok` is true only when every template renders\n * non-empty HTML without error.\n */\nexport async function verifyThemeRender(\n serverModule: ThemeServerModule | null | undefined,\n options: VerifyRenderOptions = {},\n): Promise<VerifyRenderResult> {\n const templates = options.templates ?? REQUIRED_TEMPLATES;\n\n if (!serverModule || typeof serverModule.createApp !== \"function\") {\n return {\n ok: false,\n results: [\n {\n template: \"*\",\n ok: false,\n htmlLength: 0,\n error:\n \"Theme bundle does not export `createApp` — it cannot be server-rendered. \" +\n \"Export it via defineThemeEntry() (SDK >= 0.3) and build with federate:true.\",\n },\n ],\n };\n }\n\n // Pulled dynamically so this module stays React-free at import time; resolved\n // from the host/theme node_modules at call time.\n const { renderToStaticMarkup } = (await import(\"react-dom/server\")) as {\n renderToStaticMarkup: (el: ReactElement) => string;\n };\n\n const results: TemplateRenderResult[] = [];\n for (const template of templates) {\n try {\n const ctx = makeFixtureContext(\n template,\n options.locale ? { locale: options.locale } : {},\n );\n const el = serverModule.createApp(ctx);\n const html = renderToStaticMarkup(el);\n const len = (html || \"\").trim().length;\n results.push({\n template,\n ok: len > 0,\n htmlLength: len,\n error: len > 0 ? undefined : \"rendered empty output\",\n });\n } catch (err) {\n results.push({\n template,\n ok: false,\n htmlLength: 0,\n error: err instanceof Error ? err.stack || err.message : String(err),\n });\n }\n }\n\n return { ok: results.every((r) => r.ok), results };\n}\n"]}
@@ -0,0 +1,65 @@
1
+ import { ReactElement } from 'react';
2
+ import { C as Cart, b as Collection, c as Customer, e as Product, i as Store } from './entities-6MGANln7.mjs';
3
+ import { T as ThemeMountContext } from './mount-DH9dz3dq.mjs';
4
+ export { REQUIRED_TEMPLATES } from './validation.mjs';
5
+ import './theme-D0QybTQS.mjs';
6
+
7
+ /**
8
+ * Behavioral render-verification harness — theme-enforcement Phase 2.
9
+ *
10
+ * Phase 1 proves a theme MATCHES the contract (structure/manifest/schemas).
11
+ * This proves it TRULY WORKS: it server-renders the built theme against
12
+ * representative fixture data for every required template and fails on any
13
+ * template that throws or renders empty — catching the bugs the structural
14
+ * gate can't (a section that reads `settings.x.y` when x is undefined, an SDK
15
+ * hook used outside its provider, a template that produces no output).
16
+ *
17
+ * This module is React-free at import time: it imports only types and pulls
18
+ * `react-dom/server` dynamically at call time. It runs in Node (the CLI's
19
+ * `verify` command / the build worker) against the theme's `dist/theme.server.js`
20
+ * `createApp(ctx)` export. The theme's own React/SDK (its node_modules) satisfy
21
+ * the bundle's externals.
22
+ */
23
+
24
+ declare function makeFixtureStore(overrides?: Partial<Store>): Store;
25
+ declare function makeFixtureProduct(i?: number): Product;
26
+ declare function makeFixtureCollection(): Collection;
27
+ declare function makeFixtureCart(): Cart;
28
+ declare function makeFixtureCustomer(): Customer;
29
+ /**
30
+ * Build a complete ThemeMountContext for a template. `page.data` is populated
31
+ * generously (products + collections + a single product + collection) so a
32
+ * section's hooks always have data regardless of which template reads what.
33
+ */
34
+ declare function makeFixtureContext(template: string, overrides?: Partial<ThemeMountContext>): ThemeMountContext;
35
+ interface TemplateRenderResult {
36
+ template: string;
37
+ ok: boolean;
38
+ /** Trimmed HTML length produced by the SSR render. */
39
+ htmlLength: number;
40
+ /** Failure reason (throw stack/message, or "empty output"). */
41
+ error?: string;
42
+ }
43
+ interface VerifyRenderResult {
44
+ ok: boolean;
45
+ results: TemplateRenderResult[];
46
+ }
47
+ /** The shape we need from a theme's built server bundle. */
48
+ interface ThemeServerModule {
49
+ createApp?: (ctx: ThemeMountContext) => ReactElement;
50
+ }
51
+ interface VerifyRenderOptions {
52
+ /** Templates to render. Defaults to REQUIRED_TEMPLATES. */
53
+ templates?: readonly string[];
54
+ /** Locale to render under (e.g. "ar" to catch RTL/translation crashes). */
55
+ locale?: string;
56
+ }
57
+ /**
58
+ * Server-render every requested template through the theme's `createApp` and
59
+ * report which ones throw or render empty. Never throws — a render crash is
60
+ * captured as a failed result. `ok` is true only when every template renders
61
+ * non-empty HTML without error.
62
+ */
63
+ declare function verifyThemeRender(serverModule: ThemeServerModule | null | undefined, options?: VerifyRenderOptions): Promise<VerifyRenderResult>;
64
+
65
+ export { type TemplateRenderResult, type ThemeServerModule, type VerifyRenderOptions, type VerifyRenderResult, makeFixtureCart, makeFixtureCollection, makeFixtureContext, makeFixtureCustomer, makeFixtureProduct, makeFixtureStore, verifyThemeRender };
@@ -0,0 +1,65 @@
1
+ import { ReactElement } from 'react';
2
+ import { C as Cart, b as Collection, c as Customer, e as Product, i as Store } from './entities-6MGANln7.js';
3
+ import { T as ThemeMountContext } from './mount-BN2mz_wx.js';
4
+ export { REQUIRED_TEMPLATES } from './validation.js';
5
+ import './theme-D0QybTQS.js';
6
+
7
+ /**
8
+ * Behavioral render-verification harness — theme-enforcement Phase 2.
9
+ *
10
+ * Phase 1 proves a theme MATCHES the contract (structure/manifest/schemas).
11
+ * This proves it TRULY WORKS: it server-renders the built theme against
12
+ * representative fixture data for every required template and fails on any
13
+ * template that throws or renders empty — catching the bugs the structural
14
+ * gate can't (a section that reads `settings.x.y` when x is undefined, an SDK
15
+ * hook used outside its provider, a template that produces no output).
16
+ *
17
+ * This module is React-free at import time: it imports only types and pulls
18
+ * `react-dom/server` dynamically at call time. It runs in Node (the CLI's
19
+ * `verify` command / the build worker) against the theme's `dist/theme.server.js`
20
+ * `createApp(ctx)` export. The theme's own React/SDK (its node_modules) satisfy
21
+ * the bundle's externals.
22
+ */
23
+
24
+ declare function makeFixtureStore(overrides?: Partial<Store>): Store;
25
+ declare function makeFixtureProduct(i?: number): Product;
26
+ declare function makeFixtureCollection(): Collection;
27
+ declare function makeFixtureCart(): Cart;
28
+ declare function makeFixtureCustomer(): Customer;
29
+ /**
30
+ * Build a complete ThemeMountContext for a template. `page.data` is populated
31
+ * generously (products + collections + a single product + collection) so a
32
+ * section's hooks always have data regardless of which template reads what.
33
+ */
34
+ declare function makeFixtureContext(template: string, overrides?: Partial<ThemeMountContext>): ThemeMountContext;
35
+ interface TemplateRenderResult {
36
+ template: string;
37
+ ok: boolean;
38
+ /** Trimmed HTML length produced by the SSR render. */
39
+ htmlLength: number;
40
+ /** Failure reason (throw stack/message, or "empty output"). */
41
+ error?: string;
42
+ }
43
+ interface VerifyRenderResult {
44
+ ok: boolean;
45
+ results: TemplateRenderResult[];
46
+ }
47
+ /** The shape we need from a theme's built server bundle. */
48
+ interface ThemeServerModule {
49
+ createApp?: (ctx: ThemeMountContext) => ReactElement;
50
+ }
51
+ interface VerifyRenderOptions {
52
+ /** Templates to render. Defaults to REQUIRED_TEMPLATES. */
53
+ templates?: readonly string[];
54
+ /** Locale to render under (e.g. "ar" to catch RTL/translation crashes). */
55
+ locale?: string;
56
+ }
57
+ /**
58
+ * Server-render every requested template through the theme's `createApp` and
59
+ * report which ones throw or render empty. Never throws — a render crash is
60
+ * captured as a failed result. `ok` is true only when every template renders
61
+ * non-empty HTML without error.
62
+ */
63
+ declare function verifyThemeRender(serverModule: ThemeServerModule | null | undefined, options?: VerifyRenderOptions): Promise<VerifyRenderResult>;
64
+
65
+ export { type TemplateRenderResult, type ThemeServerModule, type VerifyRenderOptions, type VerifyRenderResult, makeFixtureCart, makeFixtureCollection, makeFixtureContext, makeFixtureCustomer, makeFixtureProduct, makeFixtureStore, verifyThemeRender };
@@ -0,0 +1,191 @@
1
+ // src/validation/index.ts
2
+ var REQUIRED_TEMPLATES = [
3
+ "home",
4
+ "product",
5
+ "collection",
6
+ "cart",
7
+ "page",
8
+ "search",
9
+ "404"
10
+ ];
11
+ /* @__PURE__ */ new Set([
12
+ ...REQUIRED_TEMPLATES,
13
+ "blog",
14
+ "article",
15
+ "policies",
16
+ "password",
17
+ "account",
18
+ "checkout"
19
+ ]);
20
+
21
+ // src/verify/index.ts
22
+ function makeFixtureStore(overrides = {}) {
23
+ return {
24
+ id: "00000000-0000-0000-0000-0000000000fx",
25
+ name: "Fixture Store",
26
+ slug: "fixture-store",
27
+ subdomain: "fixture-store",
28
+ currency: "EGP",
29
+ default_language: "en",
30
+ use_nextjs_storefront: true,
31
+ logo_url: "https://cdn.example.com/logo.png",
32
+ description: "A fixture store for render verification.",
33
+ settings: {},
34
+ ...overrides
35
+ };
36
+ }
37
+ function makeFixtureProduct(i = 1) {
38
+ const id = `00000000-0000-0000-0000-00000000p${String(i).padStart(3, "0")}`;
39
+ return {
40
+ id,
41
+ name: `Fixture Product ${i}`,
42
+ slug: `fixture-product-${i}`,
43
+ description: "A representative product for render verification.",
44
+ price: 19900,
45
+ compare_at_price: 24900,
46
+ currency: "EGP",
47
+ images: [
48
+ { id: `${id}-img`, url: "https://cdn.example.com/p.jpg", alt: "Product", position: 0 }
49
+ ],
50
+ options: [{ name: "Size", position: 0, values: ["S", "M", "L"] }],
51
+ variants: [
52
+ {
53
+ id: `${id}-v1`,
54
+ position: 0,
55
+ option_values: { Size: "M" },
56
+ price: 19900,
57
+ compare_at_price: 24900,
58
+ sku: "FX-M",
59
+ inventory_quantity: 25,
60
+ is_in_stock: true
61
+ }
62
+ ],
63
+ category: "Fixtures",
64
+ tags: ["fixture"],
65
+ in_stock: true,
66
+ attributes: {}
67
+ };
68
+ }
69
+ function makeFixtureCollection() {
70
+ const products = [makeFixtureProduct(1), makeFixtureProduct(2), makeFixtureProduct(3)];
71
+ return {
72
+ id: "00000000-0000-0000-0000-00000000c001",
73
+ name: "Fixture Collection",
74
+ slug: "fixture-collection",
75
+ description: "A representative collection.",
76
+ image_url: "https://cdn.example.com/c.jpg",
77
+ product_count: products.length,
78
+ products
79
+ };
80
+ }
81
+ function makeFixtureCart() {
82
+ const p = makeFixtureProduct(1);
83
+ return {
84
+ id: "00000000-0000-0000-0000-00000000cart",
85
+ items: [
86
+ {
87
+ id: "ci-1",
88
+ product_id: p.id,
89
+ variant_id: p.variants[0].id,
90
+ name: p.name,
91
+ image_url: p.images[0]?.url,
92
+ price: p.price,
93
+ quantity: 2,
94
+ variant_name: "M"
95
+ }
96
+ ],
97
+ subtotal: p.price * 2,
98
+ total: p.price * 2,
99
+ currency: "EGP"
100
+ };
101
+ }
102
+ function makeFixtureCustomer() {
103
+ return {
104
+ id: "00000000-0000-0000-0000-0000000cust1",
105
+ email: "fixture@example.com",
106
+ first_name: "Fixture",
107
+ last_name: "Buyer",
108
+ phone: "+201000000000",
109
+ orders_count: 3,
110
+ total_spent: 59700
111
+ };
112
+ }
113
+ function fixtureThemeSettings(storeId) {
114
+ return {
115
+ schema_version: 3,
116
+ theme_id: storeId,
117
+ global_settings: {},
118
+ templates: {},
119
+ section_groups: {}
120
+ };
121
+ }
122
+ function makeFixtureContext(template, overrides = {}) {
123
+ const store = makeFixtureStore();
124
+ const product = makeFixtureProduct(1);
125
+ const products = [product, makeFixtureProduct(2), makeFixtureProduct(3)];
126
+ const collection = makeFixtureCollection();
127
+ return {
128
+ storeData: store,
129
+ currentTemplate: template,
130
+ page: {
131
+ type: template,
132
+ handle: `${template}-fixture`,
133
+ data: { products, collections: [collection], product, collection }
134
+ },
135
+ themeSettings: fixtureThemeSettings(store.id),
136
+ initialCart: makeFixtureCart(),
137
+ customer: makeFixtureCustomer(),
138
+ locale: "en",
139
+ translations: {},
140
+ navigation: {},
141
+ demo: true,
142
+ ...overrides
143
+ };
144
+ }
145
+ async function verifyThemeRender(serverModule, options = {}) {
146
+ const templates = options.templates ?? REQUIRED_TEMPLATES;
147
+ if (!serverModule || typeof serverModule.createApp !== "function") {
148
+ return {
149
+ ok: false,
150
+ results: [
151
+ {
152
+ template: "*",
153
+ ok: false,
154
+ htmlLength: 0,
155
+ error: "Theme bundle does not export `createApp` \u2014 it cannot be server-rendered. Export it via defineThemeEntry() (SDK >= 0.3) and build with federate:true."
156
+ }
157
+ ]
158
+ };
159
+ }
160
+ const { renderToStaticMarkup } = await import('react-dom/server');
161
+ const results = [];
162
+ for (const template of templates) {
163
+ try {
164
+ const ctx = makeFixtureContext(
165
+ template,
166
+ options.locale ? { locale: options.locale } : {}
167
+ );
168
+ const el = serverModule.createApp(ctx);
169
+ const html = renderToStaticMarkup(el);
170
+ const len = (html || "").trim().length;
171
+ results.push({
172
+ template,
173
+ ok: len > 0,
174
+ htmlLength: len,
175
+ error: len > 0 ? void 0 : "rendered empty output"
176
+ });
177
+ } catch (err) {
178
+ results.push({
179
+ template,
180
+ ok: false,
181
+ htmlLength: 0,
182
+ error: err instanceof Error ? err.stack || err.message : String(err)
183
+ });
184
+ }
185
+ }
186
+ return { ok: results.every((r) => r.ok), results };
187
+ }
188
+
189
+ export { REQUIRED_TEMPLATES, makeFixtureCart, makeFixtureCollection, makeFixtureContext, makeFixtureCustomer, makeFixtureProduct, makeFixtureStore, verifyThemeRender };
190
+ //# sourceMappingURL=verify.mjs.map
191
+ //# sourceMappingURL=verify.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/validation/index.ts","../src/verify/index.ts"],"names":[],"mappings":"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","file":"verify.mjs","sourcesContent":["/**\n * Shared theme-contract validators — the single source of truth.\n *\n * This module is PURE: it imports only types (erased at compile time) and\n * has no React/DOM/fs dependencies, so the same logic runs in the CLI, the\n * Vite plugin, and (re-implemented) the backend. Callers parse JSON files\n * themselves and pass plain objects in; these functions never touch disk.\n *\n * The goal is that \"builds green\" implies \"matches the contract\": every\n * place a theme can drift from what the storefront expects is checked here\n * once, instead of being re-derived (and drifting) in each tool.\n */\n\nimport type { SectionSchema, SettingDefinition } from \"../types/theme\";\n\n/**\n * The theme CONTRACT version — bumped only on a breaking change to what a\n * built theme must look like (manifest shape, required emitted artifacts,\n * section/setting semantics). The plugin stamps this into the built\n * manifest/import-map; the host refuses bundles whose contract version it\n * doesn't support. Distinct from the npm SDK version (`SDK_VERSION`), which\n * moves on every release including non-breaking ones.\n */\nexport const THEME_CONTRACT_VERSION = 1;\n\n/** npm version of this SDK build (inlined by tsup `define`). Informational. */\ndeclare const __SDK_VERSION__: string | undefined;\nexport const SDK_VERSION: string =\n typeof __SDK_VERSION__ === \"string\" ? __SDK_VERSION__ : \"0.0.0\";\n\nexport interface ValidationIssue {\n level: \"error\" | \"warning\";\n /** Stable machine code, e.g. \"manifest.version.invalid\". */\n code: string;\n message: string;\n /** Dotted path or file hint, e.g. \"presets.templates.home\" or \"hero.json\". */\n path?: string;\n}\n\nexport interface ValidationResult {\n /** True when there are zero error-level issues. */\n valid: boolean;\n issues: ValidationIssue[];\n}\n\n/**\n * Page templates the storefront routes to. A theme that omits a REQUIRED\n * template still works (the host falls back to its built-in), so a miss is\n * a warning, not an error — but tooling can elevate it with --strict.\n */\nexport const REQUIRED_TEMPLATES = [\n \"home\",\n \"product\",\n \"collection\",\n \"cart\",\n \"page\",\n \"search\",\n \"404\",\n] as const;\n\nexport const KNOWN_TEMPLATES: ReadonlySet<string> = new Set([\n ...REQUIRED_TEMPLATES,\n \"blog\",\n \"article\",\n \"policies\",\n \"password\",\n \"account\",\n \"checkout\",\n]);\n\n/** Setting types the V3 customizer renders. Unknown types warn (forward-compat). */\nexport const KNOWN_SETTING_TYPES: ReadonlySet<string> = new Set([\n \"text\", \"textarea\", \"richtext\", \"number\", \"range\", \"color\", \"checkbox\",\n \"select\", \"radio\", \"font\", \"image_picker\", \"url\", \"product\", \"product_list\",\n \"collection\", \"collection_list\", \"header\", \"paragraph\", \"html\", \"date\",\n \"time\", \"video_picker\", \"color_scheme\", \"page_picker\", \"blog_picker\",\n \"link_list_picker\", \"variant_picker\", \"file_upload\", \"icon_picker\", \"icon\",\n]);\n\n/** Setting types that are presentational (no value) — `id`/`label` optional. */\nconst PRESENTATIONAL_SETTING_TYPES: ReadonlySet<string> = new Set([\n \"header\", \"paragraph\", \"html\",\n]);\n\n// ── Primitives ────────────────────────────────────────────────────\n\nconst SECTION_TYPE_RE = /^[a-z][a-z0-9_-]*$/;\nconst THEME_ID_RE = /^[a-z0-9][a-z0-9_-]*[a-z0-9]$/;\n// Pragmatic semver (major.minor.patch + optional -prerelease / +build).\nconst SEMVER_RE =\n /^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-.]+)?(?:\\+[0-9A-Za-z-.]+)?$/;\n\nfunction isObject(v: unknown): v is Record<string, unknown> {\n return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\nfunction isNonEmptyString(v: unknown): v is string {\n return typeof v === \"string\" && v.trim().length > 0;\n}\n\nclass IssueBag {\n readonly issues: ValidationIssue[] = [];\n err(code: string, message: string, path?: string) {\n this.issues.push({ level: \"error\", code, message, path });\n }\n warn(code: string, message: string, path?: string) {\n this.issues.push({ level: \"warning\", code, message, path });\n }\n result(): ValidationResult {\n return {\n valid: !this.issues.some((i) => i.level === \"error\"),\n issues: this.issues,\n };\n }\n}\n\n/** Pull the section types referenced by a manifest's presets. */\nfunction collectPresetSectionTypes(manifest: Record<string, unknown>): Set<string> {\n const types = new Set<string>();\n const presets = manifest.presets;\n if (!isObject(presets)) return types;\n const buckets = [presets.templates, presets.section_groups];\n for (const bucket of buckets) {\n if (!isObject(bucket)) continue;\n for (const entry of Object.values(bucket)) {\n if (!isObject(entry)) continue;\n const sections = entry.sections;\n // sections may be an array of instances OR a map id -> instance.\n const instances = Array.isArray(sections)\n ? sections\n : isObject(sections)\n ? Object.values(sections)\n : [];\n for (const inst of instances) {\n if (isObject(inst) && isNonEmptyString(inst.type)) types.add(inst.type);\n }\n }\n }\n return types;\n}\n\n// ── Setting / schema validation ───────────────────────────────────\n\nfunction validateSettingInto(\n bag: IssueBag,\n setting: unknown,\n pathPrefix: string,\n seenIds: Set<string>,\n) {\n if (!isObject(setting)) {\n bag.err(\"schema.setting.invalid\", \"Setting must be an object.\", pathPrefix);\n return;\n }\n const type = setting.type;\n if (!isNonEmptyString(type)) {\n bag.err(\"schema.setting.type.missing\", \"Setting is missing a `type`.\", pathPrefix);\n } else if (!KNOWN_SETTING_TYPES.has(type)) {\n bag.warn(\n \"schema.setting.type.unknown\",\n `Unknown setting type \"${type}\" — the customizer will fall back to a text input.`,\n pathPrefix,\n );\n }\n const presentational =\n isNonEmptyString(type) && PRESENTATIONAL_SETTING_TYPES.has(type);\n if (!presentational) {\n if (!isNonEmptyString(setting.id)) {\n bag.err(\"schema.setting.id.missing\", \"Setting is missing an `id`.\", pathPrefix);\n } else {\n if (seenIds.has(setting.id)) {\n bag.err(\n \"schema.setting.id.duplicate\",\n `Duplicate setting id \"${setting.id}\".`,\n pathPrefix,\n );\n }\n seenIds.add(setting.id);\n }\n if (!isNonEmptyString(setting.label)) {\n bag.warn(\"schema.setting.label.missing\", \"Setting has no `label`.\", pathPrefix);\n }\n }\n // select / radio need options.\n if (type === \"select\" || type === \"radio\") {\n const options = setting.options;\n if (!Array.isArray(options) || options.length === 0) {\n bag.err(\n \"schema.setting.options.missing\",\n `\"${type}\" setting \"${String(setting.id)}\" must declare a non-empty \\`options\\` array.`,\n pathPrefix,\n );\n }\n }\n // range needs bounds.\n if (type === \"range\") {\n if (typeof setting.min !== \"number\" || typeof setting.max !== \"number\") {\n bag.err(\n \"schema.setting.range.bounds\",\n `\"range\" setting \"${String(setting.id)}\" must declare numeric \\`min\\` and \\`max\\`.`,\n pathPrefix,\n );\n } else if (setting.min >= setting.max) {\n bag.err(\n \"schema.setting.range.order\",\n `\"range\" setting \"${String(setting.id)}\" has min >= max.`,\n pathPrefix,\n );\n }\n }\n}\n\n/**\n * Validate one section schema (the parsed schemas/sections/<type>.json).\n * When `filenameType` is given, enforces the component-filename = schema-type\n * convention by requiring `schema.type` to equal it.\n */\nexport function validateSectionSchema(\n schema: unknown,\n opts: { filenameType?: string } = {},\n): ValidationResult {\n const bag = new IssueBag();\n const where = opts.filenameType ? `${opts.filenameType}.json` : \"section schema\";\n if (!isObject(schema)) {\n bag.err(\"schema.invalid\", \"Section schema must be a JSON object.\", where);\n return bag.result();\n }\n const type = schema.type;\n if (!isNonEmptyString(type)) {\n bag.err(\"schema.type.missing\", \"Section schema is missing a `type`.\", where);\n } else {\n if (!SECTION_TYPE_RE.test(type)) {\n bag.err(\n \"schema.type.format\",\n `Section type \"${type}\" must match ${SECTION_TYPE_RE} (lowercase, start with a letter).`,\n where,\n );\n }\n if (opts.filenameType && type !== opts.filenameType) {\n bag.err(\n \"schema.type.filename_mismatch\",\n `Schema type \"${type}\" must equal its filename \"${opts.filenameType}\" (component-filename = schema-type convention).`,\n where,\n );\n }\n }\n if (!isNonEmptyString(schema.name)) {\n bag.err(\"schema.name.missing\", \"Section schema is missing a `name`.\", where);\n }\n if (schema.settings === undefined) {\n bag.warn(\"schema.settings.missing\", \"Section schema has no `settings`.\", where);\n } else if (!Array.isArray(schema.settings)) {\n bag.err(\"schema.settings.invalid\", \"`settings` must be an array.\", where);\n } else {\n const seen = new Set<string>();\n schema.settings.forEach((s, i) =>\n validateSettingInto(bag, s, `${where}.settings[${i}]`, seen),\n );\n }\n // Nested block schemas (shallow — type + settings shape).\n if (schema.blocks !== undefined) {\n if (!Array.isArray(schema.blocks)) {\n bag.err(\"schema.blocks.invalid\", \"`blocks` must be an array.\", where);\n } else {\n schema.blocks.forEach((b, i) => {\n if (!isObject(b) || !isNonEmptyString(b.type)) {\n bag.err(\"schema.block.type.missing\", `Block[${i}] is missing a \\`type\\`.`, where);\n return;\n }\n if (!SECTION_TYPE_RE.test(b.type)) {\n bag.err(\n \"schema.block.type.format\",\n `Block type \"${b.type}\" must match ${SECTION_TYPE_RE}.`,\n where,\n );\n }\n if (Array.isArray(b.settings)) {\n const seen = new Set<string>();\n b.settings.forEach((s, j) =>\n validateSettingInto(bag, s, `${where}.blocks[${i}].settings[${j}]`, seen),\n );\n }\n });\n }\n }\n return bag.result();\n}\n\n/**\n * Instance conformance: check a settings object (from a preset or a merchant\n * customization) against its section schema. Unknown keys warn (harmless at\n * runtime — ignored); invalid values (bad select option, out-of-range,\n * wrong primitive) error.\n */\nexport function validateSettingsAgainstSchema(\n settings: unknown,\n schema: Pick<SectionSchema, \"settings\" | \"type\">,\n pathPrefix = \"settings\",\n): ValidationResult {\n const bag = new IssueBag();\n if (!isObject(settings)) {\n bag.err(\"instance.settings.invalid\", \"Section settings must be an object.\", pathPrefix);\n return bag.result();\n }\n const defs = Array.isArray(schema.settings) ? schema.settings : [];\n const byId = new Map<string, SettingDefinition>();\n for (const d of defs) if (isNonEmptyString(d?.id)) byId.set(d.id, d);\n\n for (const [key, value] of Object.entries(settings)) {\n const def = byId.get(key);\n if (!def) {\n bag.warn(\n \"instance.setting.unknown\",\n `Setting \"${key}\" is not declared in the \"${String(schema.type)}\" schema.`,\n `${pathPrefix}.${key}`,\n );\n continue;\n }\n if (value === null || value === undefined) continue;\n const p = `${pathPrefix}.${key}`;\n if ((def.type === \"select\" || def.type === \"radio\") && Array.isArray(def.options)) {\n const allowed = def.options.map((o) => o.value);\n if (typeof value === \"string\" && !allowed.includes(value)) {\n bag.err(\n \"instance.setting.option.invalid\",\n `\"${key}\" = \"${value}\" is not one of: ${allowed.join(\", \")}.`,\n p,\n );\n }\n }\n if (def.type === \"range\" && typeof value === \"number\") {\n if (typeof def.min === \"number\" && value < def.min) {\n bag.err(\"instance.setting.range.under\", `\"${key}\" = ${value} is below min ${def.min}.`, p);\n }\n if (typeof def.max === \"number\" && value > def.max) {\n bag.err(\"instance.setting.range.over\", `\"${key}\" = ${value} is above max ${def.max}.`, p);\n }\n }\n if ((def.type === \"checkbox\") && typeof value !== \"boolean\") {\n bag.warn(\"instance.setting.type.mismatch\", `\"${key}\" should be a boolean.`, p);\n }\n if ((def.type === \"number\" || def.type === \"range\") && typeof value !== \"number\") {\n bag.warn(\"instance.setting.type.mismatch\", `\"${key}\" should be a number.`, p);\n }\n }\n return bag.result();\n}\n\n// ── Manifest validation ───────────────────────────────────────────\n\n/** Core manifest field checks shared by source (theme.json) and built manifests. */\nfunction validateManifestCore(bag: IssueBag, m: Record<string, unknown>) {\n if (!isNonEmptyString(m.id)) {\n bag.err(\"manifest.id.missing\", \"theme.json is missing `id`.\", \"id\");\n } else if (!THEME_ID_RE.test(m.id)) {\n bag.err(\n \"manifest.id.format\",\n `Theme id \"${m.id}\" must be lowercase alphanumeric with dashes/underscores (no leading/trailing separator).`,\n \"id\",\n );\n }\n // name: string or bilingual object { en|default, ... }.\n const name = m.name;\n const nameOk =\n isNonEmptyString(name) ||\n (isObject(name) && Object.values(name).some((v) => isNonEmptyString(v)));\n if (!nameOk) {\n bag.err(\"manifest.name.missing\", \"theme.json is missing a non-empty `name`.\", \"name\");\n }\n if (!isNonEmptyString(m.version)) {\n bag.err(\"manifest.version.missing\", \"theme.json is missing `version`.\", \"version\");\n } else if (!SEMVER_RE.test(m.version)) {\n bag.err(\"manifest.version.invalid\", `Version \"${m.version}\" is not valid semver.`, \"version\");\n }\n if (!isNonEmptyString(m.author)) {\n bag.err(\"manifest.author.missing\", \"theme.json is missing `author`.\", \"author\");\n }\n if (m.min_sdk_version !== undefined && !SEMVER_RE.test(String(m.min_sdk_version))) {\n bag.warn(\"manifest.min_sdk_version.invalid\", \"`min_sdk_version` is not valid semver.\", \"min_sdk_version\");\n }\n}\n\n/**\n * Validate a source theme manifest (parsed theme.json).\n *\n * `ctx.sectionTypes` — the set of section types the theme actually ships\n * (from schemas/sections/*.json). When provided, every section type\n * referenced by a preset must be present, and missing required templates\n * are surfaced as warnings.\n */\nexport function validateManifest(\n manifest: unknown,\n ctx: { sectionTypes?: ReadonlySet<string> } = {},\n): ValidationResult {\n const bag = new IssueBag();\n if (!isObject(manifest)) {\n bag.err(\"manifest.invalid\", \"theme.json must be a JSON object.\", \"theme.json\");\n return bag.result();\n }\n validateManifestCore(bag, manifest);\n\n const presets = manifest.presets;\n if (!isObject(presets) || Object.keys(presets).length === 0) {\n bag.warn(\"manifest.presets.empty\", \"theme.json has no presets — merchants start with an empty page.\", \"presets\");\n } else {\n // Every referenced section type must resolve to a shipped schema.\n if (ctx.sectionTypes) {\n for (const type of collectPresetSectionTypes(manifest)) {\n if (!ctx.sectionTypes.has(type)) {\n bag.err(\n \"manifest.preset.unknown_section\",\n `Preset references section type \"${type}\" with no schemas/sections/${type}.json.`,\n \"presets\",\n );\n }\n }\n }\n // Required-template coverage (warning).\n const templates = isObject(presets.templates) ? presets.templates : {};\n for (const t of REQUIRED_TEMPLATES) {\n if (!(t in templates)) {\n bag.warn(\n \"manifest.template.missing\",\n `No preset for required template \"${t}\" — the storefront will fall back to its built-in.`,\n `presets.templates.${t}`,\n );\n }\n }\n }\n return bag.result();\n}\n\n/**\n * Server-side gate: validate the artifacts the plugin emits — dist/manifest.json\n * (the embedded built manifest, including section_schemas) and dist/import-map.json\n * (the federation + contract-version descriptor). This is what the backend should\n * run on upload/submit, since it can't run the source build.\n *\n * `opts.hostContractVersion` — when given, refuses bundles whose\n * `contract_version` the host doesn't support.\n */\nexport function validateBuiltManifest(\n builtManifest: unknown,\n importMap?: unknown,\n opts: { hostContractVersion?: number } = {},\n): ValidationResult {\n const bag = new IssueBag();\n if (!isObject(builtManifest)) {\n bag.err(\"built.invalid\", \"manifest.json must be a JSON object.\", \"manifest.json\");\n return bag.result();\n }\n validateManifestCore(bag, builtManifest);\n\n // section_schemas must cover every type the presets reference.\n const schemas = builtManifest.section_schemas;\n const shipped = new Set<string>(isObject(schemas) ? Object.keys(schemas) : []);\n if (!isObject(schemas)) {\n bag.warn(\"built.section_schemas.missing\", \"manifest.json has no `section_schemas`.\", \"section_schemas\");\n }\n for (const type of collectPresetSectionTypes(builtManifest)) {\n if (!shipped.has(type)) {\n bag.err(\n \"built.preset.unknown_section\",\n `Preset references section type \"${type}\" not present in section_schemas.`,\n \"presets\",\n );\n }\n }\n // Each shipped schema must itself be valid.\n if (isObject(schemas)) {\n for (const [type, schema] of Object.entries(schemas)) {\n const r = validateSectionSchema(schema, { filenameType: type });\n for (const issue of r.issues) bag.issues.push(issue);\n }\n }\n if (!isNonEmptyString(builtManifest.plugin_version)) {\n bag.warn(\"built.plugin_version.missing\", \"manifest.json has no `plugin_version`.\", \"plugin_version\");\n }\n\n // Contract-version compatibility from the import map.\n if (importMap !== undefined) {\n if (!isObject(importMap)) {\n bag.err(\"importmap.invalid\", \"import-map.json must be a JSON object.\", \"import-map.json\");\n } else {\n const cv = importMap.contract_version;\n if (typeof cv !== \"number\") {\n bag.warn(\n \"importmap.contract_version.missing\",\n \"import-map.json has no numeric `contract_version` — built by an older plugin.\",\n \"contract_version\",\n );\n } else if (\n typeof opts.hostContractVersion === \"number\" &&\n cv > opts.hostContractVersion\n ) {\n bag.err(\n \"importmap.contract_version.incompatible\",\n `Theme built for contract v${cv}; this platform supports up to v${opts.hostContractVersion}.`,\n \"contract_version\",\n );\n }\n }\n }\n return bag.result();\n}\n\n/** Convenience: merge several results into one. */\nexport function mergeResults(...results: ValidationResult[]): ValidationResult {\n const issues = results.flatMap((r) => r.issues);\n return { valid: !issues.some((i) => i.level === \"error\"), issues };\n}\n","/**\n * Behavioral render-verification harness — theme-enforcement Phase 2.\n *\n * Phase 1 proves a theme MATCHES the contract (structure/manifest/schemas).\n * This proves it TRULY WORKS: it server-renders the built theme against\n * representative fixture data for every required template and fails on any\n * template that throws or renders empty — catching the bugs the structural\n * gate can't (a section that reads `settings.x.y` when x is undefined, an SDK\n * hook used outside its provider, a template that produces no output).\n *\n * This module is React-free at import time: it imports only types and pulls\n * `react-dom/server` dynamically at call time. It runs in Node (the CLI's\n * `verify` command / the build worker) against the theme's `dist/theme.server.js`\n * `createApp(ctx)` export. The theme's own React/SDK (its node_modules) satisfy\n * the bundle's externals.\n */\n\nimport type { ReactElement } from \"react\";\nimport type { Store, Product, Collection, Cart, Customer } from \"../types/entities\";\nimport type { ThemeSettingsV3 } from \"../types/theme\";\nimport type { ThemeMountContext } from \"../mount\";\nimport { REQUIRED_TEMPLATES } from \"../validation\";\n\nexport { REQUIRED_TEMPLATES };\n\n// ── Fixtures ──────────────────────────────────────────────────────\n\nexport function makeFixtureStore(overrides: Partial<Store> = {}): Store {\n return {\n id: \"00000000-0000-0000-0000-0000000000fx\",\n name: \"Fixture Store\",\n slug: \"fixture-store\",\n subdomain: \"fixture-store\",\n currency: \"EGP\",\n default_language: \"en\",\n use_nextjs_storefront: true,\n logo_url: \"https://cdn.example.com/logo.png\",\n description: \"A fixture store for render verification.\",\n settings: {},\n ...overrides,\n };\n}\n\nexport function makeFixtureProduct(i = 1): Product {\n const id = `00000000-0000-0000-0000-00000000p${String(i).padStart(3, \"0\")}`;\n return {\n id,\n name: `Fixture Product ${i}`,\n slug: `fixture-product-${i}`,\n description: \"A representative product for render verification.\",\n price: 19900,\n compare_at_price: 24900,\n currency: \"EGP\",\n images: [\n { id: `${id}-img`, url: \"https://cdn.example.com/p.jpg\", alt: \"Product\", position: 0 },\n ],\n options: [{ name: \"Size\", position: 0, values: [\"S\", \"M\", \"L\"] }],\n variants: [\n {\n id: `${id}-v1`,\n position: 0,\n option_values: { Size: \"M\" },\n price: 19900,\n compare_at_price: 24900,\n sku: \"FX-M\",\n inventory_quantity: 25,\n is_in_stock: true,\n },\n ],\n category: \"Fixtures\",\n tags: [\"fixture\"],\n in_stock: true,\n attributes: {},\n };\n}\n\nexport function makeFixtureCollection(): Collection {\n const products = [makeFixtureProduct(1), makeFixtureProduct(2), makeFixtureProduct(3)];\n return {\n id: \"00000000-0000-0000-0000-00000000c001\",\n name: \"Fixture Collection\",\n slug: \"fixture-collection\",\n description: \"A representative collection.\",\n image_url: \"https://cdn.example.com/c.jpg\",\n product_count: products.length,\n products,\n };\n}\n\nexport function makeFixtureCart(): Cart {\n const p = makeFixtureProduct(1);\n return {\n id: \"00000000-0000-0000-0000-00000000cart\",\n items: [\n {\n id: \"ci-1\",\n product_id: p.id,\n variant_id: p.variants[0].id,\n name: p.name,\n image_url: p.images[0]?.url,\n price: p.price,\n quantity: 2,\n variant_name: \"M\",\n },\n ],\n subtotal: p.price * 2,\n total: p.price * 2,\n currency: \"EGP\",\n };\n}\n\nexport function makeFixtureCustomer(): Customer {\n return {\n id: \"00000000-0000-0000-0000-0000000cust1\",\n email: \"fixture@example.com\",\n first_name: \"Fixture\",\n last_name: \"Buyer\",\n phone: \"+201000000000\",\n orders_count: 3,\n total_spent: 59700,\n };\n}\n\nfunction fixtureThemeSettings(storeId: string): ThemeSettingsV3 {\n // Empty templates → demo mode → the theme renders its own bundled presets\n // (from theme.json), which is exactly what we want to exercise.\n return {\n schema_version: 3,\n theme_id: storeId,\n global_settings: {},\n templates: {},\n section_groups: {},\n } as ThemeSettingsV3;\n}\n\n/**\n * Build a complete ThemeMountContext for a template. `page.data` is populated\n * generously (products + collections + a single product + collection) so a\n * section's hooks always have data regardless of which template reads what.\n */\nexport function makeFixtureContext(\n template: string,\n overrides: Partial<ThemeMountContext> = {},\n): ThemeMountContext {\n const store = makeFixtureStore();\n const product = makeFixtureProduct(1);\n const products = [product, makeFixtureProduct(2), makeFixtureProduct(3)];\n const collection = makeFixtureCollection();\n return {\n storeData: store,\n currentTemplate: template,\n page: {\n type: template,\n handle: `${template}-fixture`,\n data: { products, collections: [collection], product, collection },\n },\n themeSettings: fixtureThemeSettings(store.id),\n initialCart: makeFixtureCart(),\n customer: makeFixtureCustomer(),\n locale: \"en\",\n translations: {},\n navigation: {},\n demo: true,\n ...overrides,\n };\n}\n\n// ── Harness ───────────────────────────────────────────────────────\n\nexport interface TemplateRenderResult {\n template: string;\n ok: boolean;\n /** Trimmed HTML length produced by the SSR render. */\n htmlLength: number;\n /** Failure reason (throw stack/message, or \"empty output\"). */\n error?: string;\n}\n\nexport interface VerifyRenderResult {\n ok: boolean;\n results: TemplateRenderResult[];\n}\n\n/** The shape we need from a theme's built server bundle. */\nexport interface ThemeServerModule {\n createApp?: (ctx: ThemeMountContext) => ReactElement;\n}\n\nexport interface VerifyRenderOptions {\n /** Templates to render. Defaults to REQUIRED_TEMPLATES. */\n templates?: readonly string[];\n /** Locale to render under (e.g. \"ar\" to catch RTL/translation crashes). */\n locale?: string;\n}\n\n/**\n * Server-render every requested template through the theme's `createApp` and\n * report which ones throw or render empty. Never throws — a render crash is\n * captured as a failed result. `ok` is true only when every template renders\n * non-empty HTML without error.\n */\nexport async function verifyThemeRender(\n serverModule: ThemeServerModule | null | undefined,\n options: VerifyRenderOptions = {},\n): Promise<VerifyRenderResult> {\n const templates = options.templates ?? REQUIRED_TEMPLATES;\n\n if (!serverModule || typeof serverModule.createApp !== \"function\") {\n return {\n ok: false,\n results: [\n {\n template: \"*\",\n ok: false,\n htmlLength: 0,\n error:\n \"Theme bundle does not export `createApp` — it cannot be server-rendered. \" +\n \"Export it via defineThemeEntry() (SDK >= 0.3) and build with federate:true.\",\n },\n ],\n };\n }\n\n // Pulled dynamically so this module stays React-free at import time; resolved\n // from the host/theme node_modules at call time.\n const { renderToStaticMarkup } = (await import(\"react-dom/server\")) as {\n renderToStaticMarkup: (el: ReactElement) => string;\n };\n\n const results: TemplateRenderResult[] = [];\n for (const template of templates) {\n try {\n const ctx = makeFixtureContext(\n template,\n options.locale ? { locale: options.locale } : {},\n );\n const el = serverModule.createApp(ctx);\n const html = renderToStaticMarkup(el);\n const len = (html || \"\").trim().length;\n results.push({\n template,\n ok: len > 0,\n htmlLength: len,\n error: len > 0 ? undefined : \"rendered empty output\",\n });\n } catch (err) {\n results.push({\n template,\n ok: false,\n htmlLength: 0,\n error: err instanceof Error ? err.stack || err.message : String(err),\n });\n }\n }\n\n return { ok: results.every((r) => r.ok), results };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@numueg/theme-sdk",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "NUMU Theme SDK — typed hooks, components, and utilities for building NUMU storefront themes",
5
5
  "keywords": [
6
6
  "numu",
@@ -45,6 +45,16 @@
45
45
  "import": "./dist/normalize.mjs",
46
46
  "require": "./dist/normalize.cjs"
47
47
  },
48
+ "./validation": {
49
+ "types": "./dist/validation.d.ts",
50
+ "import": "./dist/validation.mjs",
51
+ "require": "./dist/validation.cjs"
52
+ },
53
+ "./verify": {
54
+ "types": "./dist/verify.d.ts",
55
+ "import": "./dist/verify.mjs",
56
+ "require": "./dist/verify.cjs"
57
+ },
48
58
  "./v2-compat": {
49
59
  "types": "./dist/v2-compat.d.ts",
50
60
  "import": "./dist/v2-compat.mjs",