@zorgo/next 1.3.0 → 2.0.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.
Files changed (66) hide show
  1. package/dist/cli.d.mts +9 -0
  2. package/dist/cli.d.ts +9 -0
  3. package/dist/cli.js +44 -66
  4. package/dist/cli.js.map +1 -1
  5. package/dist/cli.mjs +43 -65
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/components.d.mts +735 -5
  8. package/dist/components.d.ts +735 -5
  9. package/dist/components.js +3692 -525
  10. package/dist/components.js.map +1 -1
  11. package/dist/components.mjs +3663 -540
  12. package/dist/components.mjs.map +1 -1
  13. package/dist/images.d.mts +25 -13
  14. package/dist/images.d.ts +25 -13
  15. package/dist/images.js +18 -8
  16. package/dist/images.js.map +1 -1
  17. package/dist/images.mjs +15 -8
  18. package/dist/images.mjs.map +1 -1
  19. package/dist/index.d.mts +315 -9
  20. package/dist/index.d.ts +315 -9
  21. package/dist/index.js +58 -52
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +58 -52
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/item-page.d.mts +6 -0
  26. package/dist/item-page.d.ts +6 -0
  27. package/dist/item-page.js +4 -2
  28. package/dist/item-page.js.map +1 -1
  29. package/dist/item-page.mjs +4 -2
  30. package/dist/item-page.mjs.map +1 -1
  31. package/dist/legal.d.mts +31 -0
  32. package/dist/legal.d.ts +31 -0
  33. package/dist/legal.js +322 -0
  34. package/dist/legal.js.map +1 -0
  35. package/dist/legal.mjs +293 -0
  36. package/dist/legal.mjs.map +1 -0
  37. package/dist/seo.d.mts +56 -1
  38. package/dist/seo.d.ts +56 -1
  39. package/dist/seo.js +111 -2
  40. package/dist/seo.js.map +1 -1
  41. package/dist/seo.mjs +106 -1
  42. package/dist/seo.mjs.map +1 -1
  43. package/dist/server.d.mts +27 -1
  44. package/dist/server.d.ts +27 -1
  45. package/dist/server.js +322 -102
  46. package/dist/server.js.map +1 -1
  47. package/dist/server.mjs +322 -104
  48. package/dist/server.mjs.map +1 -1
  49. package/dist/slugs.d.mts +2 -1
  50. package/dist/slugs.d.ts +2 -1
  51. package/dist/slugs.js +2 -0
  52. package/dist/slugs.js.map +1 -1
  53. package/dist/slugs.mjs +1 -0
  54. package/dist/tree/README.md +2 -2
  55. package/dist/tree/app/error.tsx +12 -3
  56. package/dist/tree/app/global-error.tsx +21 -4
  57. package/dist/tree/app/globals.css +41 -0
  58. package/dist/tree/app/not-found.tsx +17 -3
  59. package/dist/tree/app/privacy/page.tsx.gen.ts +28 -9
  60. package/dist/tree/app/terms/page.tsx.gen.ts +26 -10
  61. package/dist/tree/gitignore +6 -1
  62. package/dist/tree/next.config.ts +6 -5
  63. package/dist/tree/open-next.config.ts +7 -0
  64. package/dist/tree/package.json.gen.ts +8 -2
  65. package/package.json +7 -2
  66. package/dist/tree/app/globals.css.gen.ts +0 -124
package/dist/seo.js CHANGED
@@ -20,10 +20,106 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/seo.ts
21
21
  var seo_exports = {};
22
22
  __export(seo_exports, {
23
+ buildLocationJsonLd: () => buildLocationJsonLd,
23
24
  buildMenuItemJsonLd: () => buildMenuItemJsonLd,
24
- buildRestaurantJsonLd: () => buildRestaurantJsonLd
25
+ buildRestaurantJsonLd: () => buildRestaurantJsonLd,
26
+ openingHoursSpecification: () => openingHoursSpecification
25
27
  });
26
28
  module.exports = __toCommonJS(seo_exports);
