@lamgig/content-schemas 1.1.0 → 1.3.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.
package/README.md CHANGED
@@ -52,6 +52,47 @@ get uniform strictness):
52
52
  deliberately kept commerce-free as the pinned example of that shape. A consumer must treat
53
53
  `modules.commerce` as possibly absent — `parsed.modules.commerce?.acceptsOnlineOrders`.
54
54
 
55
+ ## Menu option groups (1.3.0)
56
+
57
+ Sizes and add-ons, as **one** concept: an option group. A size is `min: 1, max: 1`; an add-on
58
+ list is `min: 0, max: n`. Two **optional** keys inside `modules.menu`; nothing existing changes
59
+ and `schemaVersion` stays 1.
60
+
61
+ | Key | Where | Notes |
62
+ |---|---|---|
63
+ | `optionGroups` | `menu` | The site's groups, each `{ id, name, min, max, sortOrder, options }`; each option `{ id, name, priceDelta, isAvailable, sortOrder }` |
64
+ | `optionGroupIds` | each item | Ids into `optionGroups`, **in display order** |
65
+
66
+ - **Absent ≡ `[]`** — the producer omits both keys when there is nothing to say, so a site that
67
+ uses no options publishes exactly its 1.2.0 shape, and every snapshot published before 1.3.0
68
+ stays valid (a rollback can activate one at any time). Never `null`.
69
+ - **Price rule:** unit price = `item.price.amount` + Σ chosen options' `priceDelta`. Deltas are
70
+ integer minor units, ≥ 0, **in the item's currency** — they carry no currency of their own.
71
+ - **Archived groups and options are absent**, and an archived group is dropped from every
72
+ item's `optionGroupIds`. An option that is merely unavailable is present with
73
+ `isAvailable: false` — show it as unavailable rather than hiding it. A group no item uses is
74
+ still published.
75
+ - **Strict-only publish gate** (`menuSchemaStrict` / `contentFileSchemaStrict`): a required group
76
+ has an available option; `max` ≤ available options; ≤ `MAX_OPTION_GROUPS_PER_ITEM` (10)
77
+ groups per item; ≤ `MAX_OPTIONS_PER_GROUP` (30) options per group; one currency per group;
78
+ references resolve, each once; ids unique. Each message names the group or item. The
79
+ **tolerant** schemas do not apply these — a consumer must never reject a payload over a rule
80
+ a later release may relax — but field bounds (`min`/`max` 0–30, `priceDelta` 0–100000, name
81
+ 1–120) hold in both modes, as for every other field.
82
+ - **Consumers on ≤ 1.2.x** strip both keys and keep working: they show the base price only.
83
+
84
+ Types: `MenuOptionGroup`, `MenuOption` (producer); read them off `ConsumerContentFile` in a
85
+ consumer.
86
+
87
+ ## Changelog
88
+
89
+ | Version | Change |
90
+ |---|---|
91
+ | 1.3.0 | Optional `menu.optionGroups` and `items[].optionGroupIds` (Online Commerce C3); `MenuOptionGroup`/`MenuOption` types; `MAX_OPTION_*` constants. `full.json` gains options (hash re-pinned); `minimal.json` unchanged |
92
+ | 1.2.0 | `commerce.provider` becomes an open enum for consumers (`ConsumerContentFile`, `isKnownCommerceProvider`) |
93
+ | 1.1.0 | Optional `commerce` module |
94
+ | 1.0.0 | The locked v1 contract: `siteSettings`, `branding`, `menu` |
95
+
55
96
  ## The `commerce` module (1.1.0)
56
97
 
57
98
  Online-ordering **configuration**. LamGig does not process orders and stores no provider
@@ -60,7 +101,7 @@ payload is world-readable immutable JSON.
60
101
 
61
102
  | Field | Notes |
62
103
  |---|---|
63
- | `provider` | `'none' \| 'other'` only. A published enum is a consumer-visible commitment, and no provider has been evaluated yet — do not switch on a brand name that is not in this union |
104
+ | `provider` | The producer publishes `'none' \| 'other'` today (`COMMERCE_PROVIDERS`). **An open enum** — see below: the tolerant schema accepts any well-formed provider name |
64
105
  | `orderingUrl` | optional; **https only** |
65
106
  | `locationRef`, `menuRef` | optional public provider identifiers |
66
107
  | `currency` | ISO 4217 alpha-3, matching `menu` item prices |
@@ -74,6 +115,40 @@ business closes after midnight — so a renderer that treats it as an error will
74
115
  perfectly valid payload. `fixtures/v1/full.json` pins an overnight Friday for exactly that
75
116
  reason.
76
117
 
118
+ ### Open enums: `commerce.provider` (1.2.0)
119
+
120
+ `provider` is the **only open enum** in the contract. The platform will publish new providers
121
+ (`stripe`, then `square`) as it learns to serve them, one per release, and a consumer built
122
+ before that must keep working.
123
+
124
+ | Schema | Accepts |
125
+ |---|---|
126
+ | `contentFileSchemaStrict` / `commerceSchemaStrict` (producer) | exactly `COMMERCE_PROVIDERS` |
127
+ | `contentFileSchema` / `commerceSchema` (consumer) | `COMMERCE_PROVIDERS`, **or any name matching `COMMERCE_PROVIDER_PATTERN`** (`^[a-z][a-z0-9_]{0,31}$`), passed through unchanged. Malformed values (`"Stripe!"`, `""`) still fail |
128
+
129
+ **The consumer rule.** An unknown provider means "a provider newer than this consumer". Treat it
130
+ exactly like `other`: when `acceptsOnlineOrders` is true and `orderingUrl` is present, offer the
131
+ hand-off to `orderingUrl`; **never** show a cart or a payment form for a provider you don't
132
+ know. `isKnownCommerceProvider(p)` narrows a parsed value to the providers this version knows.
133
+ The platform always publishes `orderingUrl` when online ordering is on, in every mode, so this
134
+ rule keeps every consumer working.
135
+
136
+ **Upgrade before the platform publishes a new provider.** Up to **1.1.x the tolerant schema
137
+ rejected unknown providers** — it declared the same closed enum as the strict schema, so a
138
+ payload naming a newer provider failed to parse *as a whole* (tolerance covered unknown keys,
139
+ never unknown enum values, despite what 1.1.0's docs said). Consumers on 1.1.x must move to
140
+ ≥ 1.2.0 before any site publishes a provider outside `none | other`. Consumers on 1.0.x are
141
+ unaffected: 1.0.x has no `commerce` schema and strips the module entirely.
142
+
143
+ **TypeScript consumers: type parsed content as `ConsumerContentFile`** (and `ConsumerCommerce`),
144
+ not `ContentFile`. `ContentFile` is the *producer's* type, inferred from the strict schema, so its
145
+ `provider` is the closed union; `contentFileSchema.parse` returns the widened type. A consumer
146
+ that annotated tolerant output as `ContentFile` gets a compile error on `commerce.provider` when
147
+ it upgrades to 1.2.0 — that error is the point: it marks the one place the consumer must handle
148
+ a provider it doesn't know. The JSON contract itself is unchanged (no schemaVersion bump).
149
+
150
+ `hours[].day` stays a **closed** enum: the days of the week are not going to grow.
151
+
77
152
  ## Canonical serialization
78
153
 
79
154
  `canonicalize(payload)` produces recursively key-sorted JSON with no whitespace:
@@ -119,7 +194,8 @@ and the database can never disagree. Widening the locale subset later is additiv
119
194
 
120
195
  ## Fixtures (`fixtures/v1/`)
121
196
 
122
- - `full.json` — realistic restaurant, every optional field populated.
197
+ - `full.json` — realistic restaurant, every optional field populated (option groups since
198
+ 1.3.0, including an unavailable option).
123
199
  - `minimal.json` — empty menu, no optional fields; also the reference shape for the
124
200
  fresh-site validity check (a newly provisioned site must gather to this shape).
125
201
  - `hashes.json` — checked-in `hashContent` snapshots; a mismatch means serialization or a
@@ -75,6 +75,29 @@ var makeBrandingSchema = (o) => o({
75
75
  });
76
76
  var brandingSchemaStrict = makeBrandingSchema(strictFactory);
77
77
  var brandingSchema = makeBrandingSchema(tolerantFactory);
78
+ var MAX_OPTION_GROUPS_PER_ITEM = 10;
79
+ var MAX_OPTIONS_PER_GROUP = 30;
80
+ var MAX_OPTION_SELECT = 30;
81
+ var MAX_OPTION_PRICE_DELTA = 1e5;
82
+ var makeMenuOptionSchema = (o) => o({
83
+ id: z.uuid(),
84
+ name: z.string().min(1).max(120),
85
+ /** Integer minor units, ≥ 0, in the currency of the item it is chosen on. */
86
+ priceDelta: z.number().int().min(0).max(MAX_OPTION_PRICE_DELTA),
87
+ isAvailable: z.boolean(),
88
+ sortOrder: z.number().int().min(0).max(1e5)
89
+ });
90
+ var makeMenuOptionGroupSchema = (o) => o({
91
+ id: z.uuid(),
92
+ name: z.string().min(1).max(120),
93
+ /** Fewest options a customer must choose; ≥ 1 makes the group required. */
94
+ min: z.number().int().min(0).max(MAX_OPTION_SELECT),
95
+ /** Most options a customer may choose. */
96
+ max: z.number().int().min(0).max(MAX_OPTION_SELECT),
97
+ sortOrder: z.number().int().min(0).max(1e5),
98
+ /** Archived options are absent; an unavailable one is present with `isAvailable: false`. */
99
+ options: z.array(makeMenuOptionSchema(o))
100
+ });
78
101
  var makeMenuItemSchema = (o) => o({
79
102
  /** Stable DB UUID — identical across publishes (phase-2.md T1.3). */
80
103
  id: z.uuid(),
@@ -88,7 +111,9 @@ var makeMenuItemSchema = (o) => o({
88
111
  imageUrl: z.url().optional(),
89
112
  tags: z.array(z.string().min(1)).max(20),
90
113
  sortOrder: z.number().int().min(0).max(1e5),
91
- isAvailable: z.boolean()
114
+ isAvailable: z.boolean(),
115
+ /** 1.3.0: ids into `menu.optionGroups`, in display order. Absent ≡ `[]`. */
116
+ optionGroupIds: z.array(z.uuid()).optional()
92
117
  });
93
118
  var makeMenuCategorySchema = (o) => o({
94
119
  id: z.uuid(),
@@ -99,9 +124,75 @@ var makeMenuCategorySchema = (o) => o({
99
124
  });
100
125
  var makeMenuSchema = (o) => o({
101
126
  /** Archived/deleted entities are simply ABSENT; empty menu = `[]`, never a missing key. */
102
- categories: z.array(makeMenuCategorySchema(o))
127
+ categories: z.array(makeMenuCategorySchema(o)),
128
+ /** 1.3.0: the site's option groups (archived ones absent). Absent ≡ `[]`. */
129
+ optionGroups: z.array(makeMenuOptionGroupSchema(o)).optional()
103
130
  });
104
- var menuSchemaStrict = makeMenuSchema(strictFactory);
131
+ function checkMenuOptions(menu, ctx) {
132
+ const issue = (path, message) => ctx.addIssue({ code: "custom", path, message });
133
+ const groups = menu.optionGroups ?? [];
134
+ const groupNames = /* @__PURE__ */ new Map();
135
+ const optionIds = /* @__PURE__ */ new Set();
136
+ groups.forEach((group, g) => {
137
+ const label = `Option group "${group.name}"`;
138
+ if (groupNames.has(group.id)) issue(["optionGroups", g, "id"], `${label} repeats another group's id`);
139
+ groupNames.set(group.id, group.name);
140
+ if (group.min > group.max) {
141
+ issue(["optionGroups", g, "min"], `${label} requires at least ${group.min} but allows at most ${group.max}`);
142
+ }
143
+ if (group.options.length > MAX_OPTIONS_PER_GROUP) {
144
+ issue(
145
+ ["optionGroups", g, "options"],
146
+ `${label} has ${group.options.length} options; the limit is ${MAX_OPTIONS_PER_GROUP}`
147
+ );
148
+ }
149
+ const available = group.options.filter((option) => option.isAvailable).length;
150
+ if (group.min >= 1 && available === 0) {
151
+ issue(["optionGroups", g, "options"], `${label} is required but has no available options`);
152
+ } else if (group.max > available) {
153
+ issue(
154
+ ["optionGroups", g, "max"],
155
+ `${label} allows up to ${group.max} choices but has only ${available} available option${available === 1 ? "" : "s"}`
156
+ );
157
+ }
158
+ group.options.forEach((option, k) => {
159
+ if (optionIds.has(option.id)) {
160
+ issue(["optionGroups", g, "options", k, "id"], `${label}: option "${option.name}" repeats another option's id`);
161
+ }
162
+ optionIds.add(option.id);
163
+ });
164
+ });
165
+ const firstUse = /* @__PURE__ */ new Map();
166
+ menu.categories.forEach((category, c) => {
167
+ category.items.forEach((item, i) => {
168
+ const ids = item.optionGroupIds ?? [];
169
+ const path = ["categories", c, "items", i, "optionGroupIds"];
170
+ if (ids.length > MAX_OPTION_GROUPS_PER_ITEM) {
171
+ issue(path, `Item "${item.name}" has ${ids.length} option groups; the limit is ${MAX_OPTION_GROUPS_PER_ITEM}`);
172
+ }
173
+ const seen = /* @__PURE__ */ new Set();
174
+ ids.forEach((id, k) => {
175
+ if (seen.has(id)) issue([...path, k], `Item "${item.name}" lists the same option group twice`);
176
+ seen.add(id);
177
+ const groupName = groupNames.get(id);
178
+ if (groupName === void 0) {
179
+ issue([...path, k], `Item "${item.name}" uses an option group that is not published`);
180
+ return;
181
+ }
182
+ const prior = firstUse.get(id);
183
+ if (!prior) {
184
+ firstUse.set(id, { currency: item.price.currency, itemName: item.name });
185
+ } else if (prior.currency !== item.price.currency) {
186
+ issue(
187
+ [...path, k],
188
+ `Option group "${groupName}" is used by items in two currencies: "${prior.itemName}" (${prior.currency}) and "${item.name}" (${item.price.currency})`
189
+ );
190
+ }
191
+ });
192
+ });
193
+ });
194
+ }
195
+ var menuSchemaStrict = makeMenuSchema(strictFactory).superRefine(checkMenuOptions);
105
196
  var menuSchema = makeMenuSchema(tolerantFactory);
106
197
  var makeCommerceHoursSchema = (o) => z.array(
107
198
  o({
@@ -132,8 +223,19 @@ var makeCommerceHoursSchema = (o) => z.array(
132
223
  previous = position;
133
224
  });
134
225
  });
135
- var makeCommerceSchema = (o) => o({
136
- provider: z.enum(["none", "other"]),
226
+ var COMMERCE_PROVIDERS = ["none", "other"];
227
+ var COMMERCE_PROVIDER_PATTERN_SOURCE = "^[a-z][a-z0-9_]{0,31}$";
228
+ var COMMERCE_PROVIDER_PATTERN = new RegExp(COMMERCE_PROVIDER_PATTERN_SOURCE);
229
+ var commerceProviderStrict = z.enum(COMMERCE_PROVIDERS);
230
+ var commerceProviderTolerant = z.union([
231
+ z.enum(COMMERCE_PROVIDERS),
232
+ z.string().regex(COMMERCE_PROVIDER_PATTERN)
233
+ ]);
234
+ function isKnownCommerceProvider(provider) {
235
+ return COMMERCE_PROVIDERS.includes(provider);
236
+ }
237
+ var makeCommerceSchema = (o, provider) => o({
238
+ provider,
137
239
  /** Where a customer is sent to order. Absent when `provider` is `none`. */
138
240
  orderingUrl: z.url({ protocol: /^https$/ }).max(2048).optional(),
139
241
  /** PUBLIC provider identifiers — never a key, token or secret. */
@@ -146,9 +248,9 @@ var makeCommerceSchema = (o) => o({
146
248
  prepTimeMinutes: z.number().int().min(0).max(240).optional(),
147
249
  hours: makeCommerceHoursSchema(o)
148
250
  });
149
- var commerceSchemaStrict = makeCommerceSchema(strictFactory);
150
- var commerceSchema = makeCommerceSchema(tolerantFactory);
151
- var makeContentFileSchema = (o) => o({
251
+ var commerceSchemaStrict = makeCommerceSchema(strictFactory, commerceProviderStrict);
252
+ var commerceSchema = makeCommerceSchema(tolerantFactory, commerceProviderTolerant);
253
+ var makeContentFileSchema = (o, commerceProvider, menu) => o({
152
254
  schemaVersion: z.literal(1),
153
255
  site: o({ id: z.uuid(), slug: z.string().min(1) }),
154
256
  publishedAt: z.iso.datetime(),
@@ -165,12 +267,20 @@ var makeContentFileSchema = (o) => o({
165
267
  modules: o({
166
268
  siteSettings: makeSiteSettingsSchema(o),
167
269
  branding: makeBrandingSchema(o),
168
- menu: makeMenuSchema(o),
169
- commerce: makeCommerceSchema(o).optional()
270
+ menu,
271
+ commerce: makeCommerceSchema(o, commerceProvider).optional()
170
272
  })
171
273
  });
172
- var contentFileSchemaStrict = makeContentFileSchema(strictFactory);
173
- var contentFileSchema = makeContentFileSchema(tolerantFactory);
274
+ var contentFileSchemaStrict = makeContentFileSchema(
275
+ strictFactory,
276
+ commerceProviderStrict,
277
+ menuSchemaStrict
278
+ );
279
+ var contentFileSchema = makeContentFileSchema(
280
+ tolerantFactory,
281
+ commerceProviderTolerant,
282
+ menuSchema
283
+ );
174
284
  function canonicalize(payload) {
175
285
  const out = serialize(payload);
176
286
  if (out === void 0) {
@@ -235,12 +345,20 @@ export {
235
345
  siteSettingsSchema,
236
346
  brandingSchemaStrict,
237
347
  brandingSchema,
348
+ MAX_OPTION_GROUPS_PER_ITEM,
349
+ MAX_OPTIONS_PER_GROUP,
350
+ MAX_OPTION_SELECT,
351
+ MAX_OPTION_PRICE_DELTA,
238
352
  menuSchemaStrict,
239
353
  menuSchema,
354
+ COMMERCE_PROVIDERS,
355
+ COMMERCE_PROVIDER_PATTERN_SOURCE,
356
+ COMMERCE_PROVIDER_PATTERN,
357
+ isKnownCommerceProvider,
240
358
  commerceSchemaStrict,
241
359
  commerceSchema,
242
360
  contentFileSchemaStrict,
243
361
  contentFileSchema,
244
362
  canonicalize
245
363
  };
246
- //# sourceMappingURL=chunk-GUUCVLL3.js.map
364
+ //# sourceMappingURL=chunk-7B6AENRI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { z } from 'zod';\n\n/**\n * Version of the published-JSON contract. Additive changes (new OPTIONAL fields\n * and new OPTIONAL module keys) keep this number; any required addition is\n * breaking and bumps it. The full compatibility rule lives in this package's\n * README. See PLAN.md § Published JSON delivery and phase-2.md T1.5.\n */\nexport const SCHEMA_VERSION = 1;\n\n/** Publisher-enforced ceiling on the canonical payload size (1 MiB). */\nexport const MAX_CONTENT_BYTES = 1_048_576;\n\n// ---------------------------------------------------------------------------\n// Shared value patterns — single source of truth.\n//\n// LOCALE_PATTERN_SOURCE is the EXACT string used by the `site_settings.locale`\n// DB CHECK (supabase/migrations/*_content_site_settings.sql). Contract and\n// database must never disagree (phase-2.md T1.1): v1 supports the named BCP-47\n// subset `ll` or `ll-CC` only (e.g. `en`, `en-US`); script/numeric-region forms\n// (`zh-Hant`, `es-419`) are out of scope — widening later is additive.\n// HEX_COLOR / CURRENCY are likewise mirrored by DB CHECKs on `branding.colors`\n// and `menu_items.price_currency`.\n// ---------------------------------------------------------------------------\n\nexport const LOCALE_PATTERN_SOURCE = '^[a-z]{2}(-[A-Z]{2})?$';\nexport const LOCALE_PATTERN = new RegExp(LOCALE_PATTERN_SOURCE);\n\nexport const HEX_COLOR_PATTERN_SOURCE = '^#[0-9A-Fa-f]{6}$';\nexport const HEX_COLOR_PATTERN = new RegExp(HEX_COLOR_PATTERN_SOURCE);\n\n/** ISO 4217 alpha code shape (mirrored by the `price_currency` DB CHECK). */\nexport const CURRENCY_PATTERN_SOURCE = '^[A-Z]{3}$';\nexport const CURRENCY_PATTERN = new RegExp(CURRENCY_PATTERN_SOURCE);\n\n/** ISO 3166-1 alpha-2 country code shape. */\nexport const COUNTRY_PATTERN_SOURCE = '^[A-Z]{2}$';\nexport const COUNTRY_PATTERN = new RegExp(COUNTRY_PATTERN_SOURCE);\n\n/**\n * Days of the week, in the canonical order `commerce.hours` must be sorted by.\n * The order is part of the contract: a consumer rendering opening hours reads\n * the array straight through, so a payload listing Sunday before Monday would\n * render wrong without being malformed.\n */\nexport const COMMERCE_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;\n\n/**\n * 24-hour `HH:MM`. UNLIKE the four patterns above this one is deliberately NOT\n * mirrored by a DB CHECK: `commerce_settings.hours` is validated at the DB only\n * as \"an array of at most 7\" (see the migration header), and its element shape,\n * time syntax, day uniqueness and ordering are publish-gated here instead. A\n * schema-invalid draft may therefore exist in the table; it cannot be published.\n */\nexport const TIME_24H_PATTERN_SOURCE = '^([01][0-9]|2[0-3]):[0-5][0-9]$';\nexport const TIME_24H_PATTERN = new RegExp(TIME_24H_PATTERN_SOURCE);\n\n/** IANA time zone check via the runtime's own tz database. */\nexport function isIanaTimeZone(tz: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Two parse modes from ONE schema definition (phase-2.md T1):\n//\n// - producer-strict — `.strict()` everywhere; the dashboard validates its own\n// output against this before publishing. Unknown keys are defects.\n// - consumer-tolerant — unknown object keys are STRIPPED and unknown module\n// keys ignored, so a shipped consumer accepts a v1 payload that later gained\n// an optional field or a new module key (the additive changes the\n// compatibility rule promises).\n//\n// zod v4's `.strip()`/`.passthrough()` do NOT recurse into nested objects, so a\n// post-hoc transform of one tree silently leaves nested objects in the original\n// mode. Instead every object in the tree is built through an injected object\n// factory: `z.strictObject` for the strict pair, `z.object` (strips unknown\n// keys) for the tolerant pair. One definition, two instantiations.\n// ---------------------------------------------------------------------------\n\ntype ObjectFactory = <T extends z.ZodRawShape>(shape: T) => z.ZodObject<T>;\n\nconst strictFactory = z.strictObject as ObjectFactory;\nconst tolerantFactory = z.object as ObjectFactory;\n\n// --- siteSettings ----------------------------------------------------------\n\nconst makeSiteSettingsSchema = (o: ObjectFactory) =>\n o({\n siteName: z.string().min(1).max(120),\n tagline: z.string().max(200).optional(),\n contact: o({\n email: z.email().optional(),\n phone: z.string().min(1).optional(),\n address: o({\n line1: z.string().min(1),\n line2: z.string().min(1).optional(),\n city: z.string().min(1),\n region: z.string().min(1),\n postalCode: z.string().min(1),\n country: z.string().regex(COUNTRY_PATTERN),\n }).optional(),\n }),\n // Explicit known keys (NOT a record): strict rejects unknown platforms,\n // tolerant strips them — adding a platform later is additive.\n socialLinks: o({\n facebook: z.url().optional(),\n instagram: z.url().optional(),\n tiktok: z.url().optional(),\n x: z.url().optional(),\n youtube: z.url().optional(),\n yelp: z.url().optional(),\n google: z.url().optional(),\n }).optional(),\n locale: z.string().regex(LOCALE_PATTERN).default('en-US'),\n timezone: z\n .string()\n .min(1)\n .refine(isIanaTimeZone, 'must be an IANA time zone name')\n .default('America/Los_Angeles'),\n });\n\nexport const siteSettingsSchemaStrict = makeSiteSettingsSchema(strictFactory);\nexport const siteSettingsSchema = makeSiteSettingsSchema(tolerantFactory);\n\n// --- branding --------------------------------------------------------------\n\nconst makeBrandingSchema = (o: ObjectFactory) =>\n o({\n // Absolute URLs produced by the dashboard's storagePublicUrl builder\n // (apps/dashboard/src/lib/content-urls.ts). The DB stores storage PATHS;\n // the publish-time compose maps path -> URL. This package never imports\n // from the app (dependency direction, phase-2.md T1.7).\n logoUrl: z.url().optional(),\n faviconUrl: z.url().optional(),\n colors: o({\n primary: z.string().regex(HEX_COLOR_PATTERN),\n secondary: z.string().regex(HEX_COLOR_PATTERN).optional(),\n accent: z.string().regex(HEX_COLOR_PATTERN).optional(),\n background: z.string().regex(HEX_COLOR_PATTERN).optional(),\n text: z.string().regex(HEX_COLOR_PATTERN).optional(),\n }),\n fonts: o({\n heading: z.string().min(1).optional(),\n body: z.string().min(1).optional(),\n }).optional(),\n });\n\nexport const brandingSchemaStrict = makeBrandingSchema(strictFactory);\nexport const brandingSchema = makeBrandingSchema(tolerantFactory);\n\n// --- menu ------------------------------------------------------------------\n//\n// OPTION GROUPS (1.3.0, Online Commerce C3): one concept for sizes and add-ons.\n// A size is a group with min 1 / max 1; an add-on list is min 0 / max n. Groups\n// are site-level and reusable — defined once in `optionGroups`, attached to an\n// item by id in `optionGroupIds`, in display order. Both keys are OPTIONAL and\n// ABSENT when empty, so every pre-1.3.0 snapshot (and every site that uses no\n// options) stays valid — ARCHITECTURE.md § 5.\n//\n// Price rule: a line's unit price = item `price.amount` + Σ chosen options'\n// `priceDelta`. Deltas carry no currency of their own: they are integer minor\n// units in the currency of the ITEM they are chosen on.\n//\n// Field bounds below hold in BOTH modes and mirror the DB CHECKs. The rules that\n// relate objects to each other (references resolve, counts, required groups,\n// one currency per group) are the producer's PUBLISH GATE and live only in the\n// strict schema — see `checkMenuOptions`.\n\n/** At most this many option groups on one item (publish-gated, not DB-enforced). */\nexport const MAX_OPTION_GROUPS_PER_ITEM = 10;\n/** At most this many published options in one group (publish-gated). */\nexport const MAX_OPTIONS_PER_GROUP = 30;\n/** Upper bound of a group's `min`/`max` (mirrors the DB CHECK). */\nexport const MAX_OPTION_SELECT = 30;\n/** Upper bound of an option's `priceDelta`, minor units (mirrors the DB CHECK). */\nexport const MAX_OPTION_PRICE_DELTA = 100_000;\n\nconst makeMenuOptionSchema = (o: ObjectFactory) =>\n o({\n id: z.uuid(),\n name: z.string().min(1).max(120),\n /** Integer minor units, ≥ 0, in the currency of the item it is chosen on. */\n priceDelta: z.number().int().min(0).max(MAX_OPTION_PRICE_DELTA),\n isAvailable: z.boolean(),\n sortOrder: z.number().int().min(0).max(100_000),\n });\n\nconst makeMenuOptionGroupSchema = (o: ObjectFactory) =>\n o({\n id: z.uuid(),\n name: z.string().min(1).max(120),\n /** Fewest options a customer must choose; ≥ 1 makes the group required. */\n min: z.number().int().min(0).max(MAX_OPTION_SELECT),\n /** Most options a customer may choose. */\n max: z.number().int().min(0).max(MAX_OPTION_SELECT),\n sortOrder: z.number().int().min(0).max(100_000),\n /** Archived options are absent; an unavailable one is present with `isAvailable: false`. */\n options: z.array(makeMenuOptionSchema(o)),\n });\n\nconst makeMenuItemSchema = (o: ObjectFactory) =>\n o({\n /** Stable DB UUID — identical across publishes (phase-2.md T1.3). */\n id: z.uuid(),\n name: z.string().min(1).max(200),\n description: z.string().max(2000).optional(),\n price: o({\n /** Integer minor units (e.g. cents). */\n amount: z.number().int().min(0),\n currency: z.string().regex(CURRENCY_PATTERN),\n }),\n imageUrl: z.url().optional(),\n tags: z.array(z.string().min(1)).max(20),\n sortOrder: z.number().int().min(0).max(100_000),\n isAvailable: z.boolean(),\n /** 1.3.0: ids into `menu.optionGroups`, in display order. Absent ≡ `[]`. */\n optionGroupIds: z.array(z.uuid()).optional(),\n });\n\nconst makeMenuCategorySchema = (o: ObjectFactory) =>\n o({\n id: z.uuid(),\n name: z.string().min(1).max(200),\n description: z.string().max(2000).optional(),\n sortOrder: z.number().int().min(0).max(100_000),\n items: z.array(makeMenuItemSchema(o)),\n });\n\nconst makeMenuSchema = (o: ObjectFactory) =>\n o({\n /** Archived/deleted entities are simply ABSENT; empty menu = `[]`, never a missing key. */\n categories: z.array(makeMenuCategorySchema(o)),\n /** 1.3.0: the site's option groups (archived ones absent). Absent ≡ `[]`. */\n optionGroups: z.array(makeMenuOptionGroupSchema(o)).optional(),\n });\n\ntype MenuShape = z.output<ReturnType<typeof makeMenuSchema>>;\n\n/**\n * The menu's PUBLISH GATE for option groups (C3) — strict schema only. A draft\n * may break any of these and still be saved; it cannot be published. Every\n * message names the group or item, because it is shown to the merchant verbatim\n * by the publish screen.\n *\n * Strict-only on purpose: these are rules about what the PRODUCER may emit, and\n * a consumer must not reject a whole payload because a later release relaxed one\n * (say, more groups per item).\n */\nfunction checkMenuOptions(menu: MenuShape, ctx: z.RefinementCtx): void {\n const issue = (path: (string | number)[], message: string) =>\n ctx.addIssue({ code: 'custom', path, message });\n\n const groups = menu.optionGroups ?? [];\n const groupNames = new Map<string, string>();\n const optionIds = new Set<string>();\n groups.forEach((group, g) => {\n const label = `Option group \"${group.name}\"`;\n if (groupNames.has(group.id)) issue(['optionGroups', g, 'id'], `${label} repeats another group's id`);\n groupNames.set(group.id, group.name);\n\n if (group.min > group.max) {\n issue(['optionGroups', g, 'min'], `${label} requires at least ${group.min} but allows at most ${group.max}`);\n }\n if (group.options.length > MAX_OPTIONS_PER_GROUP) {\n issue(\n ['optionGroups', g, 'options'],\n `${label} has ${group.options.length} options; the limit is ${MAX_OPTIONS_PER_GROUP}`,\n );\n }\n const available = group.options.filter((option) => option.isAvailable).length;\n if (group.min >= 1 && available === 0) {\n issue(['optionGroups', g, 'options'], `${label} is required but has no available options`);\n } else if (group.max > available) {\n issue(\n ['optionGroups', g, 'max'],\n `${label} allows up to ${group.max} choices but has only ${available} available option${available === 1 ? '' : 's'}`,\n );\n }\n group.options.forEach((option, k) => {\n if (optionIds.has(option.id)) {\n issue(['optionGroups', g, 'options', k, 'id'], `${label}: option \"${option.name}\" repeats another option's id`);\n }\n optionIds.add(option.id);\n });\n });\n\n // Deltas are in the item's currency, so one group cannot serve two currencies.\n const firstUse = new Map<string, { currency: string; itemName: string }>();\n menu.categories.forEach((category, c) => {\n category.items.forEach((item, i) => {\n const ids = item.optionGroupIds ?? [];\n const path = ['categories', c, 'items', i, 'optionGroupIds'];\n if (ids.length > MAX_OPTION_GROUPS_PER_ITEM) {\n issue(path, `Item \"${item.name}\" has ${ids.length} option groups; the limit is ${MAX_OPTION_GROUPS_PER_ITEM}`);\n }\n const seen = new Set<string>();\n ids.forEach((id, k) => {\n if (seen.has(id)) issue([...path, k], `Item \"${item.name}\" lists the same option group twice`);\n seen.add(id);\n const groupName = groupNames.get(id);\n if (groupName === undefined) {\n issue([...path, k], `Item \"${item.name}\" uses an option group that is not published`);\n return;\n }\n const prior = firstUse.get(id);\n if (!prior) {\n firstUse.set(id, { currency: item.price.currency, itemName: item.name });\n } else if (prior.currency !== item.price.currency) {\n issue(\n [...path, k],\n `Option group \"${groupName}\" is used by items in two currencies: ` +\n `\"${prior.itemName}\" (${prior.currency}) and \"${item.name}\" (${item.price.currency})`,\n );\n }\n });\n });\n });\n}\n\nexport const menuSchemaStrict = makeMenuSchema(strictFactory).superRefine(checkMenuOptions);\nexport const menuSchema = makeMenuSchema(tolerantFactory);\n\n// --- commerce --------------------------------------------------------------\n//\n// Online-ordering CONFIGURATION, never orders and never provider secrets\n// (docs/seams/commerce.md § \"What does NOT go in the module\"): a published\n// payload is world-readable immutable JSON, so a credential written here would\n// be permanent and public. Everything below is a public identifier or a display\n// value.\n//\n// `provider` is the one OPEN enum in the contract (docs/proposals/online-commerce\n// OC-2). The PRODUCER enum is closed and grows by one member per phase, only\n// when the platform can actually serve the new value — today `none | other`.\n// The CONSUMER schema accepts any well-formed provider name and hands it through\n// unchanged, so a consumer that predates a new provider keeps parsing.\n//\n// That second half did not exist before 1.2.0. Until then the tolerant schema\n// declared the same closed enum as the strict one, so an unknown provider made\n// the WHOLE payload fail to parse — the comment here claimed widening was\n// \"additive for a tolerant consumer\", and the code said otherwise. Tolerance for\n// unknown KEYS (the object factories) never covered unknown enum VALUES.\n//\n// Consumer rule (README § Open enums): an unknown provider means \"newer than\n// me\" — treat it like `other`: offer the `orderingUrl` hand-off when\n// `acceptsOnlineOrders` is true, and never show a cart.\n//\n// TIMES ARE LOCAL to `siteSettings.timezone` and carry no offset — the site's\n// own clock is what a customer reads on the door. `closes < opens` is LEGAL and\n// means \"closes after midnight\" (docs/seams/commerce.md § 1); a consumer must\n// not treat it as an error.\n\nconst makeCommerceHoursSchema = (o: ObjectFactory) =>\n z\n .array(\n o({\n day: z.enum(COMMERCE_DAYS),\n opens: z.string().regex(TIME_24H_PATTERN),\n closes: z.string().regex(TIME_24H_PATTERN),\n }),\n )\n .max(7)\n // At most one entry per day, listed in COMMERCE_DAYS order. The DB CHECK\n // stops at \"an array of at most 7\" (deliberately — see the migration\n // header), so THIS is the layer that makes `hours` meaningful, and it runs\n // at publish time rather than at write time.\n .superRefine((entries, ctx) => {\n const seen = new Set<string>();\n let previous = -1;\n entries.forEach((entry, index) => {\n if (seen.has(entry.day)) {\n ctx.addIssue({\n code: 'custom',\n path: [index, 'day'],\n message: `duplicate day \"${entry.day}\"`,\n });\n }\n seen.add(entry.day);\n const position = COMMERCE_DAYS.indexOf(entry.day);\n if (position < previous) {\n ctx.addIssue({\n code: 'custom',\n path: [index, 'day'],\n message: 'hours must be ordered mon → sun',\n });\n }\n previous = position;\n });\n });\n\n/** Providers the PRODUCER may publish. Grows one member per phase (OC-2). */\nexport const COMMERCE_PROVIDERS = ['none', 'other'] as const;\nexport type KnownCommerceProvider = (typeof COMMERCE_PROVIDERS)[number];\n\n/**\n * Shape of a provider name a TOLERANT consumer accepts: lowercase, starts with a\n * letter, at most 32 characters. Tolerance is for FUTURE values, not malformed\n * ones — `\"Stripe!\"` or `\"\"` still fail.\n */\nexport const COMMERCE_PROVIDER_PATTERN_SOURCE = '^[a-z][a-z0-9_]{0,31}$';\nexport const COMMERCE_PROVIDER_PATTERN = new RegExp(COMMERCE_PROVIDER_PATTERN_SOURCE);\n\nconst commerceProviderStrict = z.enum(COMMERCE_PROVIDERS);\nconst commerceProviderTolerant = z.union([\n z.enum(COMMERCE_PROVIDERS),\n z.string().regex(COMMERCE_PROVIDER_PATTERN),\n]);\n\n/** Narrow a tolerantly-parsed provider to one this version of the package knows. */\nexport function isKnownCommerceProvider(provider: string): provider is KnownCommerceProvider {\n return (COMMERCE_PROVIDERS as readonly string[]).includes(provider);\n}\n\ntype CommerceProviderSchema = typeof commerceProviderStrict | typeof commerceProviderTolerant;\n\nconst makeCommerceSchema = <P extends CommerceProviderSchema>(o: ObjectFactory, provider: P) =>\n o({\n provider,\n /** Where a customer is sent to order. Absent when `provider` is `none`. */\n orderingUrl: z.url({ protocol: /^https$/ }).max(2048).optional(),\n /** PUBLIC provider identifiers — never a key, token or secret. */\n locationRef: z.string().min(1).max(128).optional(),\n menuRef: z.string().min(1).max(128).optional(),\n /** Matches `menu` item prices; the DB CHECK mirrors CURRENCY_PATTERN_SOURCE. */\n currency: z.string().regex(CURRENCY_PATTERN),\n acceptsOnlineOrders: z.boolean(),\n /** Quoted preparation time. Meaningless — and normally absent — when ordering is off. */\n prepTimeMinutes: z.number().int().min(0).max(240).optional(),\n hours: makeCommerceHoursSchema(o),\n });\n\nexport const commerceSchemaStrict = makeCommerceSchema(strictFactory, commerceProviderStrict);\nexport const commerceSchema = makeCommerceSchema(tolerantFactory, commerceProviderTolerant);\n\n// --- content file ----------------------------------------------------------\n\n// `menu` is passed in rather than built from `o` for the same reason as the\n// provider: the two modes differ beyond the object factory — the strict menu\n// carries the option-group publish gate (`checkMenuOptions`), the tolerant one\n// does not.\nconst makeContentFileSchema = <P extends CommerceProviderSchema, M extends typeof menuSchema>(\n o: ObjectFactory,\n commerceProvider: P,\n menu: M,\n) =>\n o({\n schemaVersion: z.literal(1),\n site: o({ id: z.uuid(), slug: z.string().min(1) }),\n publishedAt: z.iso.datetime(),\n // The three v1 modules are REQUIRED; empty states are valid module payloads\n // (e.g. menu.categories: []), never missing keys. A new required module is\n // breaking (retained/rollback snapshots won't contain it) — bump\n // SCHEMA_VERSION instead.\n //\n // `commerce` is therefore OPTIONAL (phase-7 D-3), and that is not a\n // stylistic choice: every snapshot published before the module existed\n // lacks the key, and a ROLLBACK can activate one of those at any time. A\n // required `commerce` would retroactively invalidate the whole publish\n // history. See ARCHITECTURE.md § 5 and docs/seams/commerce.md § 1.\n modules: o({\n siteSettings: makeSiteSettingsSchema(o),\n branding: makeBrandingSchema(o),\n menu,\n commerce: makeCommerceSchema(o, commerceProvider).optional(),\n }),\n });\n\n/** Producer gate: the dashboard validates its own output against this. */\nexport const contentFileSchemaStrict = makeContentFileSchema(\n strictFactory,\n commerceProviderStrict,\n menuSchemaStrict,\n);\n/** Public consumer schema: tolerant of additive v1 changes. */\nexport const contentFileSchema = makeContentFileSchema(\n tolerantFactory,\n commerceProviderTolerant,\n menuSchema,\n);\n\nexport type SiteSettings = z.infer<typeof siteSettingsSchemaStrict>;\nexport type Branding = z.infer<typeof brandingSchemaStrict>;\nexport type Menu = z.infer<typeof menuSchemaStrict>;\nexport type MenuCategory = Menu['categories'][number];\nexport type MenuItem = MenuCategory['items'][number];\nexport type MenuOptionGroup = NonNullable<Menu['optionGroups']>[number];\nexport type MenuOption = MenuOptionGroup['options'][number];\nexport type Commerce = z.infer<typeof commerceSchemaStrict>;\nexport type CommerceDay = (typeof COMMERCE_DAYS)[number];\nexport type CommerceHours = Commerce['hours'];\nexport type ContentFile = z.infer<typeof contentFileSchemaStrict>;\n\n/**\n * What a CONSUMER gets back from `contentFileSchema.parse` (1.2.0+). Identical to\n * `ContentFile` except that open enums are widened — `commerce.provider` is any\n * well-formed string, because a payload may name a provider newer than the\n * consumer. Type parsed content with THIS, not with the producer's `ContentFile`:\n * annotating tolerant output as `ContentFile` claims the provider is one this\n * package version knows, which is exactly what tolerance cannot promise.\n */\nexport type ConsumerContentFile = z.infer<typeof contentFileSchema>;\nexport type ConsumerCommerce = z.infer<typeof commerceSchema>;\n\n// ---------------------------------------------------------------------------\n// Canonical serialization (phase-2.md T1.6).\n//\n// Recursively key-sorted JSON with no whitespace, produced by a hand-rolled\n// serializer rather than JSON.stringify over a re-built object — rebuilding\n// depends on JS insertion-order semantics and silently reorders integer-like\n// keys. Determinism here is what makes `hashContent` a byte-equality contract\n// shared by the publisher and every consumer test.\n//\n// Rules (documented in the README):\n// - arrays keep their order; object keys are sorted with Array.prototype.sort\n// (code-unit order);\n// - object properties whose value is `undefined` are omitted (JSON semantics);\n// - `undefined` anywhere else, non-finite numbers, bigint, functions and\n// symbols THROW — never silently coerced the way JSON.stringify would.\n// ---------------------------------------------------------------------------\n\nexport function canonicalize(payload: unknown): string {\n const out = serialize(payload);\n if (out === undefined) {\n throw new TypeError('canonicalize: payload itself serializes to nothing (undefined)');\n }\n return out;\n}\n\nfunction serialize(value: unknown): string | undefined {\n if (value === undefined) return undefined;\n if (value === null) return 'null';\n switch (typeof value) {\n case 'string':\n case 'boolean':\n return JSON.stringify(value);\n case 'number':\n if (!Number.isFinite(value)) {\n throw new TypeError(`canonicalize: non-finite number ${String(value)}`);\n }\n return JSON.stringify(value);\n case 'object':\n break;\n default:\n throw new TypeError(`canonicalize: unsupported value of type ${typeof value}`);\n }\n if (Array.isArray(value)) {\n const parts = value.map((entry) => {\n const s = serialize(entry);\n if (s === undefined) {\n throw new TypeError('canonicalize: undefined array element');\n }\n return s;\n });\n return `[${parts.join(',')}]`;\n }\n const record = value as Record<string, unknown>;\n const keys = Object.keys(record).sort();\n const parts: string[] = [];\n for (const key of keys) {\n const s = serialize(record[key]);\n if (s === undefined) continue; // undefined property -> omitted key\n parts.push(`${JSON.stringify(key)}:${s}`);\n }\n return `{${parts.join(',')}}`;\n}\n"],"mappings":";AAAA,SAAS,SAAS;AAQX,IAAM,iBAAiB;AAGvB,IAAM,oBAAoB;AAc1B,IAAM,wBAAwB;AAC9B,IAAM,iBAAiB,IAAI,OAAO,qBAAqB;AAEvD,IAAM,2BAA2B;AACjC,IAAM,oBAAoB,IAAI,OAAO,wBAAwB;AAG7D,IAAM,0BAA0B;AAChC,IAAM,mBAAmB,IAAI,OAAO,uBAAuB;AAG3D,IAAM,yBAAyB;AAC/B,IAAM,kBAAkB,IAAI,OAAO,sBAAsB;AAQzD,IAAM,gBAAgB,CAAC,OAAO,OAAO,OAAO,OAAO,OAAO,OAAO,KAAK;AAStE,IAAM,0BAA0B;AAChC,IAAM,mBAAmB,IAAI,OAAO,uBAAuB;AAG3D,SAAS,eAAe,IAAqB;AAClD,MAAI;AACF,QAAI,KAAK,eAAe,SAAS,EAAE,UAAU,GAAG,CAAC;AACjD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAqBA,IAAM,gBAAgB,EAAE;AACxB,IAAM,kBAAkB,EAAE;AAI1B,IAAM,yBAAyB,CAAC,MAC9B,EAAE;AAAA,EACA,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG;AAAA,EACnC,SAAS,EAAE,OAAO,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,EACtC,SAAS,EAAE;AAAA,IACT,OAAO,EAAE,MAAM,EAAE,SAAS;AAAA,IAC1B,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IAClC,SAAS,EAAE;AAAA,MACT,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACvB,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,MAClC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACtB,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MACxB,YAAY,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,MAC5B,SAAS,EAAE,OAAO,EAAE,MAAM,eAAe;AAAA,IAC3C,CAAC,EAAE,SAAS;AAAA,EACd,CAAC;AAAA;AAAA;AAAA,EAGD,aAAa,EAAE;AAAA,IACb,UAAU,EAAE,IAAI,EAAE,SAAS;AAAA,IAC3B,WAAW,EAAE,IAAI,EAAE,SAAS;AAAA,IAC5B,QAAQ,EAAE,IAAI,EAAE,SAAS;AAAA,IACzB,GAAG,EAAE,IAAI,EAAE,SAAS;AAAA,IACpB,SAAS,EAAE,IAAI,EAAE,SAAS;AAAA,IAC1B,MAAM,EAAE,IAAI,EAAE,SAAS;AAAA,IACvB,QAAQ,EAAE,IAAI,EAAE,SAAS;AAAA,EAC3B,CAAC,EAAE,SAAS;AAAA,EACZ,QAAQ,EAAE,OAAO,EAAE,MAAM,cAAc,EAAE,QAAQ,OAAO;AAAA,EACxD,UAAU,EACP,OAAO,EACP,IAAI,CAAC,EACL,OAAO,gBAAgB,gCAAgC,EACvD,QAAQ,qBAAqB;AAClC,CAAC;AAEI,IAAM,2BAA2B,uBAAuB,aAAa;AACrE,IAAM,qBAAqB,uBAAuB,eAAe;AAIxE,IAAM,qBAAqB,CAAC,MAC1B,EAAE;AAAA;AAAA;AAAA;AAAA;AAAA,EAKA,SAAS,EAAE,IAAI,EAAE,SAAS;AAAA,EAC1B,YAAY,EAAE,IAAI,EAAE,SAAS;AAAA,EAC7B,QAAQ,EAAE;AAAA,IACR,SAAS,EAAE,OAAO,EAAE,MAAM,iBAAiB;AAAA,IAC3C,WAAW,EAAE,OAAO,EAAE,MAAM,iBAAiB,EAAE,SAAS;AAAA,IACxD,QAAQ,EAAE,OAAO,EAAE,MAAM,iBAAiB,EAAE,SAAS;AAAA,IACrD,YAAY,EAAE,OAAO,EAAE,MAAM,iBAAiB,EAAE,SAAS;AAAA,IACzD,MAAM,EAAE,OAAO,EAAE,MAAM,iBAAiB,EAAE,SAAS;AAAA,EACrD,CAAC;AAAA,EACD,OAAO,EAAE;AAAA,IACP,SAAS,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,IACpC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AAAA,EACnC,CAAC,EAAE,SAAS;AACd,CAAC;AAEI,IAAM,uBAAuB,mBAAmB,aAAa;AAC7D,IAAM,iBAAiB,mBAAmB,eAAe;AAqBzD,IAAM,6BAA6B;AAEnC,IAAM,wBAAwB;AAE9B,IAAM,oBAAoB;AAE1B,IAAM,yBAAyB;AAEtC,IAAM,uBAAuB,CAAC,MAC5B,EAAE;AAAA,EACA,IAAI,EAAE,KAAK;AAAA,EACX,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG;AAAA;AAAA,EAE/B,YAAY,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,sBAAsB;AAAA,EAC9D,aAAa,EAAE,QAAQ;AAAA,EACvB,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAO;AAChD,CAAC;AAEH,IAAM,4BAA4B,CAAC,MACjC,EAAE;AAAA,EACA,IAAI,EAAE,KAAK;AAAA,EACX,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG;AAAA;AAAA,EAE/B,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,iBAAiB;AAAA;AAAA,EAElD,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,iBAAiB;AAAA,EAClD,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAO;AAAA;AAAA,EAE9C,SAAS,EAAE,MAAM,qBAAqB,CAAC,CAAC;AAC1C,CAAC;AAEH,IAAM,qBAAqB,CAAC,MAC1B,EAAE;AAAA;AAAA,EAEA,IAAI,EAAE,KAAK;AAAA,EACX,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG;AAAA,EAC/B,aAAa,EAAE,OAAO,EAAE,IAAI,GAAI,EAAE,SAAS;AAAA,EAC3C,OAAO,EAAE;AAAA;AAAA,IAEP,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC;AAAA,IAC9B,UAAU,EAAE,OAAO,EAAE,MAAM,gBAAgB;AAAA,EAC7C,CAAC;AAAA,EACD,UAAU,EAAE,IAAI,EAAE,SAAS;AAAA,EAC3B,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE;AAAA,EACvC,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAO;AAAA,EAC9C,aAAa,EAAE,QAAQ;AAAA;AAAA,EAEvB,gBAAgB,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,SAAS;AAC7C,CAAC;AAEH,IAAM,yBAAyB,CAAC,MAC9B,EAAE;AAAA,EACA,IAAI,EAAE,KAAK;AAAA,EACX,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG;AAAA,EAC/B,aAAa,EAAE,OAAO,EAAE,IAAI,GAAI,EAAE,SAAS;AAAA,EAC3C,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAO;AAAA,EAC9C,OAAO,EAAE,MAAM,mBAAmB,CAAC,CAAC;AACtC,CAAC;AAEH,IAAM,iBAAiB,CAAC,MACtB,EAAE;AAAA;AAAA,EAEA,YAAY,EAAE,MAAM,uBAAuB,CAAC,CAAC;AAAA;AAAA,EAE7C,cAAc,EAAE,MAAM,0BAA0B,CAAC,CAAC,EAAE,SAAS;AAC/D,CAAC;AAcH,SAAS,iBAAiB,MAAiB,KAA4B;AACrE,QAAM,QAAQ,CAAC,MAA2B,YACxC,IAAI,SAAS,EAAE,MAAM,UAAU,MAAM,QAAQ,CAAC;AAEhD,QAAM,SAAS,KAAK,gBAAgB,CAAC;AACrC,QAAM,aAAa,oBAAI,IAAoB;AAC3C,QAAM,YAAY,oBAAI,IAAY;AAClC,SAAO,QAAQ,CAAC,OAAO,MAAM;AAC3B,UAAM,QAAQ,iBAAiB,MAAM,IAAI;AACzC,QAAI,WAAW,IAAI,MAAM,EAAE,EAAG,OAAM,CAAC,gBAAgB,GAAG,IAAI,GAAG,GAAG,KAAK,6BAA6B;AACpG,eAAW,IAAI,MAAM,IAAI,MAAM,IAAI;AAEnC,QAAI,MAAM,MAAM,MAAM,KAAK;AACzB,YAAM,CAAC,gBAAgB,GAAG,KAAK,GAAG,GAAG,KAAK,sBAAsB,MAAM,GAAG,uBAAuB,MAAM,GAAG,EAAE;AAAA,IAC7G;AACA,QAAI,MAAM,QAAQ,SAAS,uBAAuB;AAChD;AAAA,QACE,CAAC,gBAAgB,GAAG,SAAS;AAAA,QAC7B,GAAG,KAAK,QAAQ,MAAM,QAAQ,MAAM,0BAA0B,qBAAqB;AAAA,MACrF;AAAA,IACF;AACA,UAAM,YAAY,MAAM,QAAQ,OAAO,CAAC,WAAW,OAAO,WAAW,EAAE;AACvE,QAAI,MAAM,OAAO,KAAK,cAAc,GAAG;AACrC,YAAM,CAAC,gBAAgB,GAAG,SAAS,GAAG,GAAG,KAAK,2CAA2C;AAAA,IAC3F,WAAW,MAAM,MAAM,WAAW;AAChC;AAAA,QACE,CAAC,gBAAgB,GAAG,KAAK;AAAA,QACzB,GAAG,KAAK,iBAAiB,MAAM,GAAG,yBAAyB,SAAS,oBAAoB,cAAc,IAAI,KAAK,GAAG;AAAA,MACpH;AAAA,IACF;AACA,UAAM,QAAQ,QAAQ,CAAC,QAAQ,MAAM;AACnC,UAAI,UAAU,IAAI,OAAO,EAAE,GAAG;AAC5B,cAAM,CAAC,gBAAgB,GAAG,WAAW,GAAG,IAAI,GAAG,GAAG,KAAK,aAAa,OAAO,IAAI,+BAA+B;AAAA,MAChH;AACA,gBAAU,IAAI,OAAO,EAAE;AAAA,IACzB,CAAC;AAAA,EACH,CAAC;AAGD,QAAM,WAAW,oBAAI,IAAoD;AACzE,OAAK,WAAW,QAAQ,CAAC,UAAU,MAAM;AACvC,aAAS,MAAM,QAAQ,CAAC,MAAM,MAAM;AAClC,YAAM,MAAM,KAAK,kBAAkB,CAAC;AACpC,YAAM,OAAO,CAAC,cAAc,GAAG,SAAS,GAAG,gBAAgB;AAC3D,UAAI,IAAI,SAAS,4BAA4B;AAC3C,cAAM,MAAM,SAAS,KAAK,IAAI,SAAS,IAAI,MAAM,gCAAgC,0BAA0B,EAAE;AAAA,MAC/G;AACA,YAAM,OAAO,oBAAI,IAAY;AAC7B,UAAI,QAAQ,CAAC,IAAI,MAAM;AACrB,YAAI,KAAK,IAAI,EAAE,EAAG,OAAM,CAAC,GAAG,MAAM,CAAC,GAAG,SAAS,KAAK,IAAI,qCAAqC;AAC7F,aAAK,IAAI,EAAE;AACX,cAAM,YAAY,WAAW,IAAI,EAAE;AACnC,YAAI,cAAc,QAAW;AAC3B,gBAAM,CAAC,GAAG,MAAM,CAAC,GAAG,SAAS,KAAK,IAAI,8CAA8C;AACpF;AAAA,QACF;AACA,cAAM,QAAQ,SAAS,IAAI,EAAE;AAC7B,YAAI,CAAC,OAAO;AACV,mBAAS,IAAI,IAAI,EAAE,UAAU,KAAK,MAAM,UAAU,UAAU,KAAK,KAAK,CAAC;AAAA,QACzE,WAAW,MAAM,aAAa,KAAK,MAAM,UAAU;AACjD;AAAA,YACE,CAAC,GAAG,MAAM,CAAC;AAAA,YACX,iBAAiB,SAAS,0CACpB,MAAM,QAAQ,MAAM,MAAM,QAAQ,UAAU,KAAK,IAAI,MAAM,KAAK,MAAM,QAAQ;AAAA,UACtF;AAAA,QACF;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AACH;AAEO,IAAM,mBAAmB,eAAe,aAAa,EAAE,YAAY,gBAAgB;AACnF,IAAM,aAAa,eAAe,eAAe;AA+BxD,IAAM,0BAA0B,CAAC,MAC/B,EACG;AAAA,EACC,EAAE;AAAA,IACA,KAAK,EAAE,KAAK,aAAa;AAAA,IACzB,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB;AAAA,IACxC,QAAQ,EAAE,OAAO,EAAE,MAAM,gBAAgB;AAAA,EAC3C,CAAC;AACH,EACC,IAAI,CAAC,EAKL,YAAY,CAAC,SAAS,QAAQ;AAC7B,QAAM,OAAO,oBAAI,IAAY;AAC7B,MAAI,WAAW;AACf,UAAQ,QAAQ,CAAC,OAAO,UAAU;AAChC,QAAI,KAAK,IAAI,MAAM,GAAG,GAAG;AACvB,UAAI,SAAS;AAAA,QACX,MAAM;AAAA,QACN,MAAM,CAAC,OAAO,KAAK;AAAA,QACnB,SAAS,kBAAkB,MAAM,GAAG;AAAA,MACtC,CAAC;AAAA,IACH;AACA,SAAK,IAAI,MAAM,GAAG;AAClB,UAAM,WAAW,cAAc,QAAQ,MAAM,GAAG;AAChD,QAAI,WAAW,UAAU;AACvB,UAAI,SAAS;AAAA,QACX,MAAM;AAAA,QACN,MAAM,CAAC,OAAO,KAAK;AAAA,QACnB,SAAS;AAAA,MACX,CAAC;AAAA,IACH;AACA,eAAW;AAAA,EACb,CAAC;AACH,CAAC;AAGE,IAAM,qBAAqB,CAAC,QAAQ,OAAO;AAQ3C,IAAM,mCAAmC;AACzC,IAAM,4BAA4B,IAAI,OAAO,gCAAgC;AAEpF,IAAM,yBAAyB,EAAE,KAAK,kBAAkB;AACxD,IAAM,2BAA2B,EAAE,MAAM;AAAA,EACvC,EAAE,KAAK,kBAAkB;AAAA,EACzB,EAAE,OAAO,EAAE,MAAM,yBAAyB;AAC5C,CAAC;AAGM,SAAS,wBAAwB,UAAqD;AAC3F,SAAQ,mBAAyC,SAAS,QAAQ;AACpE;AAIA,IAAM,qBAAqB,CAAmC,GAAkB,aAC9E,EAAE;AAAA,EACA;AAAA;AAAA,EAEA,aAAa,EAAE,IAAI,EAAE,UAAU,UAAU,CAAC,EAAE,IAAI,IAAI,EAAE,SAAS;AAAA;AAAA,EAE/D,aAAa,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,EACjD,SAAS,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA;AAAA,EAE7C,UAAU,EAAE,OAAO,EAAE,MAAM,gBAAgB;AAAA,EAC3C,qBAAqB,EAAE,QAAQ;AAAA;AAAA,EAE/B,iBAAiB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,EAC3D,OAAO,wBAAwB,CAAC;AAClC,CAAC;AAEI,IAAM,uBAAuB,mBAAmB,eAAe,sBAAsB;AACrF,IAAM,iBAAiB,mBAAmB,iBAAiB,wBAAwB;AAQ1F,IAAM,wBAAwB,CAC5B,GACA,kBACA,SAEA,EAAE;AAAA,EACA,eAAe,EAAE,QAAQ,CAAC;AAAA,EAC1B,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC;AAAA,EACjD,aAAa,EAAE,IAAI,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAW5B,SAAS,EAAE;AAAA,IACT,cAAc,uBAAuB,CAAC;AAAA,IACtC,UAAU,mBAAmB,CAAC;AAAA,IAC9B;AAAA,IACA,UAAU,mBAAmB,GAAG,gBAAgB,EAAE,SAAS;AAAA,EAC7D,CAAC;AACH,CAAC;AAGI,IAAM,0BAA0B;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AACF;AAEO,IAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AACF;AA0CO,SAAS,aAAa,SAA0B;AACrD,QAAM,MAAM,UAAU,OAAO;AAC7B,MAAI,QAAQ,QAAW;AACrB,UAAM,IAAI,UAAU,gEAAgE;AAAA,EACtF;AACA,SAAO;AACT;AAEA,SAAS,UAAU,OAAoC;AACrD,MAAI,UAAU,OAAW,QAAO;AAChC,MAAI,UAAU,KAAM,QAAO;AAC3B,UAAQ,OAAO,OAAO;AAAA,IACpB,KAAK;AAAA,IACL,KAAK;AACH,aAAO,KAAK,UAAU,KAAK;AAAA,IAC7B,KAAK;AACH,UAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAC3B,cAAM,IAAI,UAAU,mCAAmC,OAAO,KAAK,CAAC,EAAE;AAAA,MACxE;AACA,aAAO,KAAK,UAAU,KAAK;AAAA,IAC7B,KAAK;AACH;AAAA,IACF;AACE,YAAM,IAAI,UAAU,2CAA2C,OAAO,KAAK,EAAE;AAAA,EACjF;AACA,MAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,UAAMA,SAAQ,MAAM,IAAI,CAAC,UAAU;AACjC,YAAM,IAAI,UAAU,KAAK;AACzB,UAAI,MAAM,QAAW;AACnB,cAAM,IAAI,UAAU,uCAAuC;AAAA,MAC7D;AACA,aAAO;AAAA,IACT,CAAC;AACD,WAAO,IAAIA,OAAM,KAAK,GAAG,CAAC;AAAA,EAC5B;AACA,QAAM,SAAS;AACf,QAAM,OAAO,OAAO,KAAK,MAAM,EAAE,KAAK;AACtC,QAAM,QAAkB,CAAC;AACzB,aAAW,OAAO,MAAM;AACtB,UAAM,IAAI,UAAU,OAAO,GAAG,CAAC;AAC/B,QAAI,MAAM,OAAW;AACrB,UAAM,KAAK,GAAG,KAAK,UAAU,GAAG,CAAC,IAAI,CAAC,EAAE;AAAA,EAC1C;AACA,SAAO,IAAI,MAAM,KAAK,GAAG,CAAC;AAC5B;","names":["parts"]}
package/dist/hash.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  canonicalize
3
- } from "./chunk-GUUCVLL3.js";
3
+ } from "./chunk-7B6AENRI.js";
4
4
 
5
5
  // src/hash.ts
6
6
  import { createHash } from "crypto";
package/dist/index.d.ts CHANGED
@@ -121,6 +121,14 @@ declare const brandingSchema: z.ZodObject<{
121
121
  body: z.ZodOptional<z.ZodString>;
122
122
  }, z.core.$strip>>;
123
123
  }, z.core.$strip>;
124
+ /** At most this many option groups on one item (publish-gated, not DB-enforced). */
125
+ declare const MAX_OPTION_GROUPS_PER_ITEM = 10;
126
+ /** At most this many published options in one group (publish-gated). */
127
+ declare const MAX_OPTIONS_PER_GROUP = 30;
128
+ /** Upper bound of a group's `min`/`max` (mirrors the DB CHECK). */
129
+ declare const MAX_OPTION_SELECT = 30;
130
+ /** Upper bound of an option's `priceDelta`, minor units (mirrors the DB CHECK). */
131
+ declare const MAX_OPTION_PRICE_DELTA = 100000;
124
132
  declare const menuSchemaStrict: z.ZodObject<{
125
133
  /** Archived/deleted entities are simply ABSENT; empty menu = `[]`, never a missing key. */
126
134
  categories: z.ZodArray<z.ZodObject<{
@@ -142,8 +150,29 @@ declare const menuSchemaStrict: z.ZodObject<{
142
150
  tags: z.ZodArray<z.ZodString>;
143
151
  sortOrder: z.ZodNumber;
144
152
  isAvailable: z.ZodBoolean;
153
+ /** 1.3.0: ids into `menu.optionGroups`, in display order. Absent ≡ `[]`. */
154
+ optionGroupIds: z.ZodOptional<z.ZodArray<z.ZodUUID>>;
145
155
  }, z.core.$strip>>;
146
156
  }, z.core.$strip>>;
157
+ /** 1.3.0: the site's option groups (archived ones absent). Absent ≡ `[]`. */
158
+ optionGroups: z.ZodOptional<z.ZodArray<z.ZodObject<{
159
+ id: z.ZodUUID;
160
+ name: z.ZodString;
161
+ /** Fewest options a customer must choose; ≥ 1 makes the group required. */
162
+ min: z.ZodNumber;
163
+ /** Most options a customer may choose. */
164
+ max: z.ZodNumber;
165
+ sortOrder: z.ZodNumber;
166
+ /** Archived options are absent; an unavailable one is present with `isAvailable: false`. */
167
+ options: z.ZodArray<z.ZodObject<{
168
+ id: z.ZodUUID;
169
+ name: z.ZodString;
170
+ /** Integer minor units, ≥ 0, in the currency of the item it is chosen on. */
171
+ priceDelta: z.ZodNumber;
172
+ isAvailable: z.ZodBoolean;
173
+ sortOrder: z.ZodNumber;
174
+ }, z.core.$strip>>;
175
+ }, z.core.$strip>>>;
147
176
  }, z.core.$strip>;
148
177
  declare const menuSchema: z.ZodObject<{
149
178
  /** Archived/deleted entities are simply ABSENT; empty menu = `[]`, never a missing key. */
@@ -166,9 +195,42 @@ declare const menuSchema: z.ZodObject<{
166
195
  tags: z.ZodArray<z.ZodString>;
167
196
  sortOrder: z.ZodNumber;
168
197
  isAvailable: z.ZodBoolean;
198
+ /** 1.3.0: ids into `menu.optionGroups`, in display order. Absent ≡ `[]`. */
199
+ optionGroupIds: z.ZodOptional<z.ZodArray<z.ZodUUID>>;
169
200
  }, z.core.$strip>>;
170
201
  }, z.core.$strip>>;
202
+ /** 1.3.0: the site's option groups (archived ones absent). Absent ≡ `[]`. */
203
+ optionGroups: z.ZodOptional<z.ZodArray<z.ZodObject<{
204
+ id: z.ZodUUID;
205
+ name: z.ZodString;
206
+ /** Fewest options a customer must choose; ≥ 1 makes the group required. */
207
+ min: z.ZodNumber;
208
+ /** Most options a customer may choose. */
209
+ max: z.ZodNumber;
210
+ sortOrder: z.ZodNumber;
211
+ /** Archived options are absent; an unavailable one is present with `isAvailable: false`. */
212
+ options: z.ZodArray<z.ZodObject<{
213
+ id: z.ZodUUID;
214
+ name: z.ZodString;
215
+ /** Integer minor units, ≥ 0, in the currency of the item it is chosen on. */
216
+ priceDelta: z.ZodNumber;
217
+ isAvailable: z.ZodBoolean;
218
+ sortOrder: z.ZodNumber;
219
+ }, z.core.$strip>>;
220
+ }, z.core.$strip>>>;
171
221
  }, z.core.$strip>;
222
+ /** Providers the PRODUCER may publish. Grows one member per phase (OC-2). */
223
+ declare const COMMERCE_PROVIDERS: readonly ["none", "other"];
224
+ type KnownCommerceProvider = (typeof COMMERCE_PROVIDERS)[number];
225
+ /**
226
+ * Shape of a provider name a TOLERANT consumer accepts: lowercase, starts with a
227
+ * letter, at most 32 characters. Tolerance is for FUTURE values, not malformed
228
+ * ones — `"Stripe!"` or `""` still fail.
229
+ */
230
+ declare const COMMERCE_PROVIDER_PATTERN_SOURCE = "^[a-z][a-z0-9_]{0,31}$";
231
+ declare const COMMERCE_PROVIDER_PATTERN: RegExp;
232
+ /** Narrow a tolerantly-parsed provider to one this version of the package knows. */
233
+ declare function isKnownCommerceProvider(provider: string): provider is KnownCommerceProvider;
172
234
  declare const commerceSchemaStrict: z.ZodObject<{
173
235
  provider: z.ZodEnum<{
174
236
  none: "none";
@@ -199,10 +261,10 @@ declare const commerceSchemaStrict: z.ZodObject<{
199
261
  }, z.core.$strip>>;
200
262
  }, z.core.$strip>;
201
263
  declare const commerceSchema: z.ZodObject<{
202
- provider: z.ZodEnum<{
264
+ provider: z.ZodUnion<readonly [z.ZodEnum<{
203
265
  none: "none";
204
266
  other: "other";
205
- }>;
267
+ }>, z.ZodString]>;
206
268
  /** Where a customer is sent to order. Absent when `provider` is `none`. */
207
269
  orderingUrl: z.ZodOptional<z.ZodURL>;
208
270
  /** PUBLIC provider identifiers — never a key, token or secret. */
@@ -299,8 +361,29 @@ declare const contentFileSchemaStrict: z.ZodObject<{
299
361
  tags: z.ZodArray<z.ZodString>;
300
362
  sortOrder: z.ZodNumber;
301
363
  isAvailable: z.ZodBoolean;
364
+ /** 1.3.0: ids into `menu.optionGroups`, in display order. Absent ≡ `[]`. */
365
+ optionGroupIds: z.ZodOptional<z.ZodArray<z.ZodUUID>>;
302
366
  }, z.core.$strip>>;
303
367
  }, z.core.$strip>>;
368
+ /** 1.3.0: the site's option groups (archived ones absent). Absent ≡ `[]`. */
369
+ optionGroups: z.ZodOptional<z.ZodArray<z.ZodObject<{
370
+ id: z.ZodUUID;
371
+ name: z.ZodString;
372
+ /** Fewest options a customer must choose; ≥ 1 makes the group required. */
373
+ min: z.ZodNumber;
374
+ /** Most options a customer may choose. */
375
+ max: z.ZodNumber;
376
+ sortOrder: z.ZodNumber;
377
+ /** Archived options are absent; an unavailable one is present with `isAvailable: false`. */
378
+ options: z.ZodArray<z.ZodObject<{
379
+ id: z.ZodUUID;
380
+ name: z.ZodString;
381
+ /** Integer minor units, ≥ 0, in the currency of the item it is chosen on. */
382
+ priceDelta: z.ZodNumber;
383
+ isAvailable: z.ZodBoolean;
384
+ sortOrder: z.ZodNumber;
385
+ }, z.core.$strip>>;
386
+ }, z.core.$strip>>>;
304
387
  }, z.core.$strip>;
305
388
  commerce: z.ZodOptional<z.ZodObject<{
306
389
  provider: z.ZodEnum<{
@@ -405,14 +488,35 @@ declare const contentFileSchema: z.ZodObject<{
405
488
  tags: z.ZodArray<z.ZodString>;
406
489
  sortOrder: z.ZodNumber;
407
490
  isAvailable: z.ZodBoolean;
491
+ /** 1.3.0: ids into `menu.optionGroups`, in display order. Absent ≡ `[]`. */
492
+ optionGroupIds: z.ZodOptional<z.ZodArray<z.ZodUUID>>;
408
493
  }, z.core.$strip>>;
409
494
  }, z.core.$strip>>;
495
+ /** 1.3.0: the site's option groups (archived ones absent). Absent ≡ `[]`. */
496
+ optionGroups: z.ZodOptional<z.ZodArray<z.ZodObject<{
497
+ id: z.ZodUUID;
498
+ name: z.ZodString;
499
+ /** Fewest options a customer must choose; ≥ 1 makes the group required. */
500
+ min: z.ZodNumber;
501
+ /** Most options a customer may choose. */
502
+ max: z.ZodNumber;
503
+ sortOrder: z.ZodNumber;
504
+ /** Archived options are absent; an unavailable one is present with `isAvailable: false`. */
505
+ options: z.ZodArray<z.ZodObject<{
506
+ id: z.ZodUUID;
507
+ name: z.ZodString;
508
+ /** Integer minor units, ≥ 0, in the currency of the item it is chosen on. */
509
+ priceDelta: z.ZodNumber;
510
+ isAvailable: z.ZodBoolean;
511
+ sortOrder: z.ZodNumber;
512
+ }, z.core.$strip>>;
513
+ }, z.core.$strip>>>;
410
514
  }, z.core.$strip>;
411
515
  commerce: z.ZodOptional<z.ZodObject<{
412
- provider: z.ZodEnum<{
516
+ provider: z.ZodUnion<readonly [z.ZodEnum<{
413
517
  none: "none";
414
518
  other: "other";
415
- }>;
519
+ }>, z.ZodString]>;
416
520
  /** Where a customer is sent to order. Absent when `provider` is `none`. */
417
521
  orderingUrl: z.ZodOptional<z.ZodURL>;
418
522
  /** PUBLIC provider identifiers — never a key, token or secret. */
@@ -444,10 +548,22 @@ type Branding = z.infer<typeof brandingSchemaStrict>;
444
548
  type Menu = z.infer<typeof menuSchemaStrict>;
445
549
  type MenuCategory = Menu['categories'][number];
446
550
  type MenuItem = MenuCategory['items'][number];
551
+ type MenuOptionGroup = NonNullable<Menu['optionGroups']>[number];
552
+ type MenuOption = MenuOptionGroup['options'][number];
447
553
  type Commerce = z.infer<typeof commerceSchemaStrict>;
448
554
  type CommerceDay = (typeof COMMERCE_DAYS)[number];
449
555
  type CommerceHours = Commerce['hours'];
450
556
  type ContentFile = z.infer<typeof contentFileSchemaStrict>;
557
+ /**
558
+ * What a CONSUMER gets back from `contentFileSchema.parse` (1.2.0+). Identical to
559
+ * `ContentFile` except that open enums are widened — `commerce.provider` is any
560
+ * well-formed string, because a payload may name a provider newer than the
561
+ * consumer. Type parsed content with THIS, not with the producer's `ContentFile`:
562
+ * annotating tolerant output as `ContentFile` claims the provider is one this
563
+ * package version knows, which is exactly what tolerance cannot promise.
564
+ */
565
+ type ConsumerContentFile = z.infer<typeof contentFileSchema>;
566
+ type ConsumerCommerce = z.infer<typeof commerceSchema>;
451
567
  declare function canonicalize(payload: unknown): string;
452
568
 
453
- export { type Branding, COMMERCE_DAYS, COUNTRY_PATTERN, COUNTRY_PATTERN_SOURCE, CURRENCY_PATTERN, CURRENCY_PATTERN_SOURCE, type Commerce, type CommerceDay, type CommerceHours, type ContentFile, HEX_COLOR_PATTERN, HEX_COLOR_PATTERN_SOURCE, LOCALE_PATTERN, LOCALE_PATTERN_SOURCE, MAX_CONTENT_BYTES, type Menu, type MenuCategory, type MenuItem, SCHEMA_VERSION, type SiteSettings, TIME_24H_PATTERN, TIME_24H_PATTERN_SOURCE, brandingSchema, brandingSchemaStrict, canonicalize, commerceSchema, commerceSchemaStrict, contentFileSchema, contentFileSchemaStrict, isIanaTimeZone, menuSchema, menuSchemaStrict, siteSettingsSchema, siteSettingsSchemaStrict };
569
+ export { type Branding, COMMERCE_DAYS, COMMERCE_PROVIDERS, COMMERCE_PROVIDER_PATTERN, COMMERCE_PROVIDER_PATTERN_SOURCE, COUNTRY_PATTERN, COUNTRY_PATTERN_SOURCE, CURRENCY_PATTERN, CURRENCY_PATTERN_SOURCE, type Commerce, type CommerceDay, type CommerceHours, type ConsumerCommerce, type ConsumerContentFile, type ContentFile, HEX_COLOR_PATTERN, HEX_COLOR_PATTERN_SOURCE, type KnownCommerceProvider, LOCALE_PATTERN, LOCALE_PATTERN_SOURCE, MAX_CONTENT_BYTES, MAX_OPTIONS_PER_GROUP, MAX_OPTION_GROUPS_PER_ITEM, MAX_OPTION_PRICE_DELTA, MAX_OPTION_SELECT, type Menu, type MenuCategory, type MenuItem, type MenuOption, type MenuOptionGroup, SCHEMA_VERSION, type SiteSettings, TIME_24H_PATTERN, TIME_24H_PATTERN_SOURCE, brandingSchema, brandingSchemaStrict, canonicalize, commerceSchema, commerceSchemaStrict, contentFileSchema, contentFileSchemaStrict, isIanaTimeZone, isKnownCommerceProvider, menuSchema, menuSchemaStrict, siteSettingsSchema, siteSettingsSchemaStrict };
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import {
2
2
  COMMERCE_DAYS,
3
+ COMMERCE_PROVIDERS,
4
+ COMMERCE_PROVIDER_PATTERN,
5
+ COMMERCE_PROVIDER_PATTERN_SOURCE,
3
6
  COUNTRY_PATTERN,
4
7
  COUNTRY_PATTERN_SOURCE,
5
8
  CURRENCY_PATTERN,
@@ -9,6 +12,10 @@ import {
9
12
  LOCALE_PATTERN,
10
13
  LOCALE_PATTERN_SOURCE,
11
14
  MAX_CONTENT_BYTES,
15
+ MAX_OPTIONS_PER_GROUP,
16
+ MAX_OPTION_GROUPS_PER_ITEM,
17
+ MAX_OPTION_PRICE_DELTA,
18
+ MAX_OPTION_SELECT,
12
19
  SCHEMA_VERSION,
13
20
  TIME_24H_PATTERN,
14
21
  TIME_24H_PATTERN_SOURCE,
@@ -20,13 +27,17 @@ import {
20
27
  contentFileSchema,
21
28
  contentFileSchemaStrict,
22
29
  isIanaTimeZone,
30
+ isKnownCommerceProvider,
23
31
  menuSchema,
24
32
  menuSchemaStrict,
25
33
  siteSettingsSchema,
26
34
  siteSettingsSchemaStrict
27
- } from "./chunk-GUUCVLL3.js";
35
+ } from "./chunk-7B6AENRI.js";
28
36
  export {
29
37
  COMMERCE_DAYS,
38
+ COMMERCE_PROVIDERS,
39
+ COMMERCE_PROVIDER_PATTERN,
40
+ COMMERCE_PROVIDER_PATTERN_SOURCE,
30
41
  COUNTRY_PATTERN,
31
42
  COUNTRY_PATTERN_SOURCE,
32
43
  CURRENCY_PATTERN,
@@ -36,6 +47,10 @@ export {
36
47
  LOCALE_PATTERN,
37
48
  LOCALE_PATTERN_SOURCE,
38
49
  MAX_CONTENT_BYTES,
50
+ MAX_OPTIONS_PER_GROUP,
51
+ MAX_OPTION_GROUPS_PER_ITEM,
52
+ MAX_OPTION_PRICE_DELTA,
53
+ MAX_OPTION_SELECT,
39
54
  SCHEMA_VERSION,
40
55
  TIME_24H_PATTERN,
41
56
  TIME_24H_PATTERN_SOURCE,
@@ -47,6 +62,7 @@ export {
47
62
  contentFileSchema,
48
63
  contentFileSchemaStrict,
49
64
  isIanaTimeZone,
65
+ isKnownCommerceProvider,
50
66
  menuSchema,
51
67
  menuSchemaStrict,
52
68
  siteSettingsSchema,
@@ -67,7 +67,8 @@
67
67
  "imageUrl": "https://example.supabase.co/storage/v1/object/public/assets/org_01example7fixture0000000000/b7e6a1d2-3c4f-4a5b-9e8d-1f2a3b4c5d6e/fd61a03af4f77d870fc21e05e7e80678095c92d808cfb3b5c279ee04c74aca13.webp",
68
68
  "tags": ["signature", "gluten-free"],
69
69
  "sortOrder": 0,
70
- "isAvailable": true
70
+ "isAvailable": true,
71
+ "optionGroupIds": ["c0693af5-7491-4b0b-bf44-b26acc3e2bee", "9c80e14f-f97a-41fc-87dc-ddf624aea9da"]
71
72
  },
72
73
  {
73
74
  "id": "6f5e4d3c-2b1a-4098-a7b6-c5d4e3f2a1b0",
@@ -79,7 +80,8 @@
79
80
  },
80
81
  "tags": ["gluten-free"],
81
82
  "sortOrder": 1,
82
- "isAvailable": true
83
+ "isAvailable": true,
84
+ "optionGroupIds": ["c0693af5-7491-4b0b-bf44-b26acc3e2bee", "9c80e14f-f97a-41fc-87dc-ddf624aea9da"]
83
85
  },
84
86
  {
85
87
  "id": "7a6b5c4d-3e2f-4109-b8a7-d6c5b4a3f2e1",
@@ -110,7 +112,8 @@
110
112
  },
111
113
  "tags": ["cold"],
112
114
  "sortOrder": 0,
113
- "isAvailable": true
115
+ "isAvailable": true,
116
+ "optionGroupIds": ["1f0a2da4-8111-47a9-be63-3ba3d8b820c1"]
114
117
  },
115
118
  {
116
119
  "id": "9c8b7a6d-5e4f-4321-90c9-f8e7d6c5b4a3",
@@ -125,6 +128,84 @@
125
128
  }
126
129
  ]
127
130
  }
131
+ ],
132
+ "optionGroups": [
133
+ {
134
+ "id": "c0693af5-7491-4b0b-bf44-b26acc3e2bee",
135
+ "name": "Size",
136
+ "min": 1,
137
+ "max": 1,
138
+ "sortOrder": 0,
139
+ "options": [
140
+ {
141
+ "id": "f02dd4ed-59bf-4285-928d-2c2f03d78c63",
142
+ "name": "Regular",
143
+ "priceDelta": 0,
144
+ "isAvailable": true,
145
+ "sortOrder": 0
146
+ },
147
+ {
148
+ "id": "a70e5eea-5e19-4ed5-bc86-f0ec60715ea2",
149
+ "name": "Large",
150
+ "priceDelta": 200,
151
+ "isAvailable": true,
152
+ "sortOrder": 1
153
+ }
154
+ ]
155
+ },
156
+ {
157
+ "id": "9c80e14f-f97a-41fc-87dc-ddf624aea9da",
158
+ "name": "Extras",
159
+ "min": 0,
160
+ "max": 2,
161
+ "sortOrder": 1,
162
+ "options": [
163
+ {
164
+ "id": "f1c60f21-f159-4f3c-90fa-94fb5c5cb3b6",
165
+ "name": "Extra brisket",
166
+ "priceDelta": 300,
167
+ "isAvailable": true,
168
+ "sortOrder": 0
169
+ },
170
+ {
171
+ "id": "bd120953-6ed4-482c-9764-2a5bbf96eec2",
172
+ "name": "Extra noodles",
173
+ "priceDelta": 150,
174
+ "isAvailable": true,
175
+ "sortOrder": 1
176
+ },
177
+ {
178
+ "id": "8d4fcac1-14f8-494b-83a4-ba64717fc8bb",
179
+ "name": "Poached egg",
180
+ "priceDelta": 100,
181
+ "isAvailable": false,
182
+ "sortOrder": 2
183
+ }
184
+ ]
185
+ },
186
+ {
187
+ "id": "1f0a2da4-8111-47a9-be63-3ba3d8b820c1",
188
+ "name": "Milk",
189
+ "min": 0,
190
+ "max": 1,
191
+ "sortOrder": 2,
192
+ "options": [
193
+ {
194
+ "id": "0d2377b3-03b1-467c-a007-b151fb156646",
195
+ "name": "Condensed milk",
196
+ "priceDelta": 0,
197
+ "isAvailable": true,
198
+ "sortOrder": 0
199
+ },
200
+ {
201
+ "id": "4c72ddc8-3416-46a7-b771-77e5f09f15ec",
202
+ "name": "Oat milk",
203
+ "priceDelta": 75,
204
+ "isAvailable": true,
205
+ "sortOrder": 1
206
+ }
207
+ ]
208
+ }
128
209
  ]
129
210
  },
130
211
  "commerce": {
@@ -136,12 +217,36 @@
136
217
  "acceptsOnlineOrders": true,
137
218
  "prepTimeMinutes": 25,
138
219
  "hours": [
139
- { "day": "mon", "opens": "11:00", "closes": "21:00" },
140
- { "day": "tue", "opens": "11:00", "closes": "21:00" },
141
- { "day": "wed", "opens": "11:00", "closes": "21:00" },
142
- { "day": "thu", "opens": "11:00", "closes": "21:00" },
143
- { "day": "fri", "opens": "11:00", "closes": "01:30" },
144
- { "day": "sat", "opens": "10:00", "closes": "01:30" }
220
+ {
221
+ "day": "mon",
222
+ "opens": "11:00",
223
+ "closes": "21:00"
224
+ },
225
+ {
226
+ "day": "tue",
227
+ "opens": "11:00",
228
+ "closes": "21:00"
229
+ },
230
+ {
231
+ "day": "wed",
232
+ "opens": "11:00",
233
+ "closes": "21:00"
234
+ },
235
+ {
236
+ "day": "thu",
237
+ "opens": "11:00",
238
+ "closes": "21:00"
239
+ },
240
+ {
241
+ "day": "fri",
242
+ "opens": "11:00",
243
+ "closes": "01:30"
244
+ },
245
+ {
246
+ "day": "sat",
247
+ "opens": "10:00",
248
+ "closes": "01:30"
249
+ }
145
250
  ]
146
251
  }
147
252
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Checked-in hashContent() snapshots of the v1 fixtures — the serialization-determinism contract. If a test fails against these, the canonical serialization or a fixture changed; BOTH are locked decisions (phase-2.md T1.6/T1.8). Regenerate only with founder sign-off. REGENERATED ONCE, 2026-08-25: full.json gained the optional `commerce` module (phase-7 Slice 2), signed off in the proposal's Slice 2 line — 'full.json fixture ... publish fixtures and hash updates'. minimal.json is UNCHANGED and must stay so: commerce is optional, so the minimal payload never carries it.",
3
- "full.json": "sha256:30bd220fda88f5dd511cd0c05fe888e9abad4f5e5946b398aade99ba8d3c8a08",
2
+ "_comment": "Checked-in hashContent() snapshots of the v1 fixtures — the serialization-determinism contract. If a test fails against these, the canonical serialization or a fixture changed; BOTH are locked decisions (phase-2.md T1.6/T1.8). Regenerate only with founder sign-off. REGENERATED TWICE: (1) 2026-08-25, full.json gained the optional `commerce` module (phase-7 Slice 2, signed off in the proposal's Slice 2 line); (2) 2026-10-06, full.json gained the optional `menu.optionGroups` / `optionGroupIds` (content-schemas 1.3.0, Online Commerce C3 T2, signed off with the approved spec's 'fixture and hash updates'; the previous full.json hash is kept in test/contract.test.ts as FULL_1_2_0_HASH — the rollback shape). minimal.json is UNCHANGED and must stay so: commerce and option groups are optional, so the minimal payload never carries them.",
3
+ "full.json": "sha256:b3262a49e000fec59b067f7aec07bc0b31855086f121a16a02af394da616ff4d",
4
4
  "minimal.json": "sha256:7b4ebc88bf2c17e1182a7e89fe994c3a38f70e0e638b37a0caa662f0c7e8db22"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamgig/content-schemas",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "The LamGig published-JSON contract: Zod schemas, canonical serialization, and fixtures for consuming content published by the LamGig platform.",
5
5
  "keywords": [
6
6
  "lamgig",
@@ -57,6 +57,6 @@
57
57
  "devDependencies": {
58
58
  "tsup": "^8.5.1",
59
59
  "typescript": "^5",
60
- "vitest": "^4.1.10"
60
+ "vitest": "^5.0.3"
61
61
  }
62
62
  }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { z } from 'zod';\n\n/**\n * Version of the published-JSON contract. Additive changes (new OPTIONAL fields\n * and new OPTIONAL module keys) keep this number; any required addition is\n * breaking and bumps it. The full compatibility rule lives in this package's\n * README. See PLAN.md § Published JSON delivery and phase-2.md T1.5.\n */\nexport const SCHEMA_VERSION = 1;\n\n/** Publisher-enforced ceiling on the canonical payload size (1 MiB). */\nexport const MAX_CONTENT_BYTES = 1_048_576;\n\n// ---------------------------------------------------------------------------\n// Shared value patterns — single source of truth.\n//\n// LOCALE_PATTERN_SOURCE is the EXACT string used by the `site_settings.locale`\n// DB CHECK (supabase/migrations/*_content_site_settings.sql). Contract and\n// database must never disagree (phase-2.md T1.1): v1 supports the named BCP-47\n// subset `ll` or `ll-CC` only (e.g. `en`, `en-US`); script/numeric-region forms\n// (`zh-Hant`, `es-419`) are out of scope — widening later is additive.\n// HEX_COLOR / CURRENCY are likewise mirrored by DB CHECKs on `branding.colors`\n// and `menu_items.price_currency`.\n// ---------------------------------------------------------------------------\n\nexport const LOCALE_PATTERN_SOURCE = '^[a-z]{2}(-[A-Z]{2})?$';\nexport const LOCALE_PATTERN = new RegExp(LOCALE_PATTERN_SOURCE);\n\nexport const HEX_COLOR_PATTERN_SOURCE = '^#[0-9A-Fa-f]{6}$';\nexport const HEX_COLOR_PATTERN = new RegExp(HEX_COLOR_PATTERN_SOURCE);\n\n/** ISO 4217 alpha code shape (mirrored by the `price_currency` DB CHECK). */\nexport const CURRENCY_PATTERN_SOURCE = '^[A-Z]{3}$';\nexport const CURRENCY_PATTERN = new RegExp(CURRENCY_PATTERN_SOURCE);\n\n/** ISO 3166-1 alpha-2 country code shape. */\nexport const COUNTRY_PATTERN_SOURCE = '^[A-Z]{2}$';\nexport const COUNTRY_PATTERN = new RegExp(COUNTRY_PATTERN_SOURCE);\n\n/**\n * Days of the week, in the canonical order `commerce.hours` must be sorted by.\n * The order is part of the contract: a consumer rendering opening hours reads\n * the array straight through, so a payload listing Sunday before Monday would\n * render wrong without being malformed.\n */\nexport const COMMERCE_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;\n\n/**\n * 24-hour `HH:MM`. UNLIKE the four patterns above this one is deliberately NOT\n * mirrored by a DB CHECK: `commerce_settings.hours` is validated at the DB only\n * as \"an array of at most 7\" (see the migration header), and its element shape,\n * time syntax, day uniqueness and ordering are publish-gated here instead. A\n * schema-invalid draft may therefore exist in the table; it cannot be published.\n */\nexport const TIME_24H_PATTERN_SOURCE = '^([01][0-9]|2[0-3]):[0-5][0-9]$';\nexport const TIME_24H_PATTERN = new RegExp(TIME_24H_PATTERN_SOURCE);\n\n/** IANA time zone check via the runtime's own tz database. */\nexport function isIanaTimeZone(tz: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: tz });\n return true;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Two parse modes from ONE schema definition (phase-2.md T1):\n//\n// - producer-strict — `.strict()` everywhere; the dashboard validates its own\n// output against this before publishing. Unknown keys are defects.\n// - consumer-tolerant — unknown object keys are STRIPPED and unknown module\n// keys ignored, so a shipped consumer accepts a v1 payload that later gained\n// an optional field or a new module key (the additive changes the\n// compatibility rule promises).\n//\n// zod v4's `.strip()`/`.passthrough()` do NOT recurse into nested objects, so a\n// post-hoc transform of one tree silently leaves nested objects in the original\n// mode. Instead every object in the tree is built through an injected object\n// factory: `z.strictObject` for the strict pair, `z.object` (strips unknown\n// keys) for the tolerant pair. One definition, two instantiations.\n// ---------------------------------------------------------------------------\n\ntype ObjectFactory = <T extends z.ZodRawShape>(shape: T) => z.ZodObject<T>;\n\nconst strictFactory = z.strictObject as ObjectFactory;\nconst tolerantFactory = z.object as ObjectFactory;\n\n// --- siteSettings ----------------------------------------------------------\n\nconst makeSiteSettingsSchema = (o: ObjectFactory) =>\n o({\n siteName: z.string().min(1).max(120),\n tagline: z.string().max(200).optional(),\n contact: o({\n email: z.email().optional(),\n phone: z.string().min(1).optional(),\n address: o({\n line1: z.string().min(1),\n line2: z.string().min(1).optional(),\n city: z.string().min(1),\n region: z.string().min(1),\n postalCode: z.string().min(1),\n country: z.string().regex(COUNTRY_PATTERN),\n }).optional(),\n }),\n // Explicit known keys (NOT a record): strict rejects unknown platforms,\n // tolerant strips them — adding a platform later is additive.\n socialLinks: o({\n facebook: z.url().optional(),\n instagram: z.url().optional(),\n tiktok: z.url().optional(),\n x: z.url().optional(),\n youtube: z.url().optional(),\n yelp: z.url().optional(),\n google: z.url().optional(),\n }).optional(),\n locale: z.string().regex(LOCALE_PATTERN).default('en-US'),\n timezone: z\n .string()\n .min(1)\n .refine(isIanaTimeZone, 'must be an IANA time zone name')\n .default('America/Los_Angeles'),\n });\n\nexport const siteSettingsSchemaStrict = makeSiteSettingsSchema(strictFactory);\nexport const siteSettingsSchema = makeSiteSettingsSchema(tolerantFactory);\n\n// --- branding --------------------------------------------------------------\n\nconst makeBrandingSchema = (o: ObjectFactory) =>\n o({\n // Absolute URLs produced by the dashboard's storagePublicUrl builder\n // (apps/dashboard/src/lib/content-urls.ts). The DB stores storage PATHS;\n // the publish-time compose maps path -> URL. This package never imports\n // from the app (dependency direction, phase-2.md T1.7).\n logoUrl: z.url().optional(),\n faviconUrl: z.url().optional(),\n colors: o({\n primary: z.string().regex(HEX_COLOR_PATTERN),\n secondary: z.string().regex(HEX_COLOR_PATTERN).optional(),\n accent: z.string().regex(HEX_COLOR_PATTERN).optional(),\n background: z.string().regex(HEX_COLOR_PATTERN).optional(),\n text: z.string().regex(HEX_COLOR_PATTERN).optional(),\n }),\n fonts: o({\n heading: z.string().min(1).optional(),\n body: z.string().min(1).optional(),\n }).optional(),\n });\n\nexport const brandingSchemaStrict = makeBrandingSchema(strictFactory);\nexport const brandingSchema = makeBrandingSchema(tolerantFactory);\n\n// --- menu ------------------------------------------------------------------\n\nconst makeMenuItemSchema = (o: ObjectFactory) =>\n o({\n /** Stable DB UUID — identical across publishes (phase-2.md T1.3). */\n id: z.uuid(),\n name: z.string().min(1).max(200),\n description: z.string().max(2000).optional(),\n price: o({\n /** Integer minor units (e.g. cents). */\n amount: z.number().int().min(0),\n currency: z.string().regex(CURRENCY_PATTERN),\n }),\n imageUrl: z.url().optional(),\n tags: z.array(z.string().min(1)).max(20),\n sortOrder: z.number().int().min(0).max(100_000),\n isAvailable: z.boolean(),\n });\n\nconst makeMenuCategorySchema = (o: ObjectFactory) =>\n o({\n id: z.uuid(),\n name: z.string().min(1).max(200),\n description: z.string().max(2000).optional(),\n sortOrder: z.number().int().min(0).max(100_000),\n items: z.array(makeMenuItemSchema(o)),\n });\n\nconst makeMenuSchema = (o: ObjectFactory) =>\n o({\n /** Archived/deleted entities are simply ABSENT; empty menu = `[]`, never a missing key. */\n categories: z.array(makeMenuCategorySchema(o)),\n });\n\nexport const menuSchemaStrict = makeMenuSchema(strictFactory);\nexport const menuSchema = makeMenuSchema(tolerantFactory);\n\n// --- commerce --------------------------------------------------------------\n//\n// Online-ordering CONFIGURATION, never orders and never provider secrets\n// (docs/seams/commerce.md § \"What does NOT go in the module\"): a published\n// payload is world-readable immutable JSON, so a credential written here would\n// be permanent and public. Everything below is a public identifier or a display\n// value.\n//\n// The `provider` enum is `none | other` ONLY (phase-7 D-9). No provider has\n// been selected, and a published enum is a consumer-visible commitment: a\n// consumer that switches on `\"square\"` would be relying on a name the seam says\n// requires an evaluation and a decision record first. Adding a member later is\n// additive for a TOLERANT consumer and breaking for a strict one, which is the\n// right way round.\n//\n// TIMES ARE LOCAL to `siteSettings.timezone` and carry no offset — the site's\n// own clock is what a customer reads on the door. `closes < opens` is LEGAL and\n// means \"closes after midnight\" (docs/seams/commerce.md § 1); a consumer must\n// not treat it as an error.\n\nconst makeCommerceHoursSchema = (o: ObjectFactory) =>\n z\n .array(\n o({\n day: z.enum(COMMERCE_DAYS),\n opens: z.string().regex(TIME_24H_PATTERN),\n closes: z.string().regex(TIME_24H_PATTERN),\n }),\n )\n .max(7)\n // At most one entry per day, listed in COMMERCE_DAYS order. The DB CHECK\n // stops at \"an array of at most 7\" (deliberately — see the migration\n // header), so THIS is the layer that makes `hours` meaningful, and it runs\n // at publish time rather than at write time.\n .superRefine((entries, ctx) => {\n const seen = new Set<string>();\n let previous = -1;\n entries.forEach((entry, index) => {\n if (seen.has(entry.day)) {\n ctx.addIssue({\n code: 'custom',\n path: [index, 'day'],\n message: `duplicate day \"${entry.day}\"`,\n });\n }\n seen.add(entry.day);\n const position = COMMERCE_DAYS.indexOf(entry.day);\n if (position < previous) {\n ctx.addIssue({\n code: 'custom',\n path: [index, 'day'],\n message: 'hours must be ordered mon → sun',\n });\n }\n previous = position;\n });\n });\n\nconst makeCommerceSchema = (o: ObjectFactory) =>\n o({\n provider: z.enum(['none', 'other']),\n /** Where a customer is sent to order. Absent when `provider` is `none`. */\n orderingUrl: z.url({ protocol: /^https$/ }).max(2048).optional(),\n /** PUBLIC provider identifiers — never a key, token or secret. */\n locationRef: z.string().min(1).max(128).optional(),\n menuRef: z.string().min(1).max(128).optional(),\n /** Matches `menu` item prices; the DB CHECK mirrors CURRENCY_PATTERN_SOURCE. */\n currency: z.string().regex(CURRENCY_PATTERN),\n acceptsOnlineOrders: z.boolean(),\n /** Quoted preparation time. Meaningless — and normally absent — when ordering is off. */\n prepTimeMinutes: z.number().int().min(0).max(240).optional(),\n hours: makeCommerceHoursSchema(o),\n });\n\nexport const commerceSchemaStrict = makeCommerceSchema(strictFactory);\nexport const commerceSchema = makeCommerceSchema(tolerantFactory);\n\n// --- content file ----------------------------------------------------------\n\nconst makeContentFileSchema = (o: ObjectFactory) =>\n o({\n schemaVersion: z.literal(1),\n site: o({ id: z.uuid(), slug: z.string().min(1) }),\n publishedAt: z.iso.datetime(),\n // The three v1 modules are REQUIRED; empty states are valid module payloads\n // (e.g. menu.categories: []), never missing keys. A new required module is\n // breaking (retained/rollback snapshots won't contain it) — bump\n // SCHEMA_VERSION instead.\n //\n // `commerce` is therefore OPTIONAL (phase-7 D-3), and that is not a\n // stylistic choice: every snapshot published before the module existed\n // lacks the key, and a ROLLBACK can activate one of those at any time. A\n // required `commerce` would retroactively invalidate the whole publish\n // history. See ARCHITECTURE.md § 5 and docs/seams/commerce.md § 1.\n modules: o({\n siteSettings: makeSiteSettingsSchema(o),\n branding: makeBrandingSchema(o),\n menu: makeMenuSchema(o),\n commerce: makeCommerceSchema(o).optional(),\n }),\n });\n\n/** Producer gate: the dashboard validates its own output against this. */\nexport const contentFileSchemaStrict = makeContentFileSchema(strictFactory);\n/** Public consumer schema: tolerant of additive v1 changes. */\nexport const contentFileSchema = makeContentFileSchema(tolerantFactory);\n\nexport type SiteSettings = z.infer<typeof siteSettingsSchemaStrict>;\nexport type Branding = z.infer<typeof brandingSchemaStrict>;\nexport type Menu = z.infer<typeof menuSchemaStrict>;\nexport type MenuCategory = Menu['categories'][number];\nexport type MenuItem = MenuCategory['items'][number];\nexport type Commerce = z.infer<typeof commerceSchemaStrict>;\nexport type CommerceDay = (typeof COMMERCE_DAYS)[number];\nexport type CommerceHours = Commerce['hours'];\nexport type ContentFile = z.infer<typeof contentFileSchemaStrict>;\n\n// ---------------------------------------------------------------------------\n// Canonical serialization (phase-2.md T1.6).\n//\n// Recursively key-sorted JSON with no whitespace, produced by a hand-rolled\n// serializer rather than JSON.stringify over a re-built object — rebuilding\n// depends on JS insertion-order semantics and silently reorders integer-like\n// keys. Determinism here is what makes `hashContent` a byte-equality contract\n// shared by the publisher and every consumer test.\n//\n// Rules (documented in the README):\n// - arrays keep their order; object keys are sorted with Array.prototype.sort\n// (code-unit order);\n// - object properties whose value is `undefined` are omitted (JSON semantics);\n// - `undefined` anywhere else, non-finite numbers, bigint, functions and\n// symbols THROW — never silently coerced the way JSON.stringify would.\n// ---------------------------------------------------------------------------\n\nexport function canonicalize(payload: unknown): string {\n const out = serialize(payload);\n if (out === undefined) {\n throw new TypeError('canonicalize: payload itself serializes to nothing (undefined)');\n }\n return out;\n}\n\nfunction serialize(value: unknown): string | undefined {\n if (value === undefined) return undefined;\n if (value === null) return 'null';\n switch (typeof value) {\n case 'string':\n case 'boolean':\n return JSON.stringify(value);\n case 'number':\n if (!Number.isFinite(value)) {\n throw new TypeError(`canonicalize: non-finite number ${String(value)}`);\n }\n return JSON.stringify(value);\n case 'object':\n break;\n default:\n throw new TypeError(`canonicalize: unsupported value of type ${typeof value}`);\n }\n if (Array.isArray(value)) {\n const parts = value.map((entry) => {\n const s = serialize(entry);\n if (s === undefined) {\n throw new TypeError('canonicalize: undefined array element');\n }\n return s;\n });\n return `[${parts.join(',')}]`;\n }\n const record = value as Record<string, unknown>;\n const keys = Object.keys(record).sort();\n const parts: string[] = [];\n for (const key of keys) {\n const s = serialize(record[key]);\n if (s === undefined) continue; // undefined property -> omitted key\n parts.push(`${JSON.stringify(key)}:${s}`);\n }\n return `{${parts.join(',')}}`;\n}\n"],"mappings":"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","names":["parts"]}