29
+
30
+ // ../../node_modules/date-fns-tz/dist/esm/_lib/tzTokenizeDate/index.js
31
+ var testDateFormatted = new Intl.DateTimeFormat("en-US", {
32
+ hourCycle: "h23",
33
+ timeZone: "America/New_York",
34
+ year: "numeric",
35
+ month: "2-digit",
36
+ day: "2-digit",
37
+ hour: "2-digit",
38
+ minute: "2-digit",
39
+ second: "2-digit"
40
+ }).format(/* @__PURE__ */ new Date("2014-06-25T04:00:00.123Z"));
41
+
42
+ // ../../node_modules/date-fns-tz/dist/esm/format/formatters/index.js
43
+ var MILLISECONDS_IN_MINUTE = 60 * 1e3;
44
+
45
+ // ../zorgo/universal/utils/locationOrderingAvailability.ts
46
+ function wallTime(value) {
47
+ return value.replace(/[+-].*$/, "").trim().slice(0, 5);
48
+ }
49
+
50
+ // src/seo.ts
51
+ var DAYS = [
52
+ "Monday",
53
+ "Tuesday",
54
+ "Wednesday",
55
+ "Thursday",
56
+ "Friday",
57
+ "Saturday",
58
+ "Sunday"
59
+ ];
60
+ function restaurantId(info) {
61
+ return `${info.url ?? ""}#restaurant`;
62
+ }
63
+ function branchId(info, location) {
64
+ return `${info.url ?? ""}#location-${location.location_id}`;
65
+ }
66
+ function openingHoursSpecification(location) {
67
+ const byWindow = /* @__PURE__ */ new Map();
68
+ for (const day of DAYS) {
69
+ const key = day.toLowerCase();
70
+ const open = location[`${key}_open`];
71
+ const close = location[`${key}_close`];
72
+ if (!open || !close) continue;
73
+ const window = `${wallTime(open)}|${wallTime(close)}`;
74
+ byWindow.set(window, [...byWindow.get(window) ?? [], `https://schema.org/${day}`]);
75
+ }
76
+ return [...byWindow].map(([window, dayOfWeek]) => {
77
+ const [opens, closes] = window.split("|");
78
+ return { "@type": "OpeningHoursSpecification", dayOfWeek, opens, closes };
79
+ });
80
+ }
81
+ var US_ADDRESS = /^(.+),\s*([^,]+),\s*([A-Z]{2})\s+(\d{5}(?:-\d{4})?)(?:,\s*USA?)?$/;
82
+ function postalAddress(address) {
83
+ const match = US_ADDRESS.exec(address.trim());
84
+ if (!match) return address;
85
+ const [, streetAddress, addressLocality, addressRegion, postalCode] = match;
86
+ if (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;
87
+ return {
88
+ "@type": "PostalAddress",
89
+ streetAddress: streetAddress.trim(),
90
+ addressLocality: addressLocality.trim(),
91
+ addressRegion,
92
+ postalCode,
93
+ addressCountry: "US"
94
+ };
95
+ }
96
+ function locationFacts(location) {
97
+ const facts = {};
98
+ if (location.address) facts.address = postalAddress(location.address);
99
+ if (location.phone) facts.telephone = location.phone;
100
+ if (location.lat != null && location.lng != null) {
101
+ facts.geo = {
102
+ "@type": "GeoCoordinates",
103
+ latitude: location.lat,
104
+ longitude: location.lng
105
+ };
106
+ }
107
+ const hours = openingHoursSpecification(location);
108
+ if (hours.length) facts.openingHoursSpecification = hours;
109
+ return facts;
110
+ }
111
+ function buildLocationJsonLd(location, info) {
112
+ const node = {
113
+ "@context": "https://schema.org",
114
+ "@type": "Restaurant",
115
+ "@id": branchId(info, location),
116
+ name: `${info.name} \u2014 ${location.name}`,
117
+ parentOrganization: { "@id": restaurantId(info) },
118
+ ...locationFacts(location)
119
+ };
120
+ if (info.image) node.image = info.image;
121
+ return node;
122
+ }
27
123
  function priceString(cents) {
28
124
  return (cents / 100).toFixed(2);
29
125
  }
@@ -67,6 +163,17 @@ function buildRestaurantJsonLd(menu, info) {
67
163
  if (info.image) restaurant.image = info.image;
68
164
  if (info.description) restaurant.description = info.description;
69
165
  if (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;
166
+ const locations = info.locations ?? [];
167
+ if (locations.length === 1) {
168
+ Object.assign(restaurant, locationFacts(locations[0]));
169
+ } else if (locations.length > 1) {
170
+ restaurant["@id"] = restaurantId(info);
171
+ restaurant.subOrganization = locations.map((location) => ({
172
+ "@type": "Restaurant",
173
+ "@id": branchId(info, location),
174
+ name: location.name
175
+ }));
176
+ }
70
177
  restaurant.hasMenu = {
71
178
  "@type": "Menu",
72
179
  name: "Menu",
@@ -76,7 +183,9 @@ function buildRestaurantJsonLd(menu, info) {
76
183
  }
77
184
  // Annotate the CommonJS export names for ESM import in node:
78
185
  0 && (module.exports = {
186
+ buildLocationJsonLd,
79
187
  buildMenuItemJsonLd,
80
- buildRestaurantJsonLd
188
+ buildRestaurantJsonLd,
189
+ openingHoursSpecification
81
190
  });
82
191
  //# sourceMappingURL=seo.js.map
package/dist/seo.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/seo.ts"],"sourcesContent":["import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, MenuItem } from \"@zorgo/universal/interfaces\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAcA,SAAS,YAAY,OAAuB;AAC3C,UAAQ,QAAQ,KAAK,QAAQ,CAAC;AAC/B;AAEA,SAAS,aAAa,SAAsB;AAC3C,SAAO;AAAA,IACN,SAAS;AAAA,IACT,OAAO,YAAY,QAAQ,iBAAiB,QAAQ,KAAK;AAAA,IACzD,eAAe;AAAA,EAChB;AACD;AAEA,SAAS,aAAa,MAAgB;AACrC,QAAM,OAAgC;AAAA,IACrC,SAAS;AAAA,IACT,MAAM,KAAK;AAAA,EACZ;AACA,MAAI,KAAK,YAAa,MAAK,cAAc,KAAK;AAE9C,QAAM,SAAS,KAAK,SAAS,IAAI,YAAY;AAC7C,MAAI,OAAO,WAAW,EAAG,MAAK,SAAS,OAAO,CAAC;AAAA,WACtC,OAAO,SAAS,EAAG,MAAK,SAAS;AAC1C,SAAO;AACR;AAQO,SAAS,oBAAoB,MAAgB;AACnD,SAAO,EAAE,YAAY,sBAAsB,GAAG,aAAa,IAAI,EAAE;AAClE;AAQO,SAAS,sBAAsB,MAAY,MAAsB;AACvE,QAAM,iBAAiB,KAAK,WAAW,IAAI,CAAC,aAAa;AACxD,UAAM,UAAmC;AAAA,MACxC,SAAS;AAAA,MACT,MAAM,SAAS;AAAA,IAChB;AACA,UAAM,QAAQ,KAAK,MACjB,OAAO,CAAC,SAAS,KAAK,gBAAgB,SAAS,WAAW,EAC1D,IAAI,YAAY;AAClB,QAAI,MAAM,OAAQ,SAAQ,cAAc;AACxC,WAAO;AAAA,EACR,CAAC;AAED,QAAM,aAAsC;AAAA,IAC3C,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,MAAM,KAAK;AAAA,EACZ;AACA,MAAI,KAAK,IAAK,YAAW,MAAM,KAAK;AACpC,MAAI,KAAK,MAAO,YAAW,QAAQ,KAAK;AACxC,MAAI,KAAK,YAAa,YAAW,cAAc,KAAK;AACpD,MAAI,KAAK,eAAe,OAAQ,YAAW,gBAAgB,KAAK;AAChE,aAAW,UAAU;AAAA,IACpB,SAAS;AAAA,IACT,MAAM;AAAA,IACN;AAAA,EACD;AAEA,SAAO;AACR;","names":[]}
1
+ {"version":3,"sources":["../src/seo.ts","../../../node_modules/date-fns-tz/dist/esm/_lib/tzTokenizeDate/index.js","../../../node_modules/date-fns-tz/dist/esm/format/formatters/index.js","../../zorgo/universal/utils/locationOrderingAvailability.ts"],"sourcesContent":["import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, Location, MenuItem } from \"@zorgo/universal/interfaces\";\nimport { wallTime } from \"@zorgo/universal/utils/locationOrderingAvailability\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n\t/** Public locations from `lib/generated/locations.ts`. One location's facts\n\t * land on the Restaurant node itself; several become linked branches. */\n\tlocations?: Location[];\n}\n\nconst DAYS = [\n\t\"Monday\",\n\t\"Tuesday\",\n\t\"Wednesday\",\n\t\"Thursday\",\n\t\"Friday\",\n\t\"Saturday\",\n\t\"Sunday\",\n] as const;\n\n/** Stable node ids so the parent and its branches can reference each other. */\nfunction restaurantId(info: RestaurantInfo) {\n\treturn `${info.url ?? \"\"}#restaurant`;\n}\nfunction branchId(info: RestaurantInfo, location: Location) {\n\treturn `${info.url ?? \"\"}#location-${location.location_id}`;\n}\n\n/**\n * One OpeningHoursSpecification per distinct weekly window, with the days that\n * share it grouped into `dayOfWeek`. Days with no hours set are simply absent,\n * which is how schema.org expresses \"closed\".\n */\nexport function openingHoursSpecification(location: Location) {\n\tconst byWindow = new Map<string, string[]>();\n\tfor (const day of DAYS) {\n\t\tconst key = day.toLowerCase();\n\t\tconst open = location[`${key}_open` as keyof Location] as string | undefined;\n\t\tconst close = location[`${key}_close` as keyof Location] as string | undefined;\n\t\tif (!open || !close) continue;\n\t\tconst window = `${wallTime(open)}|${wallTime(close)}`;\n\t\tbyWindow.set(window, [...(byWindow.get(window) ?? []), `https://schema.org/${day}`]);\n\t}\n\n\treturn [...byWindow].map(([window, dayOfWeek]) => {\n\t\tconst [opens, closes] = window.split(\"|\");\n\t\treturn { \"@type\": \"OpeningHoursSpecification\", dayOfWeek, opens, closes };\n\t});\n}\n\n/**\n * `123 Main St, Springfield, IL 62704` — street, city, two-letter state, ZIP,\n * with an optional +4 and an optional trailing country. Anchored at both ends\n * and deliberately strict: everything before the last two commas is the street,\n * so a unit (\"… Ste 4, Springfield, IL 62704\") still parses.\n */\nconst US_ADDRESS = /^(.+),\\s*([^,]+),\\s*([A-Z]{2})\\s+(\\d{5}(?:-\\d{4})?)(?:,\\s*USA?)?$/;\n\n/**\n * schema.org accepts `address` as either Text or a PostalAddress node, but\n * Google's LocalBusiness rich results only read the structured form — a bare\n * string means the branch nodes never qualify.\n *\n * `address` is still one staff-typed column, so this parses rather than reads,\n * and it only parses what it can prove: a string that does not match the US\n * shape exactly falls back to the Text form it has always shipped. A wrong\n * `addressRegion` in structured data is worse than no `addressRegion` — it is a\n * fact we are asserting to Google — so the regex declines rather than guesses.\n *\n * ponytail: US-only, because every franchise is. Drop the fallback and read real\n * columns the day `locations` stores the parts separately.\n */\nfunction postalAddress(address: string): Record<string, unknown> | string {\n\tconst match = US_ADDRESS.exec(address.trim());\n\tif (!match) return address;\n\tconst [, streetAddress, addressLocality, addressRegion, postalCode] = match;\n\t// Unreachable — all four groups are required by the pattern — but under\n\t// noUncheckedIndexedAccess a capture is `string | undefined`, and declining\n\t// is already what this function does when it cannot prove the shape.\n\tif (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;\n\treturn {\n\t\t\"@type\": \"PostalAddress\",\n\t\tstreetAddress: streetAddress.trim(),\n\t\taddressLocality: addressLocality.trim(),\n\t\taddressRegion,\n\t\tpostalCode,\n\t\taddressCountry: \"US\",\n\t};\n}\n\n/** address / geo / telephone / hours — the facts shared by the single-location\n * Restaurant node and every branch node. */\nfunction locationFacts(location: Location) {\n\tconst facts: Record<string, unknown> = {};\n\tif (location.address) facts.address = postalAddress(location.address);\n\tif (location.phone) facts.telephone = location.phone;\n\tif (location.lat != null && location.lng != null) {\n\t\tfacts.geo = {\n\t\t\t\"@type\": \"GeoCoordinates\",\n\t\t\tlatitude: location.lat,\n\t\t\tlongitude: location.lng,\n\t\t};\n\t}\n\tconst hours = openingHoursSpecification(location);\n\tif (hours.length) facts.openingHoursSpecification = hours;\n\treturn facts;\n}\n\n/**\n * The branch node for one location, for that location's page. Carries the same\n * address/geo/hours facts as the parent and links back to it via\n * `parentOrganization`. Pure — safe to run at build time.\n */\nexport function buildLocationJsonLd(location: Location, info: RestaurantInfo) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\t\"@id\": branchId(info, location),\n\t\tname: `${info.name} — ${location.name}`,\n\t\tparentOrganization: { \"@id\": restaurantId(info) },\n\t\t...locationFacts(location),\n\t};\n\tif (info.image) node.image = info.image;\n\treturn node;\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\n\t// A single-location franchise *is* its storefront, so its facts belong on\n\t// the Restaurant node. Several locations get referenced as branches instead\n\t// — the full branch nodes live on the location pages, not duplicated here.\n\tconst locations = info.locations ?? [];\n\tif (locations.length === 1) {\n\t\tObject.assign(restaurant, locationFacts(locations[0]!));\n\t} else if (locations.length > 1) {\n\t\trestaurant[\"@id\"] = restaurantId(info);\n\t\trestaurant.subOrganization = locations.map((location) => ({\n\t\t\t\"@type\": \"Restaurant\",\n\t\t\t\"@id\": branchId(info, location),\n\t\t\tname: location.name,\n\t\t}));\n\t}\n\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n","/**\n * Returns the [year, month, day, hour, minute, seconds] tokens of the provided\n * `date` as it will be rendered in the `timeZone`.\n */\nexport function tzTokenizeDate(date, timeZone) {\n const dtf = getDateTimeFormat(timeZone);\n return 'formatToParts' in dtf ? partsOffset(dtf, date) : hackyOffset(dtf, date);\n}\nconst typeToPos = {\n year: 0,\n month: 1,\n day: 2,\n hour: 3,\n minute: 4,\n second: 5,\n};\nfunction partsOffset(dtf, date) {\n try {\n const formatted = dtf.formatToParts(date);\n const filled = [];\n for (let i = 0; i < formatted.length; i++) {\n const pos = typeToPos[formatted[i].type];\n if (pos !== undefined) {\n filled[pos] = parseInt(formatted[i].value, 10);\n }\n }\n return filled;\n }\n catch (error) {\n if (error instanceof RangeError) {\n return [NaN];\n }\n throw error;\n }\n}\nfunction hackyOffset(dtf, date) {\n const formatted = dtf.format(date);\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const parsed = /(\\d+)\\/(\\d+)\\/(\\d+),? (\\d+):(\\d+):(\\d+)/.exec(formatted);\n // const [, fMonth, fDay, fYear, fHour, fMinute, fSecond] = parsed\n // return [fYear, fMonth, fDay, fHour, fMinute, fSecond]\n return [\n parseInt(parsed[3], 10),\n parseInt(parsed[1], 10),\n parseInt(parsed[2], 10),\n parseInt(parsed[4], 10),\n parseInt(parsed[5], 10),\n parseInt(parsed[6], 10),\n ];\n}\n// Get a cached Intl.DateTimeFormat instance for the IANA `timeZone`. This can be used\n// to get deterministic local date/time output according to the `en-US` locale which\n// can be used to extract local time parts as necessary.\nconst dtfCache = {};\n// New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12`\nconst testDateFormatted = new Intl.DateTimeFormat('en-US', {\n hourCycle: 'h23',\n timeZone: 'America/New_York',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n}).format(new Date('2014-06-25T04:00:00.123Z'));\nconst hourCycleSupported = testDateFormatted === '06/25/2014, 00:00:00' ||\n testDateFormatted === '‎06‎/‎25‎/‎2014‎ ‎00‎:‎00‎:‎00';\nfunction getDateTimeFormat(timeZone) {\n if (!dtfCache[timeZone]) {\n dtfCache[timeZone] = hourCycleSupported\n ? new Intl.DateTimeFormat('en-US', {\n hourCycle: 'h23',\n timeZone: timeZone,\n year: 'numeric',\n month: 'numeric',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n })\n : new Intl.DateTimeFormat('en-US', {\n hour12: false,\n timeZone: timeZone,\n year: 'numeric',\n month: 'numeric',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n });\n }\n return dtfCache[timeZone];\n}\n","import { tzIntlTimeZoneName } from '../../_lib/tzIntlTimeZoneName/index.js';\nimport { tzParseTimezone } from '../../_lib/tzParseTimezone/index.js';\nconst MILLISECONDS_IN_MINUTE = 60 * 1000;\nexport const formatters = {\n // Timezone (ISO-8601. If offset is 0, output is always `'Z'`)\n X: function (date, token, options) {\n const timezoneOffset = getTimeZoneOffset(options.timeZone, date);\n if (timezoneOffset === 0) {\n return 'Z';\n }\n switch (token) {\n // Hours and optional minutes\n case 'X':\n return formatTimezoneWithOptionalMinutes(timezoneOffset);\n // Hours, minutes and optional seconds without `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `XX`\n case 'XXXX':\n case 'XX': // Hours and minutes without `:` delimeter\n return formatTimezone(timezoneOffset);\n // Hours, minutes and optional seconds with `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `XXX`\n case 'XXXXX':\n case 'XXX': // Hours and minutes with `:` delimeter\n default:\n return formatTimezone(timezoneOffset, ':');\n }\n },\n // Timezone (ISO-8601. If offset is 0, output is `'+00:00'` or equivalent)\n x: function (date, token, options) {\n const timezoneOffset = getTimeZoneOffset(options.timeZone, date);\n switch (token) {\n // Hours and optional minutes\n case 'x':\n return formatTimezoneWithOptionalMinutes(timezoneOffset);\n // Hours, minutes and optional seconds without `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `xx`\n case 'xxxx':\n case 'xx': // Hours and minutes without `:` delimeter\n return formatTimezone(timezoneOffset);\n // Hours, minutes and optional seconds with `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `xxx`\n case 'xxxxx':\n case 'xxx': // Hours and minutes with `:` delimeter\n default:\n return formatTimezone(timezoneOffset, ':');\n }\n },\n // Timezone (GMT)\n O: function (date, token, options) {\n const timezoneOffset = getTimeZoneOffset(options.timeZone, date);\n switch (token) {\n // Short\n case 'O':\n case 'OO':\n case 'OOO':\n return 'GMT' + formatTimezoneShort(timezoneOffset, ':');\n // Long\n case 'OOOO':\n default:\n return 'GMT' + formatTimezone(timezoneOffset, ':');\n }\n },\n // Timezone (specific non-location)\n z: function (date, token, options) {\n switch (token) {\n // Short\n case 'z':\n case 'zz':\n case 'zzz':\n return tzIntlTimeZoneName('short', date, options);\n // Long\n case 'zzzz':\n default:\n return tzIntlTimeZoneName('long', date, options);\n }\n },\n};\nfunction getTimeZoneOffset(timeZone, originalDate) {\n const timeZoneOffset = timeZone\n ? tzParseTimezone(timeZone, originalDate, true) / MILLISECONDS_IN_MINUTE\n : originalDate?.getTimezoneOffset() ?? 0;\n if (Number.isNaN(timeZoneOffset)) {\n throw new RangeError('Invalid time zone specified: ' + timeZone);\n }\n return timeZoneOffset;\n}\nfunction addLeadingZeros(number, targetLength) {\n const sign = number < 0 ? '-' : '';\n let output = Math.abs(number).toString();\n while (output.length < targetLength) {\n output = '0' + output;\n }\n return sign + output;\n}\nfunction formatTimezone(offset, delimiter = '') {\n const sign = offset > 0 ? '-' : '+';\n const absOffset = Math.abs(offset);\n const hours = addLeadingZeros(Math.floor(absOffset / 60), 2);\n const minutes = addLeadingZeros(Math.floor(absOffset % 60), 2);\n return sign + hours + delimiter + minutes;\n}\nfunction formatTimezoneWithOptionalMinutes(offset, delimiter) {\n if (offset % 60 === 0) {\n const sign = offset > 0 ? '-' : '+';\n return sign + addLeadingZeros(Math.abs(offset) / 60, 2);\n }\n return formatTimezone(offset, delimiter);\n}\nfunction formatTimezoneShort(offset, delimiter = '') {\n const sign = offset > 0 ? '-' : '+';\n const absOffset = Math.abs(offset);\n const hours = Math.floor(absOffset / 60);\n const minutes = absOffset % 60;\n if (minutes === 0) {\n return sign + String(hours);\n }\n return sign + String(hours) + delimiter + addLeadingZeros(minutes, 2);\n}\n","import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { isBefore } from \"date-fns\";\nimport { formatInTimeZone, toDate } from \"date-fns-tz\";\nimport type { Location, LocationClosure } from \"../interfaces\";\n\nexport type LocationOrderingKind = \"orders\" | \"catering\";\n\n/** Same opinionated TZ as legacy menu checks. */\nexport const LOCATION_ORDERING_TIMEZONE = \"America/New_York\";\n\nconst DAYS = [\n\t\"sunday\",\n\t\"monday\",\n\t\"tuesday\",\n\t\"wednesday\",\n\t\"thursday\",\n\t\"friday\",\n\t\"saturday\",\n] as const;\n\n/** The open/close instants of one calendar day's window, in LOCATION_ORDERING_TIMEZONE. */\nexport interface LocationWindow {\n\t/** Lowercase weekday the window's hours were read from. */\n\tday: (typeof DAYS)[number];\n\topen: Date;\n\tclose: Date;\n}\n\n/**\n * \"11:00:00+00\" -> \"11:00\". The stored offset is an artifact of the\n * `time with time zone` column; the digits are the location's wall clock.\n */\nexport function wallTime(value: string): string {\n\treturn value.replace(/[+-].*$/, \"\").trim().slice(0, 5);\n}\n\n/**\n * The open/close instants for the day `dayOffset` days from `at`, read in\n * LOCATION_ORDERING_TIMEZONE. Null when that weekday has no hours set.\n *\n * A close at or before the open is read as closing after midnight, so an\n * 11pm–2am window is one continuous night rather than a closed day.\n */\nexport function locationWindowOn(\n\tat: Date,\n\tlocation: Location,\n\tdayOffset = 0,\n): LocationWindow | null {\n\t// Anchored at noon UTC so stepping whole days can never cross a date\n\t// boundary the way adding 24h to a midnight-adjacent instant can.\n\tconst anchor = new Date(\n\t\t`${formatInTimeZone(at, LOCATION_ORDERING_TIMEZONE, \"yyyy-MM-dd\")}T12:00:00Z`,\n\t);\n\tanchor.setUTCDate(anchor.getUTCDate() + dayOffset);\n\tconst dateStr = anchor.toISOString().slice(0, 10);\n\tconst day = DAYS[anchor.getUTCDay()]!;\n\n\tconst open = location[`${day}_open` as keyof Location] as string | undefined;\n\tconst close = location[`${day}_close` as keyof Location] as string | undefined;\n\tif (!open || !close) return null;\n\n\tconst openAt = toDate(`${dateStr}T${wallTime(open)}`, {\n\t\ttimeZone: LOCATION_ORDERING_TIMEZONE,\n\t});\n\tlet closeAt = toDate(`${dateStr}T${wallTime(close)}`, {\n\t\ttimeZone: LOCATION_ORDERING_TIMEZONE,\n\t});\n\tif (closeAt <= openAt) closeAt = new Date(closeAt.getTime() + 24 * 60 * 60 * 1000);\n\n\treturn { day, open: openAt, close: closeAt };\n}\n\n/**\n * True when the location is outside its weekly open window at `at`\n * (includes `force_close`). Yesterday's window is checked too, so the small\n * hours of an overnight window still count as open.\n */\nexport function isLocationClosedByWeeklySchedule(at: Date, location: Location): boolean {\n\tif (location.force_close) return true;\n\n\tfor (const offset of [-1, 0]) {\n\t\tconst window = locationWindowOn(at, location, offset);\n\t\tif (window && !isBefore(at, window.open) && isBefore(at, window.close)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\treturn true;\n}\n\n/**\n * True when `location_closures` marks the calendar day closed for the given flow.\n */\nexport function isLocationClosedByClosuresTable(\n\tat: Date,\n\tlocationId: number,\n\tclosures: LocationClosure[],\n\tkind: LocationOrderingKind,\n): boolean {\n\tconst day = formatInTimeZone(at, LOCATION_ORDERING_TIMEZONE, \"yyyy-MM-dd\");\n\n\tfor (const row of closures) {\n\t\tif (row.location_id !== locationId) continue;\n\t\tconst rowDay = formatInTimeZone(new Date(row.date), LOCATION_ORDERING_TIMEZONE, \"yyyy-MM-dd\");\n\t\tif (rowDay !== day) continue;\n\t\tif (kind === \"orders\" && Boolean(row.orders_closed)) return true;\n\t\tif (kind === \"catering\" && Boolean(row.catering_closed)) return true;\n\t}\n\treturn false;\n}\n\nexport interface LocationOrderingBlockedOptions {\n\tkind?: LocationOrderingKind;\n\t/** When true (default), times before the current instant are blocked. */\n\tblockIfInPast?: boolean;\n}\n\n/**\n * Single client-side check: blocked if missing location, in the past (optional),\n * `force_close`, outside weekly hours, or `location_closures` for that calendar day.\n */\nexport function isLocationOrderingBlockedAt(\n\tat: Date,\n\tlocation: Location | null,\n\tclosures: LocationClosure[],\n\toptions?: LocationOrderingBlockedOptions,\n): boolean {\n\tif (!location || location.location_id === 0) return true;\n\n\tconst kind = options?.kind ?? \"orders\";\n\tconst blockIfInPast = options?.blockIfInPast !== false;\n\tif (blockIfInPast && at.getTime() < Date.now()) {\n\t\treturn true;\n\t}\n\n\tif (isLocationClosedByWeeklySchedule(at, location)) return true;\n\tif (isLocationClosedByClosuresTable(at, location.location_id, closures, kind)) return true;\n\n\treturn false;\n}\n\n/**\n * Loads `location_closures` rows for the given location ids (Supabase anon / user client).\n */\nexport async function fetchLocationClosuresForLocations(\n\tclient: SupabaseClient,\n\tlocationIds: number[],\n): Promise<LocationClosure[]> {\n\tif (locationIds.length === 0) return [];\n\n\tconst { data, error } = await client\n\t\t.from(\"location_closures\")\n\t\t.select(\"id, franchise_id, location_id, orders_closed, catering_closed, date\")\n\t\t.in(\"location_id\", locationIds);\n\n\tif (error) {\n\t\tconsole.error(\"[fetchLocationClosuresForLocations]\", error.message);\n\t\treturn [];\n\t}\n\n\treturn (data ?? []) as LocationClosure[];\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACuDA,IAAM,oBAAoB,IAAI,KAAK,eAAe,SAAS;AAAA,EACvD,WAAW;AAAA,EACX,UAAU;AAAA,EACV,MAAM;AAAA,EACN,OAAO;AAAA,EACP,KAAK;AAAA,EACL,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AACZ,CAAC,EAAE,OAAO,oBAAI,KAAK,0BAA0B,CAAC;;;AC9D9C,IAAM,yBAAyB,KAAK;;;AC8B7B,SAAS,SAAS,OAAuB;AAC/C,SAAO,MAAM,QAAQ,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,CAAC;AACtD;;;AHjBA,IAAM,OAAO;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD;AAGA,SAAS,aAAa,MAAsB;AAC3C,SAAO,GAAG,KAAK,OAAO,EAAE;AACzB;AACA,SAAS,SAAS,MAAsB,UAAoB;AAC3D,SAAO,GAAG,KAAK,OAAO,EAAE,aAAa,SAAS,WAAW;AAC1D;AAOO,SAAS,0BAA0B,UAAoB;AAC7D,QAAM,WAAW,oBAAI,IAAsB;AAC3C,aAAW,OAAO,MAAM;AACvB,UAAM,MAAM,IAAI,YAAY;AAC5B,UAAM,OAAO,SAAS,GAAG,GAAG,OAAyB;AACrD,UAAM,QAAQ,SAAS,GAAG,GAAG,QAA0B;AACvD,QAAI,CAAC,QAAQ,CAAC,MAAO;AACrB,UAAM,SAAS,GAAG,SAAS,IAAI,CAAC,IAAI,SAAS,KAAK,CAAC;AACnD,aAAS,IAAI,QAAQ,CAAC,GAAI,SAAS,IAAI,MAAM,KAAK,CAAC,GAAI,sBAAsB,GAAG,EAAE,CAAC;AAAA,EACpF;AAEA,SAAO,CAAC,GAAG,QAAQ,EAAE,IAAI,CAAC,CAAC,QAAQ,SAAS,MAAM;AACjD,UAAM,CAAC,OAAO,MAAM,IAAI,OAAO,MAAM,GAAG;AACxC,WAAO,EAAE,SAAS,6BAA6B,WAAW,OAAO,OAAO;AAAA,EACzE,CAAC;AACF;AAQA,IAAM,aAAa;AAgBnB,SAAS,cAAc,SAAmD;AACzE,QAAM,QAAQ,WAAW,KAAK,QAAQ,KAAK,CAAC;AAC5C,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,CAAC,EAAE,eAAe,iBAAiB,eAAe,UAAU,IAAI;AAItE,MAAI,CAAC,iBAAiB,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,WAAY,QAAO;AAChF,SAAO;AAAA,IACN,SAAS;AAAA,IACT,eAAe,cAAc,KAAK;AAAA,IAClC,iBAAiB,gBAAgB,KAAK;AAAA,IACtC;AAAA,IACA;AAAA,IACA,gBAAgB;AAAA,EACjB;AACD;AAIA,SAAS,cAAc,UAAoB;AAC1C,QAAM,QAAiC,CAAC;AACxC,MAAI,SAAS,QAAS,OAAM,UAAU,cAAc,SAAS,OAAO;AACpE,MAAI,SAAS,MAAO,OAAM,YAAY,SAAS;AAC/C,MAAI,SAAS,OAAO,QAAQ,SAAS,OAAO,MAAM;AACjD,UAAM,MAAM;AAAA,MACX,SAAS;AAAA,MACT,UAAU,SAAS;AAAA,MACnB,WAAW,SAAS;AAAA,IACrB;AAAA,EACD;AACA,QAAM,QAAQ,0BAA0B,QAAQ;AAChD,MAAI,MAAM,OAAQ,OAAM,4BAA4B;AACpD,SAAO;AACR;AAOO,SAAS,oBAAoB,UAAoB,MAAsB;AAC7E,QAAM,OAAgC;AAAA,IACrC,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,OAAO,SAAS,MAAM,QAAQ;AAAA,IAC9B,MAAM,GAAG,KAAK,IAAI,WAAM,SAAS,IAAI;AAAA,IACrC,oBAAoB,EAAE,OAAO,aAAa,IAAI,EAAE;AAAA,IAChD,GAAG,cAAc,QAAQ;AAAA,EAC1B;AACA,MAAI,KAAK,MAAO,MAAK,QAAQ,KAAK;AAClC,SAAO;AACR;AAGA,SAAS,YAAY,OAAuB;AAC3C,UAAQ,QAAQ,KAAK,QAAQ,CAAC;AAC/B;AAEA,SAAS,aAAa,SAAsB;AAC3C,SAAO;AAAA,IACN,SAAS;AAAA,IACT,OAAO,YAAY,QAAQ,iBAAiB,QAAQ,KAAK;AAAA,IACzD,eAAe;AAAA,EAChB;AACD;AAEA,SAAS,aAAa,MAAgB;AACrC,QAAM,OAAgC;AAAA,IACrC,SAAS;AAAA,IACT,MAAM,KAAK;AAAA,EACZ;AACA,MAAI,KAAK,YAAa,MAAK,cAAc,KAAK;AAE9C,QAAM,SAAS,KAAK,SAAS,IAAI,YAAY;AAC7C,MAAI,OAAO,WAAW,EAAG,MAAK,SAAS,OAAO,CAAC;AAAA,WACtC,OAAO,SAAS,EAAG,MAAK,SAAS;AAC1C,SAAO;AACR;AAQO,SAAS,oBAAoB,MAAgB;AACnD,SAAO,EAAE,YAAY,sBAAsB,GAAG,aAAa,IAAI,EAAE;AAClE;AAQO,SAAS,sBAAsB,MAAY,MAAsB;AACvE,QAAM,iBAAiB,KAAK,WAAW,IAAI,CAAC,aAAa;AACxD,UAAM,UAAmC;AAAA,MACxC,SAAS;AAAA,MACT,MAAM,SAAS;AAAA,IAChB;AACA,UAAM,QAAQ,KAAK,MACjB,OAAO,CAAC,SAAS,KAAK,gBAAgB,SAAS,WAAW,EAC1D,IAAI,YAAY;AAClB,QAAI,MAAM,OAAQ,SAAQ,cAAc;AACxC,WAAO;AAAA,EACR,CAAC;AAED,QAAM,aAAsC;AAAA,IAC3C,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,MAAM,KAAK;AAAA,EACZ;AACA,MAAI,KAAK,IAAK,YAAW,MAAM,KAAK;AACpC,MAAI,KAAK,MAAO,YAAW,QAAQ,KAAK;AACxC,MAAI,KAAK,YAAa,YAAW,cAAc,KAAK;AACpD,MAAI,KAAK,eAAe,OAAQ,YAAW,gBAAgB,KAAK;AAKhE,QAAM,YAAY,KAAK,aAAa,CAAC;AACrC,MAAI,UAAU,WAAW,GAAG;AAC3B,WAAO,OAAO,YAAY,cAAc,UAAU,CAAC,CAAE,CAAC;AAAA,EACvD,WAAW,UAAU,SAAS,GAAG;AAChC,eAAW,KAAK,IAAI,aAAa,IAAI;AACrC,eAAW,kBAAkB,UAAU,IAAI,CAAC,cAAc;AAAA,MACzD,SAAS;AAAA,MACT,OAAO,SAAS,MAAM,QAAQ;AAAA,MAC9B,MAAM,SAAS;AAAA,IAChB,EAAE;AAAA,EACH;AAEA,aAAW,UAAU;AAAA,IACpB,SAAS;AAAA,IACT,MAAM;AAAA,IACN;AAAA,EACD;AAEA,SAAO;AACR;","names":[]}
package/dist/seo.mjs CHANGED
@@ -1,4 +1,96 @@
1
+ // ../../node_modules/date-fns-tz/dist/esm/_lib/tzTokenizeDate/index.js
2
+ var testDateFormatted = new Intl.DateTimeFormat("en-US", {
3
+ hourCycle: "h23",
4
+ timeZone: "America/New_York",
5
+ year: "numeric",
6
+ month: "2-digit",
7
+ day: "2-digit",
8
+ hour: "2-digit",
9
+ minute: "2-digit",
10
+ second: "2-digit"
11
+ }).format(/* @__PURE__ */ new Date("2014-06-25T04:00:00.123Z"));
12
+
13
+ // ../../node_modules/date-fns-tz/dist/esm/format/formatters/index.js
14
+ var MILLISECONDS_IN_MINUTE = 60 * 1e3;
15
+
16
+ // ../zorgo/universal/utils/locationOrderingAvailability.ts
17
+ function wallTime(value) {
18
+ return value.replace(/[+-].*$/, "").trim().slice(0, 5);
19
+ }
20
+
1
21
  // src/seo.ts
22
+ var DAYS = [
23
+ "Monday",
24
+ "Tuesday",
25
+ "Wednesday",
26
+ "Thursday",
27
+ "Friday",
28
+ "Saturday",
29
+ "Sunday"
30
+ ];
31
+ function restaurantId(info) {
32
+ return `${info.url ?? ""}#restaurant`;
33
+ }
34
+ function branchId(info, location) {
35
+ return `${info.url ?? ""}#location-${location.location_id}`;
36
+ }
37
+ function openingHoursSpecification(location) {
38
+ const byWindow = /* @__PURE__ */ new Map();
39
+ for (const day of DAYS) {
40
+ const key = day.toLowerCase();
41
+ const open = location[`${key}_open`];
42
+ const close = location[`${key}_close`];
43
+ if (!open || !close) continue;
44
+ const window = `${wallTime(open)}|${wallTime(close)}`;
45
+ byWindow.set(window, [...byWindow.get(window) ?? [], `https://schema.org/${day}`]);
46
+ }
47
+ return [...byWindow].map(([window, dayOfWeek]) => {
48
+ const [opens, closes] = window.split("|");
49
+ return { "@type": "OpeningHoursSpecification", dayOfWeek, opens, closes };
50
+ });
51
+ }
52
+ var US_ADDRESS = /^(.+),\s*([^,]+),\s*([A-Z]{2})\s+(\d{5}(?:-\d{4})?)(?:,\s*USA?)?$/;
53
+ function postalAddress(address) {
54
+ const match = US_ADDRESS.exec(address.trim());
55
+ if (!match) return address;
56
+ const [, streetAddress, addressLocality, addressRegion, postalCode] = match;
57
+ if (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;
58
+ return {
59
+ "@type": "PostalAddress",
60
+ streetAddress: streetAddress.trim(),
61
+ addressLocality: addressLocality.trim(),
62
+ addressRegion,
63
+ postalCode,
64
+ addressCountry: "US"
65
+ };
66
+ }
67
+ function locationFacts(location) {
68
+ const facts = {};
69
+ if (location.address) facts.address = postalAddress(location.address);
70
+ if (location.phone) facts.telephone = location.phone;
71
+ if (location.lat != null && location.lng != null) {
72
+ facts.geo = {
73
+ "@type": "GeoCoordinates",
74
+ latitude: location.lat,
75
+ longitude: location.lng
76
+ };
77
+ }
78
+ const hours = openingHoursSpecification(location);
79
+ if (hours.length) facts.openingHoursSpecification = hours;
80
+ return facts;
81
+ }
82
+ function buildLocationJsonLd(location, info) {
83
+ const node = {
84
+ "@context": "https://schema.org",
85
+ "@type": "Restaurant",
86
+ "@id": branchId(info, location),
87
+ name: `${info.name} \u2014 ${location.name}`,
88
+ parentOrganization: { "@id": restaurantId(info) },
89
+ ...locationFacts(location)
90
+ };
91
+ if (info.image) node.image = info.image;
92
+ return node;
93
+ }
2
94
  function priceString(cents) {
3
95
  return (cents / 100).toFixed(2);
4
96
  }
@@ -42,6 +134,17 @@ function buildRestaurantJsonLd(menu, info) {
42
134
  if (info.image) restaurant.image = info.image;
43
135
  if (info.description) restaurant.description = info.description;
44
136
  if (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;
137
+ const locations = info.locations ?? [];
138
+ if (locations.length === 1) {
139
+ Object.assign(restaurant, locationFacts(locations[0]));
140
+ } else if (locations.length > 1) {
141
+ restaurant["@id"] = restaurantId(info);
142
+ restaurant.subOrganization = locations.map((location) => ({
143
+ "@type": "Restaurant",
144
+ "@id": branchId(info, location),
145
+ name: location.name
146
+ }));
147
+ }
45
148
  restaurant.hasMenu = {
46
149
  "@type": "Menu",
47
150
  name: "Menu",
@@ -50,7 +153,9 @@ function buildRestaurantJsonLd(menu, info) {
50
153
  return restaurant;
51
154
  }
52
155
  export {
156
+ buildLocationJsonLd,
53
157
  buildMenuItemJsonLd,
54
- buildRestaurantJsonLd
158
+ buildRestaurantJsonLd,
159
+ openingHoursSpecification
55
160
  };
56
161
  //# sourceMappingURL=seo.mjs.map
package/dist/seo.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/seo.ts"],"sourcesContent":["import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, MenuItem } from \"@zorgo/universal/interfaces\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n"],"mappings":";AAcA,SAAS,YAAY,OAAuB;AAC3C,UAAQ,QAAQ,KAAK,QAAQ,CAAC;AAC/B;AAEA,SAAS,aAAa,SAAsB;AAC3C,SAAO;AAAA,IACN,SAAS;AAAA,IACT,OAAO,YAAY,QAAQ,iBAAiB,QAAQ,KAAK;AAAA,IACzD,eAAe;AAAA,EAChB;AACD;AAEA,SAAS,aAAa,MAAgB;AACrC,QAAM,OAAgC;AAAA,IACrC,SAAS;AAAA,IACT,MAAM,KAAK;AAAA,EACZ;AACA,MAAI,KAAK,YAAa,MAAK,cAAc,KAAK;AAE9C,QAAM,SAAS,KAAK,SAAS,IAAI,YAAY;AAC7C,MAAI,OAAO,WAAW,EAAG,MAAK,SAAS,OAAO,CAAC;AAAA,WACtC,OAAO,SAAS,EAAG,MAAK,SAAS;AAC1C,SAAO;AACR;AAQO,SAAS,oBAAoB,MAAgB;AACnD,SAAO,EAAE,YAAY,sBAAsB,GAAG,aAAa,IAAI,EAAE;AAClE;AAQO,SAAS,sBAAsB,MAAY,MAAsB;AACvE,QAAM,iBAAiB,KAAK,WAAW,IAAI,CAAC,aAAa;AACxD,UAAM,UAAmC;AAAA,MACxC,SAAS;AAAA,MACT,MAAM,SAAS;AAAA,IAChB;AACA,UAAM,QAAQ,KAAK,MACjB,OAAO,CAAC,SAAS,KAAK,gBAAgB,SAAS,WAAW,EAC1D,IAAI,YAAY;AAClB,QAAI,MAAM,OAAQ,SAAQ,cAAc;AACxC,WAAO;AAAA,EACR,CAAC;AAED,QAAM,aAAsC;AAAA,IAC3C,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,MAAM,KAAK;AAAA,EACZ;AACA,MAAI,KAAK,IAAK,YAAW,MAAM,KAAK;AACpC,MAAI,KAAK,MAAO,YAAW,QAAQ,KAAK;AACxC,MAAI,KAAK,YAAa,YAAW,cAAc,KAAK;AACpD,MAAI,KAAK,eAAe,OAAQ,YAAW,gBAAgB,KAAK;AAChE,aAAW,UAAU;AAAA,IACpB,SAAS;AAAA,IACT,MAAM;AAAA,IACN;AAAA,EACD;AAEA,SAAO;AACR;","names":[]}
1
+ {"version":3,"sources":["../../../node_modules/date-fns-tz/dist/esm/_lib/tzTokenizeDate/index.js","../../../node_modules/date-fns-tz/dist/esm/format/formatters/index.js","../../zorgo/universal/utils/locationOrderingAvailability.ts","../src/seo.ts"],"sourcesContent":["/**\n * Returns the [year, month, day, hour, minute, seconds] tokens of the provided\n * `date` as it will be rendered in the `timeZone`.\n */\nexport function tzTokenizeDate(date, timeZone) {\n const dtf = getDateTimeFormat(timeZone);\n return 'formatToParts' in dtf ? partsOffset(dtf, date) : hackyOffset(dtf, date);\n}\nconst typeToPos = {\n year: 0,\n month: 1,\n day: 2,\n hour: 3,\n minute: 4,\n second: 5,\n};\nfunction partsOffset(dtf, date) {\n try {\n const formatted = dtf.formatToParts(date);\n const filled = [];\n for (let i = 0; i < formatted.length; i++) {\n const pos = typeToPos[formatted[i].type];\n if (pos !== undefined) {\n filled[pos] = parseInt(formatted[i].value, 10);\n }\n }\n return filled;\n }\n catch (error) {\n if (error instanceof RangeError) {\n return [NaN];\n }\n throw error;\n }\n}\nfunction hackyOffset(dtf, date) {\n const formatted = dtf.format(date);\n // eslint-disable-next-line @typescript-eslint/no-non-null-assertion\n const parsed = /(\\d+)\\/(\\d+)\\/(\\d+),? (\\d+):(\\d+):(\\d+)/.exec(formatted);\n // const [, fMonth, fDay, fYear, fHour, fMinute, fSecond] = parsed\n // return [fYear, fMonth, fDay, fHour, fMinute, fSecond]\n return [\n parseInt(parsed[3], 10),\n parseInt(parsed[1], 10),\n parseInt(parsed[2], 10),\n parseInt(parsed[4], 10),\n parseInt(parsed[5], 10),\n parseInt(parsed[6], 10),\n ];\n}\n// Get a cached Intl.DateTimeFormat instance for the IANA `timeZone`. This can be used\n// to get deterministic local date/time output according to the `en-US` locale which\n// can be used to extract local time parts as necessary.\nconst dtfCache = {};\n// New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12`\nconst testDateFormatted = new Intl.DateTimeFormat('en-US', {\n hourCycle: 'h23',\n timeZone: 'America/New_York',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n}).format(new Date('2014-06-25T04:00:00.123Z'));\nconst hourCycleSupported = testDateFormatted === '06/25/2014, 00:00:00' ||\n testDateFormatted === '‎06‎/‎25‎/‎2014‎ ‎00‎:‎00‎:‎00';\nfunction getDateTimeFormat(timeZone) {\n if (!dtfCache[timeZone]) {\n dtfCache[timeZone] = hourCycleSupported\n ? new Intl.DateTimeFormat('en-US', {\n hourCycle: 'h23',\n timeZone: timeZone,\n year: 'numeric',\n month: 'numeric',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n })\n : new Intl.DateTimeFormat('en-US', {\n hour12: false,\n timeZone: timeZone,\n year: 'numeric',\n month: 'numeric',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n });\n }\n return dtfCache[timeZone];\n}\n","import { tzIntlTimeZoneName } from '../../_lib/tzIntlTimeZoneName/index.js';\nimport { tzParseTimezone } from '../../_lib/tzParseTimezone/index.js';\nconst MILLISECONDS_IN_MINUTE = 60 * 1000;\nexport const formatters = {\n // Timezone (ISO-8601. If offset is 0, output is always `'Z'`)\n X: function (date, token, options) {\n const timezoneOffset = getTimeZoneOffset(options.timeZone, date);\n if (timezoneOffset === 0) {\n return 'Z';\n }\n switch (token) {\n // Hours and optional minutes\n case 'X':\n return formatTimezoneWithOptionalMinutes(timezoneOffset);\n // Hours, minutes and optional seconds without `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `XX`\n case 'XXXX':\n case 'XX': // Hours and minutes without `:` delimeter\n return formatTimezone(timezoneOffset);\n // Hours, minutes and optional seconds with `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `XXX`\n case 'XXXXX':\n case 'XXX': // Hours and minutes with `:` delimeter\n default:\n return formatTimezone(timezoneOffset, ':');\n }\n },\n // Timezone (ISO-8601. If offset is 0, output is `'+00:00'` or equivalent)\n x: function (date, token, options) {\n const timezoneOffset = getTimeZoneOffset(options.timeZone, date);\n switch (token) {\n // Hours and optional minutes\n case 'x':\n return formatTimezoneWithOptionalMinutes(timezoneOffset);\n // Hours, minutes and optional seconds without `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `xx`\n case 'xxxx':\n case 'xx': // Hours and minutes without `:` delimeter\n return formatTimezone(timezoneOffset);\n // Hours, minutes and optional seconds with `:` delimeter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `xxx`\n case 'xxxxx':\n case 'xxx': // Hours and minutes with `:` delimeter\n default:\n return formatTimezone(timezoneOffset, ':');\n }\n },\n // Timezone (GMT)\n O: function (date, token, options) {\n const timezoneOffset = getTimeZoneOffset(options.timeZone, date);\n switch (token) {\n // Short\n case 'O':\n case 'OO':\n case 'OOO':\n return 'GMT' + formatTimezoneShort(timezoneOffset, ':');\n // Long\n case 'OOOO':\n default:\n return 'GMT' + formatTimezone(timezoneOffset, ':');\n }\n },\n // Timezone (specific non-location)\n z: function (date, token, options) {\n switch (token) {\n // Short\n case 'z':\n case 'zz':\n case 'zzz':\n return tzIntlTimeZoneName('short', date, options);\n // Long\n case 'zzzz':\n default:\n return tzIntlTimeZoneName('long', date, options);\n }\n },\n};\nfunction getTimeZoneOffset(timeZone, originalDate) {\n const timeZoneOffset = timeZone\n ? tzParseTimezone(timeZone, originalDate, true) / MILLISECONDS_IN_MINUTE\n : originalDate?.getTimezoneOffset() ?? 0;\n if (Number.isNaN(timeZoneOffset)) {\n throw new RangeError('Invalid time zone specified: ' + timeZone);\n }\n return timeZoneOffset;\n}\nfunction addLeadingZeros(number, targetLength) {\n const sign = number < 0 ? '-' : '';\n let output = Math.abs(number).toString();\n while (output.length < targetLength) {\n output = '0' + output;\n }\n return sign + output;\n}\nfunction formatTimezone(offset, delimiter = '') {\n const sign = offset > 0 ? '-' : '+';\n const absOffset = Math.abs(offset);\n const hours = addLeadingZeros(Math.floor(absOffset / 60), 2);\n const minutes = addLeadingZeros(Math.floor(absOffset % 60), 2);\n return sign + hours + delimiter + minutes;\n}\nfunction formatTimezoneWithOptionalMinutes(offset, delimiter) {\n if (offset % 60 === 0) {\n const sign = offset > 0 ? '-' : '+';\n return sign + addLeadingZeros(Math.abs(offset) / 60, 2);\n }\n return formatTimezone(offset, delimiter);\n}\nfunction formatTimezoneShort(offset, delimiter = '') {\n const sign = offset > 0 ? '-' : '+';\n const absOffset = Math.abs(offset);\n const hours = Math.floor(absOffset / 60);\n const minutes = absOffset % 60;\n if (minutes === 0) {\n return sign + String(hours);\n }\n return sign + String(hours) + delimiter + addLeadingZeros(minutes, 2);\n}\n","import type { SupabaseClient } from \"@supabase/supabase-js\";\nimport { isBefore } from \"date-fns\";\nimport { formatInTimeZone, toDate } from \"date-fns-tz\";\nimport type { Location, LocationClosure } from \"../interfaces\";\n\nexport type LocationOrderingKind = \"orders\" | \"catering\";\n\n/** Same opinionated TZ as legacy menu checks. */\nexport const LOCATION_ORDERING_TIMEZONE = \"America/New_York\";\n\nconst DAYS = [\n\t\"sunday\",\n\t\"monday\",\n\t\"tuesday\",\n\t\"wednesday\",\n\t\"thursday\",\n\t\"friday\",\n\t\"saturday\",\n] as const;\n\n/** The open/close instants of one calendar day's window, in LOCATION_ORDERING_TIMEZONE. */\nexport interface LocationWindow {\n\t/** Lowercase weekday the window's hours were read from. */\n\tday: (typeof DAYS)[number];\n\topen: Date;\n\tclose: Date;\n}\n\n/**\n * \"11:00:00+00\" -> \"11:00\". The stored offset is an artifact of the\n * `time with time zone` column; the digits are the location's wall clock.\n */\nexport function wallTime(value: string): string {\n\treturn value.replace(/[+-].*$/, \"\").trim().slice(0, 5);\n}\n\n/**\n * The open/close instants for the day `dayOffset` days from `at`, read in\n * LOCATION_ORDERING_TIMEZONE. Null when that weekday has no hours set.\n *\n * A close at or before the open is read as closing after midnight, so an\n * 11pm–2am window is one continuous night rather than a closed day.\n */\nexport function locationWindowOn(\n\tat: Date,\n\tlocation: Location,\n\tdayOffset = 0,\n): LocationWindow | null {\n\t// Anchored at noon UTC so stepping whole days can never cross a date\n\t// boundary the way adding 24h to a midnight-adjacent instant can.\n\tconst anchor = new Date(\n\t\t`${formatInTimeZone(at, LOCATION_ORDERING_TIMEZONE, \"yyyy-MM-dd\")}T12:00:00Z`,\n\t);\n\tanchor.setUTCDate(anchor.getUTCDate() + dayOffset);\n\tconst dateStr = anchor.toISOString().slice(0, 10);\n\tconst day = DAYS[anchor.getUTCDay()]!;\n\n\tconst open = location[`${day}_open` as keyof Location] as string | undefined;\n\tconst close = location[`${day}_close` as keyof Location] as string | undefined;\n\tif (!open || !close) return null;\n\n\tconst openAt = toDate(`${dateStr}T${wallTime(open)}`, {\n\t\ttimeZone: LOCATION_ORDERING_TIMEZONE,\n\t});\n\tlet closeAt = toDate(`${dateStr}T${wallTime(close)}`, {\n\t\ttimeZone: LOCATION_ORDERING_TIMEZONE,\n\t});\n\tif (closeAt <= openAt) closeAt = new Date(closeAt.getTime() + 24 * 60 * 60 * 1000);\n\n\treturn { day, open: openAt, close: closeAt };\n}\n\n/**\n * True when the location is outside its weekly open window at `at`\n * (includes `force_close`). Yesterday's window is checked too, so the small\n * hours of an overnight window still count as open.\n */\nexport function isLocationClosedByWeeklySchedule(at: Date, location: Location): boolean {\n\tif (location.force_close) return true;\n\n\tfor (const offset of [-1, 0]) {\n\t\tconst window = locationWindowOn(at, location, offset);\n\t\tif (window && !isBefore(at, window.open) && isBefore(at, window.close)) {\n\t\t\treturn false;\n\t\t}\n\t}\n\treturn true;\n}\n\n/**\n * True when `location_closures` marks the calendar day closed for the given flow.\n */\nexport function isLocationClosedByClosuresTable(\n\tat: Date,\n\tlocationId: number,\n\tclosures: LocationClosure[],\n\tkind: LocationOrderingKind,\n): boolean {\n\tconst day = formatInTimeZone(at, LOCATION_ORDERING_TIMEZONE, \"yyyy-MM-dd\");\n\n\tfor (const row of closures) {\n\t\tif (row.location_id !== locationId) continue;\n\t\tconst rowDay = formatInTimeZone(new Date(row.date), LOCATION_ORDERING_TIMEZONE, \"yyyy-MM-dd\");\n\t\tif (rowDay !== day) continue;\n\t\tif (kind === \"orders\" && Boolean(row.orders_closed)) return true;\n\t\tif (kind === \"catering\" && Boolean(row.catering_closed)) return true;\n\t}\n\treturn false;\n}\n\nexport interface LocationOrderingBlockedOptions {\n\tkind?: LocationOrderingKind;\n\t/** When true (default), times before the current instant are blocked. */\n\tblockIfInPast?: boolean;\n}\n\n/**\n * Single client-side check: blocked if missing location, in the past (optional),\n * `force_close`, outside weekly hours, or `location_closures` for that calendar day.\n */\nexport function isLocationOrderingBlockedAt(\n\tat: Date,\n\tlocation: Location | null,\n\tclosures: LocationClosure[],\n\toptions?: LocationOrderingBlockedOptions,\n): boolean {\n\tif (!location || location.location_id === 0) return true;\n\n\tconst kind = options?.kind ?? \"orders\";\n\tconst blockIfInPast = options?.blockIfInPast !== false;\n\tif (blockIfInPast && at.getTime() < Date.now()) {\n\t\treturn true;\n\t}\n\n\tif (isLocationClosedByWeeklySchedule(at, location)) return true;\n\tif (isLocationClosedByClosuresTable(at, location.location_id, closures, kind)) return true;\n\n\treturn false;\n}\n\n/**\n * Loads `location_closures` rows for the given location ids (Supabase anon / user client).\n */\nexport async function fetchLocationClosuresForLocations(\n\tclient: SupabaseClient,\n\tlocationIds: number[],\n): Promise<LocationClosure[]> {\n\tif (locationIds.length === 0) return [];\n\n\tconst { data, error } = await client\n\t\t.from(\"location_closures\")\n\t\t.select(\"id, franchise_id, location_id, orders_closed, catering_closed, date\")\n\t\t.in(\"location_id\", locationIds);\n\n\tif (error) {\n\t\tconsole.error(\"[fetchLocationClosuresForLocations]\", error.message);\n\t\treturn [];\n\t}\n\n\treturn (data ?? []) as LocationClosure[];\n}\n","import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, Location, MenuItem } from \"@zorgo/universal/interfaces\";\nimport { wallTime } from \"@zorgo/universal/utils/locationOrderingAvailability\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n\t/** Public locations from `lib/generated/locations.ts`. One location's facts\n\t * land on the Restaurant node itself; several become linked branches. */\n\tlocations?: Location[];\n}\n\nconst DAYS = [\n\t\"Monday\",\n\t\"Tuesday\",\n\t\"Wednesday\",\n\t\"Thursday\",\n\t\"Friday\",\n\t\"Saturday\",\n\t\"Sunday\",\n] as const;\n\n/** Stable node ids so the parent and its branches can reference each other. */\nfunction restaurantId(info: RestaurantInfo) {\n\treturn `${info.url ?? \"\"}#restaurant`;\n}\nfunction branchId(info: RestaurantInfo, location: Location) {\n\treturn `${info.url ?? \"\"}#location-${location.location_id}`;\n}\n\n/**\n * One OpeningHoursSpecification per distinct weekly window, with the days that\n * share it grouped into `dayOfWeek`. Days with no hours set are simply absent,\n * which is how schema.org expresses \"closed\".\n */\nexport function openingHoursSpecification(location: Location) {\n\tconst byWindow = new Map<string, string[]>();\n\tfor (const day of DAYS) {\n\t\tconst key = day.toLowerCase();\n\t\tconst open = location[`${key}_open` as keyof Location] as string | undefined;\n\t\tconst close = location[`${key}_close` as keyof Location] as string | undefined;\n\t\tif (!open || !close) continue;\n\t\tconst window = `${wallTime(open)}|${wallTime(close)}`;\n\t\tbyWindow.set(window, [...(byWindow.get(window) ?? []), `https://schema.org/${day}`]);\n\t}\n\n\treturn [...byWindow].map(([window, dayOfWeek]) => {\n\t\tconst [opens, closes] = window.split(\"|\");\n\t\treturn { \"@type\": \"OpeningHoursSpecification\", dayOfWeek, opens, closes };\n\t});\n}\n\n/**\n * `123 Main St, Springfield, IL 62704` — street, city, two-letter state, ZIP,\n * with an optional +4 and an optional trailing country. Anchored at both ends\n * and deliberately strict: everything before the last two commas is the street,\n * so a unit (\"… Ste 4, Springfield, IL 62704\") still parses.\n */\nconst US_ADDRESS = /^(.+),\\s*([^,]+),\\s*([A-Z]{2})\\s+(\\d{5}(?:-\\d{4})?)(?:,\\s*USA?)?$/;\n\n/**\n * schema.org accepts `address` as either Text or a PostalAddress node, but\n * Google's LocalBusiness rich results only read the structured form — a bare\n * string means the branch nodes never qualify.\n *\n * `address` is still one staff-typed column, so this parses rather than reads,\n * and it only parses what it can prove: a string that does not match the US\n * shape exactly falls back to the Text form it has always shipped. A wrong\n * `addressRegion` in structured data is worse than no `addressRegion` — it is a\n * fact we are asserting to Google — so the regex declines rather than guesses.\n *\n * ponytail: US-only, because every franchise is. Drop the fallback and read real\n * columns the day `locations` stores the parts separately.\n */\nfunction postalAddress(address: string): Record<string, unknown> | string {\n\tconst match = US_ADDRESS.exec(address.trim());\n\tif (!match) return address;\n\tconst [, streetAddress, addressLocality, addressRegion, postalCode] = match;\n\t// Unreachable — all four groups are required by the pattern — but under\n\t// noUncheckedIndexedAccess a capture is `string | undefined`, and declining\n\t// is already what this function does when it cannot prove the shape.\n\tif (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;\n\treturn {\n\t\t\"@type\": \"PostalAddress\",\n\t\tstreetAddress: streetAddress.trim(),\n\t\taddressLocality: addressLocality.trim(),\n\t\taddressRegion,\n\t\tpostalCode,\n\t\taddressCountry: \"US\",\n\t};\n}\n\n/** address / geo / telephone / hours — the facts shared by the single-location\n * Restaurant node and every branch node. */\nfunction locationFacts(location: Location) {\n\tconst facts: Record<string, unknown> = {};\n\tif (location.address) facts.address = postalAddress(location.address);\n\tif (location.phone) facts.telephone = location.phone;\n\tif (location.lat != null && location.lng != null) {\n\t\tfacts.geo = {\n\t\t\t\"@type\": \"GeoCoordinates\",\n\t\t\tlatitude: location.lat,\n\t\t\tlongitude: location.lng,\n\t\t};\n\t}\n\tconst hours = openingHoursSpecification(location);\n\tif (hours.length) facts.openingHoursSpecification = hours;\n\treturn facts;\n}\n\n/**\n * The branch node for one location, for that location's page. Carries the same\n * address/geo/hours facts as the parent and links back to it via\n * `parentOrganization`. Pure — safe to run at build time.\n */\nexport function buildLocationJsonLd(location: Location, info: RestaurantInfo) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\t\"@id\": branchId(info, location),\n\t\tname: `${info.name} — ${location.name}`,\n\t\tparentOrganization: { \"@id\": restaurantId(info) },\n\t\t...locationFacts(location),\n\t};\n\tif (info.image) node.image = info.image;\n\treturn node;\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\n\t// A single-location franchise *is* its storefront, so its facts belong on\n\t// the Restaurant node. Several locations get referenced as branches instead\n\t// — the full branch nodes live on the location pages, not duplicated here.\n\tconst locations = info.locations ?? [];\n\tif (locations.length === 1) {\n\t\tObject.assign(restaurant, locationFacts(locations[0]!));\n\t} else if (locations.length > 1) {\n\t\trestaurant[\"@id\"] = restaurantId(info);\n\t\trestaurant.subOrganization = locations.map((location) => ({\n\t\t\t\"@type\": \"Restaurant\",\n\t\t\t\"@id\": branchId(info, location),\n\t\t\tname: location.name,\n\t\t}));\n\t}\n\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n"],"mappings":"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","names":[]}
package/dist/server.d.mts CHANGED
@@ -1,3 +1,5 @@
1
+ import { z } from 'zod';
2
+
1
3
  /** Why a site token could not be obtained — lets callers report precisely. */
2
4
  type SiteTokenFailure = "missing-secret" | "rejected" | "unreachable";
3
5
  declare class SiteTokenError extends Error {
@@ -14,6 +16,30 @@ declare class SiteTokenError extends Error {
14
16
  * than collapsing them into one misleading "missing secret" message.
15
17
  */
16
18
  declare function fetchSiteToken(): Promise<string>;
19
+ declare const BrandingSchema: z.ZodPrefault<z.ZodObject<{
20
+ backgroundBase: z.ZodDefault<z.ZodString>;
21
+ primary: z.ZodDefault<z.ZodString>;
22
+ accent: z.ZodDefault<z.ZodString>;
23
+ fontFamily: z.ZodDefault<z.ZodString>;
24
+ }, z.core.$strip>>;
25
+ type Branding = z.infer<typeof BrandingSchema>;
26
+ /**
27
+ * Pure builder, kept separate from the file write so it's testable without
28
+ * touching the filesystem or real process.env. `env` is the site's .env — the
29
+ * single source of truth wrangler.toml is regenerated from on every prebuild.
30
+ */
31
+ declare function buildWranglerToml(env: NodeJS.ProcessEnv): string;
32
+ /**
33
+ * Pure builder for the generated branding stylesheet — separated from styles()
34
+ * so it's testable without a network fetch or filesystem. The Google Fonts
35
+ * @import must be the first statement in this file: app/globals.css imports it
36
+ * before `@import "tailwindcss"`, and Tailwind's own @import resolution would
37
+ * otherwise land the font import after non-import content, which browsers
38
+ * silently ignore.
39
+ */
40
+ declare function buildBrandingCss(branding: Branding, opts: {
41
+ selfHostedFont: boolean;
42
+ }): string;
17
43
  /**
18
44
  * Full prebuild pipeline for a scaffolded site. Loads the site's .env, exchanges
19
45
  * the site secret for a token, then fetches franchise data and writes the
@@ -22,4 +48,4 @@ declare function fetchSiteToken(): Promise<string>;
22
48
  */
23
49
  declare function runPrebuild(): Promise<void>;
24
50
 
25
- export { SiteTokenError, type SiteTokenFailure, fetchSiteToken, runPrebuild };
51
+ export { SiteTokenError, type SiteTokenFailure, buildBrandingCss, buildWranglerToml, fetchSiteToken, runPrebuild };
package/dist/server.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { z } from 'zod';
2
+
1
3
  /** Why a site token could not be obtained — lets callers report precisely. */
2
4
  type SiteTokenFailure = "missing-secret" | "rejected" | "unreachable";
3
5
  declare class SiteTokenError extends Error {
@@ -14,6 +16,30 @@ declare class SiteTokenError extends Error {
14
16
  * than collapsing them into one misleading "missing secret" message.
15
17
  */
16
18
  declare function fetchSiteToken(): Promise<string>;
19
+ declare const BrandingSchema: z.ZodPrefault<z.ZodObject<{
20
+ backgroundBase: z.ZodDefault<z.ZodString>;
21
+ primary: z.ZodDefault<z.ZodString>;
22
+ accent: z.ZodDefault<z.ZodString>;
23
+ fontFamily: z.ZodDefault<z.ZodString>;
24
+ }, z.core.$strip>>;
25
+ type Branding = z.infer<typeof BrandingSchema>;
26
+ /**
27
+ * Pure builder, kept separate from the file write so it's testable without
28
+ * touching the filesystem or real process.env. `env` is the site's .env — the
29
+ * single source of truth wrangler.toml is regenerated from on every prebuild.
30
+ */
31
+ declare function buildWranglerToml(env: NodeJS.ProcessEnv): string;
32
+ /**
33
+ * Pure builder for the generated branding stylesheet — separated from styles()
34
+ * so it's testable without a network fetch or filesystem. The Google Fonts
35
+ * @import must be the first statement in this file: app/globals.css imports it
36
+ * before `@import "tailwindcss"`, and Tailwind's own @import resolution would
37
+ * otherwise land the font import after non-import content, which browsers
38
+ * silently ignore.
39
+ */
40
+ declare function buildBrandingCss(branding: Branding, opts: {
41
+ selfHostedFont: boolean;
42
+ }): string;
17
43
  /**
18
44
  * Full prebuild pipeline for a scaffolded site. Loads the site's .env, exchanges
19
45
  * the site secret for a token, then fetches franchise data and writes the
@@ -22,4 +48,4 @@ declare function fetchSiteToken(): Promise<string>;
22
48
  */
23
49
  declare function runPrebuild(): Promise<void>;
24
50
 
25
- export { SiteTokenError, type SiteTokenFailure, fetchSiteToken, runPrebuild };
51
+ export { SiteTokenError, type SiteTokenFailure, buildBrandingCss, buildWranglerToml, fetchSiteToken, runPrebuild };