@reblu/site-next 1.12.0 → 1.14.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/CHANGELOG.md +57 -0
- package/README.md +80 -0
- package/dist/analytics.cjs.map +1 -1
- package/dist/analytics.js.map +1 -1
- package/dist/cart.cjs +137 -0
- package/dist/cart.cjs.map +1 -0
- package/dist/cart.d.cts +92 -0
- package/dist/cart.d.ts +92 -0
- package/dist/cart.js +108 -0
- package/dist/cart.js.map +1 -0
- package/dist/collection-BE5dhf-r.d.cts +106 -0
- package/dist/collection-BE5dhf-r.d.ts +106 -0
- package/dist/consent.cjs.map +1 -1
- package/dist/consent.js.map +1 -1
- package/dist/json-ld.cjs +28 -2
- package/dist/json-ld.cjs.map +1 -1
- package/dist/json-ld.d.cts +44 -35
- package/dist/json-ld.d.ts +44 -35
- package/dist/json-ld.js +26 -1
- package/dist/json-ld.js.map +1 -1
- package/dist/opening-hours.cjs +127 -0
- package/dist/opening-hours.cjs.map +1 -0
- package/dist/opening-hours.d.cts +53 -0
- package/dist/opening-hours.d.ts +53 -0
- package/dist/opening-hours.js +102 -0
- package/dist/opening-hours.js.map +1 -0
- package/dist/shop-route-handlers.cjs +140 -0
- package/dist/shop-route-handlers.cjs.map +1 -0
- package/dist/shop-route-handlers.d.cts +62 -0
- package/dist/shop-route-handlers.d.ts +62 -0
- package/dist/shop-route-handlers.js +109 -0
- package/dist/shop-route-handlers.js.map +1 -0
- package/dist/shop-server.cjs +22 -4
- package/dist/shop-server.cjs.map +1 -1
- package/dist/shop-server.d.cts +17 -101
- package/dist/shop-server.d.ts +17 -101
- package/dist/shop-server.js +21 -4
- package/dist/shop-server.js.map +1 -1
- package/dist/shop.cjs +5 -4
- package/dist/shop.cjs.map +1 -1
- package/dist/shop.d.cts +2 -2
- package/dist/shop.d.ts +2 -2
- package/dist/shop.js +5 -4
- package/dist/shop.js.map +1 -1
- package/package.json +34 -3
package/dist/json-ld.d.cts
CHANGED
|
@@ -1,39 +1,6 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ConfigOpeningHours } from '@reblu/site-contracts';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
* `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).
|
|
5
|
-
*
|
|
6
|
-
* A `<script>` element is HTML's "raw text" context: the parser closes it on the
|
|
7
|
-
* first case-insensitive `</script` byte sequence it sees, no matter whether
|
|
8
|
-
* that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON
|
|
9
|
-
* syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any
|
|
10
|
-
* JSON-LD payload built from tenant-authored data (page title, description,
|
|
11
|
-
* FAQ copy, …) into a stored-XSS sink: a value containing
|
|
12
|
-
* `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.
|
|
13
|
-
*
|
|
14
|
-
* `jsonLdScriptContent` neutralises exactly that boundary while staying
|
|
15
|
-
* byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON
|
|
16
|
-
* character escapes, so `JSON.parse` recovers the original value unchanged —
|
|
17
|
-
* this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in
|
|
18
|
-
* `@reblu/site-blocks` for that job, at the HTML boundary rather than the
|
|
19
|
-
* script boundary). U+2028/U+2029 are escaped too: both are valid inside a
|
|
20
|
-
* JSON string but are raw JS line terminators, and some `<script>` consumers
|
|
21
|
-
* (older JS parsers, some static analysers) choke on them unescaped.
|
|
22
|
-
*
|
|
23
|
-
* This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s
|
|
24
|
-
* main entry bundles `parse5` + the `hast-util-*` chain + `marked` for
|
|
25
|
-
* `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848
|
|
26
|
-
* spent a full round removing exactly that weight (89 KB gzipped) from a client
|
|
27
|
-
* bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific
|
|
28
|
-
* (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has
|
|
29
|
-
* zero dependencies of its own, so it ships as its OWN tsup entry
|
|
30
|
-
* (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel
|
|
31
|
-
* — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +
|
|
32
|
-
* `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`
|
|
33
|
-
* already use for the same reason.
|
|
34
|
-
*
|
|
35
|
-
* @see https://nextjs.org/docs/app/guides/json-ld
|
|
36
|
-
*/
|
|
37
4
|
/**
|
|
38
5
|
* Serialises `value` for embedding inside `<script type="application/ld+json">`.
|
|
39
6
|
* Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON
|
|
@@ -62,5 +29,47 @@ interface JsonLdProps {
|
|
|
62
29
|
* hydration diff.
|
|
63
30
|
*/
|
|
64
31
|
declare function JsonLd({ id, data }: JsonLdProps): react_jsx_runtime.JSX.Element;
|
|
32
|
+
/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/
|
|
33
|
+
* `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.
|
|
34
|
+
* @see https://schema.org/OpeningHoursSpecification
|
|
35
|
+
*/
|
|
36
|
+
interface OpeningHoursSpecification {
|
|
37
|
+
'@type': 'OpeningHoursSpecification';
|
|
38
|
+
dayOfWeek: string[];
|
|
39
|
+
opens: string;
|
|
40
|
+
closes: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Maps the Reblu `Config.openingHours` typed value to one
|
|
44
|
+
* `OpeningHoursSpecification` object per range, each with its `days` turned
|
|
45
|
+
* into schema.org `DayOfWeek` URIs. One range = one spec object; this does
|
|
46
|
+
* NOT group or reformat for display (see `formatOpeningHours` in
|
|
47
|
+
* `@reblu/site-next/opening-hours` for that) — structured data wants one
|
|
48
|
+
* entry per machine-readable (days, opens, closes) tuple, not prose.
|
|
49
|
+
*
|
|
50
|
+
* Returns `undefined` — not `[]` — for "nothing to merge" (no typed hours, or
|
|
51
|
+
* a typed value with zero ranges), so a caller building a JSON-LD entity with
|
|
52
|
+
* `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can
|
|
53
|
+
* `if (spec)` guard it without emitting an empty array into the graph.
|
|
54
|
+
*
|
|
55
|
+
* Used both by SITE builds assembling their own JSON-LD (the published use
|
|
56
|
+
* case this SDK exists for) and by base-api's own structured-data builder —
|
|
57
|
+
* see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`
|
|
58
|
+
* (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:
|
|
59
|
+
* that duplication exists only where two independently-deployed sides (a
|
|
60
|
+
* SITE's stored cookie vs. base-api's request-time check) must each derive
|
|
61
|
+
* the identical value without talking to each other. Here base-api is the
|
|
62
|
+
* ONLY side computing this value — there is no second computation to agree
|
|
63
|
+
* with — so importing the single implementation is strictly better than a
|
|
64
|
+
* second copy that could drift.
|
|
65
|
+
*
|
|
66
|
+
* Defensive against a malformed stored value — `Config.openingHours` is a
|
|
67
|
+
* compile-time guarantee only. A range whose `days` is empty, or whose codes
|
|
68
|
+
* are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped
|
|
69
|
+
* entirely rather than emitting a spec object with an empty `dayOfWeek` (or
|
|
70
|
+
* one padded with `undefined` for every unrecognised code). If every range
|
|
71
|
+
* is dropped this way, the result is `undefined`, never `[]`.
|
|
72
|
+
*/
|
|
73
|
+
declare function toOpeningHoursSpecification(openingHours: ConfigOpeningHours | null | undefined): OpeningHoursSpecification[] | undefined;
|
|
65
74
|
|
|
66
|
-
export { JsonLd, type JsonLdProps, jsonLdScriptContent };
|
|
75
|
+
export { JsonLd, type JsonLdProps, type OpeningHoursSpecification, jsonLdScriptContent, toOpeningHoursSpecification };
|
package/dist/json-ld.d.ts
CHANGED
|
@@ -1,39 +1,6 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ConfigOpeningHours } from '@reblu/site-contracts';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
* `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).
|
|
5
|
-
*
|
|
6
|
-
* A `<script>` element is HTML's "raw text" context: the parser closes it on the
|
|
7
|
-
* first case-insensitive `</script` byte sequence it sees, no matter whether
|
|
8
|
-
* that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON
|
|
9
|
-
* syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any
|
|
10
|
-
* JSON-LD payload built from tenant-authored data (page title, description,
|
|
11
|
-
* FAQ copy, …) into a stored-XSS sink: a value containing
|
|
12
|
-
* `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.
|
|
13
|
-
*
|
|
14
|
-
* `jsonLdScriptContent` neutralises exactly that boundary while staying
|
|
15
|
-
* byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON
|
|
16
|
-
* character escapes, so `JSON.parse` recovers the original value unchanged —
|
|
17
|
-
* this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in
|
|
18
|
-
* `@reblu/site-blocks` for that job, at the HTML boundary rather than the
|
|
19
|
-
* script boundary). U+2028/U+2029 are escaped too: both are valid inside a
|
|
20
|
-
* JSON string but are raw JS line terminators, and some `<script>` consumers
|
|
21
|
-
* (older JS parsers, some static analysers) choke on them unescaped.
|
|
22
|
-
*
|
|
23
|
-
* This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s
|
|
24
|
-
* main entry bundles `parse5` + the `hast-util-*` chain + `marked` for
|
|
25
|
-
* `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848
|
|
26
|
-
* spent a full round removing exactly that weight (89 KB gzipped) from a client
|
|
27
|
-
* bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific
|
|
28
|
-
* (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has
|
|
29
|
-
* zero dependencies of its own, so it ships as its OWN tsup entry
|
|
30
|
-
* (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel
|
|
31
|
-
* — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +
|
|
32
|
-
* `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`
|
|
33
|
-
* already use for the same reason.
|
|
34
|
-
*
|
|
35
|
-
* @see https://nextjs.org/docs/app/guides/json-ld
|
|
36
|
-
*/
|
|
37
4
|
/**
|
|
38
5
|
* Serialises `value` for embedding inside `<script type="application/ld+json">`.
|
|
39
6
|
* Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON
|
|
@@ -62,5 +29,47 @@ interface JsonLdProps {
|
|
|
62
29
|
* hydration diff.
|
|
63
30
|
*/
|
|
64
31
|
declare function JsonLd({ id, data }: JsonLdProps): react_jsx_runtime.JSX.Element;
|
|
32
|
+
/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/
|
|
33
|
+
* `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.
|
|
34
|
+
* @see https://schema.org/OpeningHoursSpecification
|
|
35
|
+
*/
|
|
36
|
+
interface OpeningHoursSpecification {
|
|
37
|
+
'@type': 'OpeningHoursSpecification';
|
|
38
|
+
dayOfWeek: string[];
|
|
39
|
+
opens: string;
|
|
40
|
+
closes: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Maps the Reblu `Config.openingHours` typed value to one
|
|
44
|
+
* `OpeningHoursSpecification` object per range, each with its `days` turned
|
|
45
|
+
* into schema.org `DayOfWeek` URIs. One range = one spec object; this does
|
|
46
|
+
* NOT group or reformat for display (see `formatOpeningHours` in
|
|
47
|
+
* `@reblu/site-next/opening-hours` for that) — structured data wants one
|
|
48
|
+
* entry per machine-readable (days, opens, closes) tuple, not prose.
|
|
49
|
+
*
|
|
50
|
+
* Returns `undefined` — not `[]` — for "nothing to merge" (no typed hours, or
|
|
51
|
+
* a typed value with zero ranges), so a caller building a JSON-LD entity with
|
|
52
|
+
* `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can
|
|
53
|
+
* `if (spec)` guard it without emitting an empty array into the graph.
|
|
54
|
+
*
|
|
55
|
+
* Used both by SITE builds assembling their own JSON-LD (the published use
|
|
56
|
+
* case this SDK exists for) and by base-api's own structured-data builder —
|
|
57
|
+
* see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`
|
|
58
|
+
* (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:
|
|
59
|
+
* that duplication exists only where two independently-deployed sides (a
|
|
60
|
+
* SITE's stored cookie vs. base-api's request-time check) must each derive
|
|
61
|
+
* the identical value without talking to each other. Here base-api is the
|
|
62
|
+
* ONLY side computing this value — there is no second computation to agree
|
|
63
|
+
* with — so importing the single implementation is strictly better than a
|
|
64
|
+
* second copy that could drift.
|
|
65
|
+
*
|
|
66
|
+
* Defensive against a malformed stored value — `Config.openingHours` is a
|
|
67
|
+
* compile-time guarantee only. A range whose `days` is empty, or whose codes
|
|
68
|
+
* are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped
|
|
69
|
+
* entirely rather than emitting a spec object with an empty `dayOfWeek` (or
|
|
70
|
+
* one padded with `undefined` for every unrecognised code). If every range
|
|
71
|
+
* is dropped this way, the result is `undefined`, never `[]`.
|
|
72
|
+
*/
|
|
73
|
+
declare function toOpeningHoursSpecification(openingHours: ConfigOpeningHours | null | undefined): OpeningHoursSpecification[] | undefined;
|
|
65
74
|
|
|
66
|
-
export { JsonLd, type JsonLdProps, jsonLdScriptContent };
|
|
75
|
+
export { JsonLd, type JsonLdProps, type OpeningHoursSpecification, jsonLdScriptContent, toOpeningHoursSpecification };
|
package/dist/json-ld.js
CHANGED
|
@@ -23,8 +23,33 @@ function JsonLd({ id, data }) {
|
|
|
23
23
|
}
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
|
+
var SCHEMA_DAY_OF_WEEK = {
|
|
27
|
+
MON: "https://schema.org/Monday",
|
|
28
|
+
TUE: "https://schema.org/Tuesday",
|
|
29
|
+
WED: "https://schema.org/Wednesday",
|
|
30
|
+
THU: "https://schema.org/Thursday",
|
|
31
|
+
FRI: "https://schema.org/Friday",
|
|
32
|
+
SAT: "https://schema.org/Saturday",
|
|
33
|
+
SUN: "https://schema.org/Sunday"
|
|
34
|
+
};
|
|
35
|
+
function toOpeningHoursSpecification(openingHours) {
|
|
36
|
+
if (!openingHours || openingHours.ranges.length === 0) return void 0;
|
|
37
|
+
const specs = [];
|
|
38
|
+
for (const range of openingHours.ranges) {
|
|
39
|
+
const dayOfWeek = range.days.filter((day) => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day)).map((day) => SCHEMA_DAY_OF_WEEK[day]);
|
|
40
|
+
if (dayOfWeek.length === 0) continue;
|
|
41
|
+
specs.push({
|
|
42
|
+
"@type": "OpeningHoursSpecification",
|
|
43
|
+
dayOfWeek,
|
|
44
|
+
opens: range.opens,
|
|
45
|
+
closes: range.closes
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return specs.length > 0 ? specs : void 0;
|
|
49
|
+
}
|
|
26
50
|
export {
|
|
27
51
|
JsonLd,
|
|
28
|
-
jsonLdScriptContent
|
|
52
|
+
jsonLdScriptContent,
|
|
53
|
+
toOpeningHoursSpecification
|
|
29
54
|
};
|
|
30
55
|
//# sourceMappingURL=json-ld.js.map
|
package/dist/json-ld.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/json-ld.tsx"],"sourcesContent":["/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../src/json-ld.tsx"],"sourcesContent":["/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n\n// ─── Opening hours → schema.org OpeningHoursSpecification (REB-826) ──────────\n\n/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/\n * `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.\n * @see https://schema.org/OpeningHoursSpecification\n */\nexport interface OpeningHoursSpecification {\n '@type': 'OpeningHoursSpecification'\n dayOfWeek: string[]\n opens: string\n closes: string\n}\n\n/** schema.org `DayOfWeek` URIs, keyed by the Reblu `ConfigOpeningHoursDay` enum. */\nconst SCHEMA_DAY_OF_WEEK: Record<ConfigOpeningHoursDay, string> = {\n MON: 'https://schema.org/Monday',\n TUE: 'https://schema.org/Tuesday',\n WED: 'https://schema.org/Wednesday',\n THU: 'https://schema.org/Thursday',\n FRI: 'https://schema.org/Friday',\n SAT: 'https://schema.org/Saturday',\n SUN: 'https://schema.org/Sunday',\n}\n\n/**\n * Maps the Reblu `Config.openingHours` typed value to one\n * `OpeningHoursSpecification` object per range, each with its `days` turned\n * into schema.org `DayOfWeek` URIs. One range = one spec object; this does\n * NOT group or reformat for display (see `formatOpeningHours` in\n * `@reblu/site-next/opening-hours` for that) — structured data wants one\n * entry per machine-readable (days, opens, closes) tuple, not prose.\n *\n * Returns `undefined` — not `[]` — for \"nothing to merge\" (no typed hours, or\n * a typed value with zero ranges), so a caller building a JSON-LD entity with\n * `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can\n * `if (spec)` guard it without emitting an empty array into the graph.\n *\n * Used both by SITE builds assembling their own JSON-LD (the published use\n * case this SDK exists for) and by base-api's own structured-data builder —\n * see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`\n * (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:\n * that duplication exists only where two independently-deployed sides (a\n * SITE's stored cookie vs. base-api's request-time check) must each derive\n * the identical value without talking to each other. Here base-api is the\n * ONLY side computing this value — there is no second computation to agree\n * with — so importing the single implementation is strictly better than a\n * second copy that could drift.\n *\n * Defensive against a malformed stored value — `Config.openingHours` is a\n * compile-time guarantee only. A range whose `days` is empty, or whose codes\n * are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped\n * entirely rather than emitting a spec object with an empty `dayOfWeek` (or\n * one padded with `undefined` for every unrecognised code). If every range\n * is dropped this way, the result is `undefined`, never `[]`.\n */\nexport function toOpeningHoursSpecification(\n openingHours: ConfigOpeningHours | null | undefined,\n): OpeningHoursSpecification[] | undefined {\n if (!openingHours || openingHours.ranges.length === 0) return undefined\n const specs: OpeningHoursSpecification[] = []\n for (const range of openingHours.ranges) {\n const dayOfWeek = range.days\n // `Object.hasOwn`, not `day in SCHEMA_DAY_OF_WEEK`: the `in` operator\n // also matches INHERITED properties (`'constructor'`, `'toString'`,\n // `'hasOwnProperty'`, …), which are not own keys of this lookup object\n // but would still pass an `in` check and resolve to a function, not a\n // schema.org URI string — corrupting `dayOfWeek` with exactly the kind\n // of malformed entry this filtering exists to prevent.\n .filter((day): day is ConfigOpeningHoursDay => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day))\n .map((day) => SCHEMA_DAY_OF_WEEK[day])\n if (dayOfWeek.length === 0) continue\n specs.push({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek,\n opens: range.opens,\n closes: range.closes,\n })\n }\n return specs.length > 0 ? specs : undefined\n}\n"],"mappings":";AAiFI;AA5CJ,IAAM,yBAAyB;AAE/B,IAAM,UAAkC;AAAA,EACtC,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,UAAU;AAAA,EACV,UAAU;AACZ;AAeO,SAAS,oBAAoB,OAAwB;AAC1D,QAAM,OAAO,KAAK,UAAU,KAAK,KAAK;AACtC,SAAO,KAAK,QAAQ,wBAAwB,CAAC,SAAS,QAAQ,IAAI,CAAC;AACrE;AAgBO,SAAS,OAAO,EAAE,IAAI,KAAK,GAAgB;AAChD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,IAAI,WAAW,EAAE;AAAA,MACjB,MAAK;AAAA,MACL,0BAAwB;AAAA,MACxB,yBAAyB,EAAE,QAAQ,oBAAoB,IAAI,EAAE;AAAA;AAAA,EAC/D;AAEJ;AAgBA,IAAM,qBAA4D;AAAA,EAChE,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAiCO,SAAS,4BACd,cACyC;AACzC,MAAI,CAAC,gBAAgB,aAAa,OAAO,WAAW,EAAG,QAAO;AAC9D,QAAM,QAAqC,CAAC;AAC5C,aAAW,SAAS,aAAa,QAAQ;AACvC,UAAM,YAAY,MAAM,KAOrB,OAAO,CAAC,QAAsC,OAAO,OAAO,oBAAoB,GAAG,CAAC,EACpF,IAAI,CAAC,QAAQ,mBAAmB,GAAG,CAAC;AACvC,QAAI,UAAU,WAAW,EAAG;AAC5B,UAAM,KAAK;AAAA,MACT,SAAS;AAAA,MACT;AAAA,MACA,OAAO,MAAM;AAAA,MACb,QAAQ,MAAM;AAAA,IAChB,CAAC;AAAA,EACH;AACA,SAAO,MAAM,SAAS,IAAI,QAAQ;AACpC;","names":[]}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/opening-hours.ts
|
|
21
|
+
var opening_hours_exports = {};
|
|
22
|
+
__export(opening_hours_exports, {
|
|
23
|
+
formatOpeningHours: () => formatOpeningHours
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(opening_hours_exports);
|
|
26
|
+
var LOCALES = {
|
|
27
|
+
es: {
|
|
28
|
+
dayNames: {
|
|
29
|
+
MON: "lunes",
|
|
30
|
+
TUE: "martes",
|
|
31
|
+
WED: "mi\xE9rcoles",
|
|
32
|
+
THU: "jueves",
|
|
33
|
+
FRI: "viernes",
|
|
34
|
+
SAT: "s\xE1bado",
|
|
35
|
+
SUN: "domingo"
|
|
36
|
+
},
|
|
37
|
+
dayRange: (from, to) => `de ${from} a ${to}`,
|
|
38
|
+
joinDays: (segments) => joinWithAnd(segments, "y"),
|
|
39
|
+
sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),
|
|
40
|
+
timeWindow: (opens, closes) => `de ${opens} a ${closes}`,
|
|
41
|
+
joinWindows: (windows) => joinWithAnd(windows, "y")
|
|
42
|
+
},
|
|
43
|
+
en: {
|
|
44
|
+
dayNames: {
|
|
45
|
+
MON: "Monday",
|
|
46
|
+
TUE: "Tuesday",
|
|
47
|
+
WED: "Wednesday",
|
|
48
|
+
THU: "Thursday",
|
|
49
|
+
FRI: "Friday",
|
|
50
|
+
SAT: "Saturday",
|
|
51
|
+
SUN: "Sunday"
|
|
52
|
+
},
|
|
53
|
+
dayRange: (from, to) => `${from} to ${to}`,
|
|
54
|
+
joinDays: (segments) => joinWithAnd(segments, "and"),
|
|
55
|
+
sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),
|
|
56
|
+
timeWindow: (opens, closes) => `from ${opens} to ${closes}`,
|
|
57
|
+
joinWindows: (windows) => joinWithAnd(windows, "and")
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
var DAY_ORDER = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
|
|
61
|
+
function joinWithAnd(items, conjunction) {
|
|
62
|
+
if (items.length === 0) return "";
|
|
63
|
+
if (items.length === 1) return items[0];
|
|
64
|
+
if (items.length === 2) return `${items[0]} ${conjunction} ${items[1]}`;
|
|
65
|
+
return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
|
|
66
|
+
}
|
|
67
|
+
function capitalize(text) {
|
|
68
|
+
if (text.length === 0) return text;
|
|
69
|
+
return text[0].toUpperCase() + text.slice(1);
|
|
70
|
+
}
|
|
71
|
+
function formatDaysPhrase(days, locale) {
|
|
72
|
+
const present = new Set(days);
|
|
73
|
+
const ordered = DAY_ORDER.filter((d) => present.has(d));
|
|
74
|
+
if (ordered.length === 0) return "";
|
|
75
|
+
const segments = [];
|
|
76
|
+
let runStart = ordered[0];
|
|
77
|
+
let runEnd = ordered[0];
|
|
78
|
+
const flush = () => {
|
|
79
|
+
segments.push(
|
|
80
|
+
runStart === runEnd ? locale.dayNames[runStart] : locale.dayRange(locale.dayNames[runStart], locale.dayNames[runEnd])
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
for (let i = 1; i < ordered.length; i += 1) {
|
|
84
|
+
const prevIndex = DAY_ORDER.indexOf(runEnd);
|
|
85
|
+
const isConsecutive = DAY_ORDER[prevIndex + 1] === ordered[i];
|
|
86
|
+
if (isConsecutive) {
|
|
87
|
+
runEnd = ordered[i];
|
|
88
|
+
} else {
|
|
89
|
+
flush();
|
|
90
|
+
runStart = ordered[i];
|
|
91
|
+
runEnd = ordered[i];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
flush();
|
|
95
|
+
return locale.joinDays(segments);
|
|
96
|
+
}
|
|
97
|
+
function dayKey(days) {
|
|
98
|
+
return DAY_ORDER.filter((d) => days.includes(d)).join(",");
|
|
99
|
+
}
|
|
100
|
+
function normalizedDays(days) {
|
|
101
|
+
return DAY_ORDER.filter((d) => days.includes(d));
|
|
102
|
+
}
|
|
103
|
+
function formatOpeningHours(openingHours, options = {}) {
|
|
104
|
+
const locale = LOCALES[options.locale ?? "es"];
|
|
105
|
+
const note = openingHours?.note?.trim();
|
|
106
|
+
const ranges = (openingHours?.ranges ?? []).map((range) => ({ ...range, days: normalizedDays(range.days) })).filter((range) => range.days.length > 0);
|
|
107
|
+
if (ranges.length === 0) return note || null;
|
|
108
|
+
const groups = /* @__PURE__ */ new Map();
|
|
109
|
+
for (const range of ranges) {
|
|
110
|
+
const key = dayKey(range.days);
|
|
111
|
+
const group = groups.get(key) ?? { days: range.days, windows: [] };
|
|
112
|
+
group.windows.push(locale.timeWindow(range.opens, range.closes));
|
|
113
|
+
groups.set(key, group);
|
|
114
|
+
}
|
|
115
|
+
const earliestDayIndex = (days) => Math.min(...days.map((d) => DAY_ORDER.indexOf(d)));
|
|
116
|
+
const sentences = [...groups.values()].sort((a, b) => earliestDayIndex(a.days) - earliestDayIndex(b.days)).map((group) => {
|
|
117
|
+
const daysPhrase = formatDaysPhrase(group.days, locale);
|
|
118
|
+
const windows = locale.joinWindows(group.windows);
|
|
119
|
+
return `${locale.sentence(daysPhrase, windows)}.`;
|
|
120
|
+
});
|
|
121
|
+
return note ? [...sentences, note].join("\n") : sentences.join("\n");
|
|
122
|
+
}
|
|
123
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
124
|
+
0 && (module.exports = {
|
|
125
|
+
formatOpeningHours
|
|
126
|
+
});
|
|
127
|
+
//# sourceMappingURL=opening-hours.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/opening-hours.ts"],"sourcesContent":["/**\n * `@reblu/site-next/opening-hours` — pure, RSC-safe formatter for the typed\n * `Config.openingHours` value (REB-826).\n *\n * Own tsup entry (zero dependencies of its own — no React, no `next`), the\n * same isolation reason as `./recaptcha`/`./route-handlers`: it is pure\n * string logic, so it is callable from a Server Component, a Client\n * Component, or a nexus preview with no RSC-boundary cost either way, and\n * importing it must never drag in `CtaButtons`/`EditableCta`'s client graph.\n *\n * `formatOpeningHours` turns the typed, machine-shaped value into one\n * locale-formatted sentence per line:\n *\n * formatOpeningHours({\n * ranges: [{ days: ['MON','TUE','WED','THU','FRI'], opens: '08:00', closes: '20:00' }],\n * })\n * // → \"De lunes a viernes de 08:00 a 20:00.\"\n *\n * For `OpeningHoursSpecification` (schema.org JSON-LD), see\n * `toOpeningHoursSpecification` in `@reblu/site-next/json-ld` instead — this\n * module is display prose, that one is machine-readable structured data; they\n * deliberately do not share formatting logic.\n */\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nexport type OpeningHoursLocale = 'es' | 'en'\n\ninterface LocaleStrings {\n /** Monday-first day names, matching `OPENING_HOURS_DAY_ORDER`. */\n dayNames: Record<ConfigOpeningHoursDay, string>\n /** `(from, to) => \"de lunes a viernes\"` */\n dayRange: (from: string, to: string) => string\n /** Joins day-name/day-range segments: \"lunes, miércoles y viernes\". */\n joinDays: (segments: string[]) => string\n /** `(days, opens, closes) => \"De lunes a viernes de 08:00 a 20:00\"` */\n sentence: (daysPhrase: string, windows: string) => string\n /** `(opens, closes) => \"de 08:00 a 20:00\"` */\n timeWindow: (opens: string, closes: string) => string\n /** Joins multiple time windows for the same days: \"y de 16:00 a 20:00\". */\n joinWindows: (windows: string[]) => string\n}\n\nconst LOCALES: Record<OpeningHoursLocale, LocaleStrings> = {\n es: {\n dayNames: {\n MON: 'lunes',\n TUE: 'martes',\n WED: 'miércoles',\n THU: 'jueves',\n FRI: 'viernes',\n SAT: 'sábado',\n SUN: 'domingo',\n },\n dayRange: (from, to) => `de ${from} a ${to}`,\n joinDays: (segments) => joinWithAnd(segments, 'y'),\n sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),\n timeWindow: (opens, closes) => `de ${opens} a ${closes}`,\n joinWindows: (windows) => joinWithAnd(windows, 'y'),\n },\n en: {\n dayNames: {\n MON: 'Monday',\n TUE: 'Tuesday',\n WED: 'Wednesday',\n THU: 'Thursday',\n FRI: 'Friday',\n SAT: 'Saturday',\n SUN: 'Sunday',\n },\n dayRange: (from, to) => `${from} to ${to}`,\n joinDays: (segments) => joinWithAnd(segments, 'and'),\n sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),\n timeWindow: (opens, closes) => `from ${opens} to ${closes}`,\n joinWindows: (windows) => joinWithAnd(windows, 'and'),\n },\n}\n\n/** Monday-first, matching every `LocaleStrings.dayNames` table above. */\nconst DAY_ORDER: ConfigOpeningHoursDay[] = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']\n\nfunction joinWithAnd(items: string[], conjunction: string): string {\n if (items.length === 0) return ''\n if (items.length === 1) return items[0]\n if (items.length === 2) return `${items[0]} ${conjunction} ${items[1]}`\n return `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`\n}\n\nfunction capitalize(text: string): string {\n if (text.length === 0) return text\n return text[0].toUpperCase() + text.slice(1)\n}\n\n/**\n * Groups a day set into contiguous runs (Monday-first) and renders each run\n * as either a single day name or a \"de X a Y\" range, then joins the\n * segments. `['MON','TUE','WED','THU','FRI']` → \"de lunes a viernes\";\n * `['MON','WED','FRI']` → \"lunes, miércoles y viernes\" (no false contiguity).\n */\nfunction formatDaysPhrase(days: ConfigOpeningHoursDay[], locale: LocaleStrings): string {\n const present = new Set(days)\n const ordered = DAY_ORDER.filter((d) => present.has(d))\n if (ordered.length === 0) return ''\n\n const segments: string[] = []\n let runStart = ordered[0]\n let runEnd = ordered[0]\n\n const flush = () => {\n segments.push(\n runStart === runEnd\n ? locale.dayNames[runStart]\n : locale.dayRange(locale.dayNames[runStart], locale.dayNames[runEnd]),\n )\n }\n\n for (let i = 1; i < ordered.length; i += 1) {\n const prevIndex = DAY_ORDER.indexOf(runEnd)\n const isConsecutive = DAY_ORDER[prevIndex + 1] === ordered[i]\n if (isConsecutive) {\n runEnd = ordered[i]\n } else {\n flush()\n runStart = ordered[i]\n runEnd = ordered[i]\n }\n }\n flush()\n\n return locale.joinDays(segments)\n}\n\n/** The days-key used to GROUP ranges that share the exact same day set. */\nfunction dayKey(days: ConfigOpeningHoursDay[]): string {\n return DAY_ORDER.filter((d) => days.includes(d)).join(',')\n}\n\n/**\n * Keeps only recognised day codes, in canonical Monday-first order. The\n * `Config.openingHours` type is a compile-time guarantee only — a row\n * written by an older schema version, hand-edited, or otherwise corrupt can\n * hand this function a `days` array containing a code outside the known\n * `ConfigOpeningHoursDay` union. Filtering (rather than trusting the type)\n * here, once, before grouping, is what stops an unrecognised code from\n * reaching `earliestDayIndex` — where `DAY_ORDER.indexOf` would silently\n * return -1 and corrupt the sentence sort order.\n */\nfunction normalizedDays(days: ConfigOpeningHoursDay[]): ConfigOpeningHoursDay[] {\n return DAY_ORDER.filter((d) => days.includes(d))\n}\n\nexport interface FormatOpeningHoursOptions {\n /** @default 'es' */\n locale?: OpeningHoursLocale\n}\n\n/**\n * Formats a typed `Config.openingHours` value into human-readable prose,\n * Spanish by default. Ranges sharing the exact same `days` set are grouped\n * into one sentence with multiple time windows joined by \"y\"/\"and\" (a split\n * schedule, e.g. a lunch closure). Groups with different day sets become\n * separate sentences, joined by a newline. The optional `note` is appended as\n * its own trailing line, TRIMMED of leading/trailing whitespace but otherwise\n * unchanged (it is free text the owner wrote).\n *\n * A range whose `days` is empty, or whose codes are all unrecognised once\n * normalised (see `normalizedDays`), is dropped entirely rather than\n * producing a sentence with no day phrase in front of the time window (a\n * leading \" de 08:00 a 20:00.\" — the defect this filtering exists to\n * prevent). The API only guarantees the `ConfigOpeningHours` shape at\n * compile time, not at runtime.\n *\n * Returns `null` when there is nothing to show (no ranges and no note) —\n * never an empty string a caller would have to additionally guard against.\n */\nexport function formatOpeningHours(\n openingHours: ConfigOpeningHours | null | undefined,\n options: FormatOpeningHoursOptions = {},\n): string | null {\n const locale = LOCALES[options.locale ?? 'es']\n const note = openingHours?.note?.trim()\n const ranges = (openingHours?.ranges ?? [])\n .map((range) => ({ ...range, days: normalizedDays(range.days) }))\n .filter((range) => range.days.length > 0)\n\n if (ranges.length === 0) return note || null\n\n const groups = new Map<string, { days: ConfigOpeningHoursDay[]; windows: string[] }>()\n for (const range of ranges) {\n const key = dayKey(range.days)\n const group = groups.get(key) ?? { days: range.days, windows: [] }\n group.windows.push(locale.timeWindow(range.opens, range.closes))\n groups.set(key, group)\n }\n\n const earliestDayIndex = (days: ConfigOpeningHoursDay[]) =>\n Math.min(...days.map((d) => DAY_ORDER.indexOf(d)))\n\n const sentences = [...groups.values()]\n // Stable, deterministic order: by each group's earliest day in the week.\n .sort((a, b) => earliestDayIndex(a.days) - earliestDayIndex(b.days))\n .map((group) => {\n const daysPhrase = formatDaysPhrase(group.days, locale)\n const windows = locale.joinWindows(group.windows)\n return `${locale.sentence(daysPhrase, windows)}.`\n })\n\n return note ? [...sentences, note].join('\\n') : sentences.join('\\n')\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ConfigOpeningHours } from '@reblu/site-contracts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `@reblu/site-next/opening-hours` — pure, RSC-safe formatter for the typed
|
|
5
|
+
* `Config.openingHours` value (REB-826).
|
|
6
|
+
*
|
|
7
|
+
* Own tsup entry (zero dependencies of its own — no React, no `next`), the
|
|
8
|
+
* same isolation reason as `./recaptcha`/`./route-handlers`: it is pure
|
|
9
|
+
* string logic, so it is callable from a Server Component, a Client
|
|
10
|
+
* Component, or a nexus preview with no RSC-boundary cost either way, and
|
|
11
|
+
* importing it must never drag in `CtaButtons`/`EditableCta`'s client graph.
|
|
12
|
+
*
|
|
13
|
+
* `formatOpeningHours` turns the typed, machine-shaped value into one
|
|
14
|
+
* locale-formatted sentence per line:
|
|
15
|
+
*
|
|
16
|
+
* formatOpeningHours({
|
|
17
|
+
* ranges: [{ days: ['MON','TUE','WED','THU','FRI'], opens: '08:00', closes: '20:00' }],
|
|
18
|
+
* })
|
|
19
|
+
* // → "De lunes a viernes de 08:00 a 20:00."
|
|
20
|
+
*
|
|
21
|
+
* For `OpeningHoursSpecification` (schema.org JSON-LD), see
|
|
22
|
+
* `toOpeningHoursSpecification` in `@reblu/site-next/json-ld` instead — this
|
|
23
|
+
* module is display prose, that one is machine-readable structured data; they
|
|
24
|
+
* deliberately do not share formatting logic.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
type OpeningHoursLocale = 'es' | 'en';
|
|
28
|
+
interface FormatOpeningHoursOptions {
|
|
29
|
+
/** @default 'es' */
|
|
30
|
+
locale?: OpeningHoursLocale;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Formats a typed `Config.openingHours` value into human-readable prose,
|
|
34
|
+
* Spanish by default. Ranges sharing the exact same `days` set are grouped
|
|
35
|
+
* into one sentence with multiple time windows joined by "y"/"and" (a split
|
|
36
|
+
* schedule, e.g. a lunch closure). Groups with different day sets become
|
|
37
|
+
* separate sentences, joined by a newline. The optional `note` is appended as
|
|
38
|
+
* its own trailing line, TRIMMED of leading/trailing whitespace but otherwise
|
|
39
|
+
* unchanged (it is free text the owner wrote).
|
|
40
|
+
*
|
|
41
|
+
* A range whose `days` is empty, or whose codes are all unrecognised once
|
|
42
|
+
* normalised (see `normalizedDays`), is dropped entirely rather than
|
|
43
|
+
* producing a sentence with no day phrase in front of the time window (a
|
|
44
|
+
* leading " de 08:00 a 20:00." — the defect this filtering exists to
|
|
45
|
+
* prevent). The API only guarantees the `ConfigOpeningHours` shape at
|
|
46
|
+
* compile time, not at runtime.
|
|
47
|
+
*
|
|
48
|
+
* Returns `null` when there is nothing to show (no ranges and no note) —
|
|
49
|
+
* never an empty string a caller would have to additionally guard against.
|
|
50
|
+
*/
|
|
51
|
+
declare function formatOpeningHours(openingHours: ConfigOpeningHours | null | undefined, options?: FormatOpeningHoursOptions): string | null;
|
|
52
|
+
|
|
53
|
+
export { type FormatOpeningHoursOptions, type OpeningHoursLocale, formatOpeningHours };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ConfigOpeningHours } from '@reblu/site-contracts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `@reblu/site-next/opening-hours` — pure, RSC-safe formatter for the typed
|
|
5
|
+
* `Config.openingHours` value (REB-826).
|
|
6
|
+
*
|
|
7
|
+
* Own tsup entry (zero dependencies of its own — no React, no `next`), the
|
|
8
|
+
* same isolation reason as `./recaptcha`/`./route-handlers`: it is pure
|
|
9
|
+
* string logic, so it is callable from a Server Component, a Client
|
|
10
|
+
* Component, or a nexus preview with no RSC-boundary cost either way, and
|
|
11
|
+
* importing it must never drag in `CtaButtons`/`EditableCta`'s client graph.
|
|
12
|
+
*
|
|
13
|
+
* `formatOpeningHours` turns the typed, machine-shaped value into one
|
|
14
|
+
* locale-formatted sentence per line:
|
|
15
|
+
*
|
|
16
|
+
* formatOpeningHours({
|
|
17
|
+
* ranges: [{ days: ['MON','TUE','WED','THU','FRI'], opens: '08:00', closes: '20:00' }],
|
|
18
|
+
* })
|
|
19
|
+
* // → "De lunes a viernes de 08:00 a 20:00."
|
|
20
|
+
*
|
|
21
|
+
* For `OpeningHoursSpecification` (schema.org JSON-LD), see
|
|
22
|
+
* `toOpeningHoursSpecification` in `@reblu/site-next/json-ld` instead — this
|
|
23
|
+
* module is display prose, that one is machine-readable structured data; they
|
|
24
|
+
* deliberately do not share formatting logic.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
type OpeningHoursLocale = 'es' | 'en';
|
|
28
|
+
interface FormatOpeningHoursOptions {
|
|
29
|
+
/** @default 'es' */
|
|
30
|
+
locale?: OpeningHoursLocale;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Formats a typed `Config.openingHours` value into human-readable prose,
|
|
34
|
+
* Spanish by default. Ranges sharing the exact same `days` set are grouped
|
|
35
|
+
* into one sentence with multiple time windows joined by "y"/"and" (a split
|
|
36
|
+
* schedule, e.g. a lunch closure). Groups with different day sets become
|
|
37
|
+
* separate sentences, joined by a newline. The optional `note` is appended as
|
|
38
|
+
* its own trailing line, TRIMMED of leading/trailing whitespace but otherwise
|
|
39
|
+
* unchanged (it is free text the owner wrote).
|
|
40
|
+
*
|
|
41
|
+
* A range whose `days` is empty, or whose codes are all unrecognised once
|
|
42
|
+
* normalised (see `normalizedDays`), is dropped entirely rather than
|
|
43
|
+
* producing a sentence with no day phrase in front of the time window (a
|
|
44
|
+
* leading " de 08:00 a 20:00." — the defect this filtering exists to
|
|
45
|
+
* prevent). The API only guarantees the `ConfigOpeningHours` shape at
|
|
46
|
+
* compile time, not at runtime.
|
|
47
|
+
*
|
|
48
|
+
* Returns `null` when there is nothing to show (no ranges and no note) —
|
|
49
|
+
* never an empty string a caller would have to additionally guard against.
|
|
50
|
+
*/
|
|
51
|
+
declare function formatOpeningHours(openingHours: ConfigOpeningHours | null | undefined, options?: FormatOpeningHoursOptions): string | null;
|
|
52
|
+
|
|
53
|
+
export { type FormatOpeningHoursOptions, type OpeningHoursLocale, formatOpeningHours };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/opening-hours.ts
|
|
2
|
+
var LOCALES = {
|
|
3
|
+
es: {
|
|
4
|
+
dayNames: {
|
|
5
|
+
MON: "lunes",
|
|
6
|
+
TUE: "martes",
|
|
7
|
+
WED: "mi\xE9rcoles",
|
|
8
|
+
THU: "jueves",
|
|
9
|
+
FRI: "viernes",
|
|
10
|
+
SAT: "s\xE1bado",
|
|
11
|
+
SUN: "domingo"
|
|
12
|
+
},
|
|
13
|
+
dayRange: (from, to) => `de ${from} a ${to}`,
|
|
14
|
+
joinDays: (segments) => joinWithAnd(segments, "y"),
|
|
15
|
+
sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),
|
|
16
|
+
timeWindow: (opens, closes) => `de ${opens} a ${closes}`,
|
|
17
|
+
joinWindows: (windows) => joinWithAnd(windows, "y")
|
|
18
|
+
},
|
|
19
|
+
en: {
|
|
20
|
+
dayNames: {
|
|
21
|
+
MON: "Monday",
|
|
22
|
+
TUE: "Tuesday",
|
|
23
|
+
WED: "Wednesday",
|
|
24
|
+
THU: "Thursday",
|
|
25
|
+
FRI: "Friday",
|
|
26
|
+
SAT: "Saturday",
|
|
27
|
+
SUN: "Sunday"
|
|
28
|
+
},
|
|
29
|
+
dayRange: (from, to) => `${from} to ${to}`,
|
|
30
|
+
joinDays: (segments) => joinWithAnd(segments, "and"),
|
|
31
|
+
sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),
|
|
32
|
+
timeWindow: (opens, closes) => `from ${opens} to ${closes}`,
|
|
33
|
+
joinWindows: (windows) => joinWithAnd(windows, "and")
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var DAY_ORDER = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
|
|
37
|
+
function joinWithAnd(items, conjunction) {
|
|
38
|
+
if (items.length === 0) return "";
|
|
39
|
+
if (items.length === 1) return items[0];
|
|
40
|
+
if (items.length === 2) return `${items[0]} ${conjunction} ${items[1]}`;
|
|
41
|
+
return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
|
|
42
|
+
}
|
|
43
|
+
function capitalize(text) {
|
|
44
|
+
if (text.length === 0) return text;
|
|
45
|
+
return text[0].toUpperCase() + text.slice(1);
|
|
46
|
+
}
|
|
47
|
+
function formatDaysPhrase(days, locale) {
|
|
48
|
+
const present = new Set(days);
|
|
49
|
+
const ordered = DAY_ORDER.filter((d) => present.has(d));
|
|
50
|
+
if (ordered.length === 0) return "";
|
|
51
|
+
const segments = [];
|
|
52
|
+
let runStart = ordered[0];
|
|
53
|
+
let runEnd = ordered[0];
|
|
54
|
+
const flush = () => {
|
|
55
|
+
segments.push(
|
|
56
|
+
runStart === runEnd ? locale.dayNames[runStart] : locale.dayRange(locale.dayNames[runStart], locale.dayNames[runEnd])
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
for (let i = 1; i < ordered.length; i += 1) {
|
|
60
|
+
const prevIndex = DAY_ORDER.indexOf(runEnd);
|
|
61
|
+
const isConsecutive = DAY_ORDER[prevIndex + 1] === ordered[i];
|
|
62
|
+
if (isConsecutive) {
|
|
63
|
+
runEnd = ordered[i];
|
|
64
|
+
} else {
|
|
65
|
+
flush();
|
|
66
|
+
runStart = ordered[i];
|
|
67
|
+
runEnd = ordered[i];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
flush();
|
|
71
|
+
return locale.joinDays(segments);
|
|
72
|
+
}
|
|
73
|
+
function dayKey(days) {
|
|
74
|
+
return DAY_ORDER.filter((d) => days.includes(d)).join(",");
|
|
75
|
+
}
|
|
76
|
+
function normalizedDays(days) {
|
|
77
|
+
return DAY_ORDER.filter((d) => days.includes(d));
|
|
78
|
+
}
|
|
79
|
+
function formatOpeningHours(openingHours, options = {}) {
|
|
80
|
+
const locale = LOCALES[options.locale ?? "es"];
|
|
81
|
+
const note = openingHours?.note?.trim();
|
|
82
|
+
const ranges = (openingHours?.ranges ?? []).map((range) => ({ ...range, days: normalizedDays(range.days) })).filter((range) => range.days.length > 0);
|
|
83
|
+
if (ranges.length === 0) return note || null;
|
|
84
|
+
const groups = /* @__PURE__ */ new Map();
|
|
85
|
+
for (const range of ranges) {
|
|
86
|
+
const key = dayKey(range.days);
|
|
87
|
+
const group = groups.get(key) ?? { days: range.days, windows: [] };
|
|
88
|
+
group.windows.push(locale.timeWindow(range.opens, range.closes));
|
|
89
|
+
groups.set(key, group);
|
|
90
|
+
}
|
|
91
|
+
const earliestDayIndex = (days) => Math.min(...days.map((d) => DAY_ORDER.indexOf(d)));
|
|
92
|
+
const sentences = [...groups.values()].sort((a, b) => earliestDayIndex(a.days) - earliestDayIndex(b.days)).map((group) => {
|
|
93
|
+
const daysPhrase = formatDaysPhrase(group.days, locale);
|
|
94
|
+
const windows = locale.joinWindows(group.windows);
|
|
95
|
+
return `${locale.sentence(daysPhrase, windows)}.`;
|
|
96
|
+
});
|
|
97
|
+
return note ? [...sentences, note].join("\n") : sentences.join("\n");
|
|
98
|
+
}
|
|
99
|
+
export {
|
|
100
|
+
formatOpeningHours
|
|
101
|
+
};
|
|
102
|
+
//# sourceMappingURL=opening-hours.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/opening-hours.ts"],"sourcesContent":["/**\n * `@reblu/site-next/opening-hours` — pure, RSC-safe formatter for the typed\n * `Config.openingHours` value (REB-826).\n *\n * Own tsup entry (zero dependencies of its own — no React, no `next`), the\n * same isolation reason as `./recaptcha`/`./route-handlers`: it is pure\n * string logic, so it is callable from a Server Component, a Client\n * Component, or a nexus preview with no RSC-boundary cost either way, and\n * importing it must never drag in `CtaButtons`/`EditableCta`'s client graph.\n *\n * `formatOpeningHours` turns the typed, machine-shaped value into one\n * locale-formatted sentence per line:\n *\n * formatOpeningHours({\n * ranges: [{ days: ['MON','TUE','WED','THU','FRI'], opens: '08:00', closes: '20:00' }],\n * })\n * // → \"De lunes a viernes de 08:00 a 20:00.\"\n *\n * For `OpeningHoursSpecification` (schema.org JSON-LD), see\n * `toOpeningHoursSpecification` in `@reblu/site-next/json-ld` instead — this\n * module is display prose, that one is machine-readable structured data; they\n * deliberately do not share formatting logic.\n */\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nexport type OpeningHoursLocale = 'es' | 'en'\n\ninterface LocaleStrings {\n /** Monday-first day names, matching `OPENING_HOURS_DAY_ORDER`. */\n dayNames: Record<ConfigOpeningHoursDay, string>\n /** `(from, to) => \"de lunes a viernes\"` */\n dayRange: (from: string, to: string) => string\n /** Joins day-name/day-range segments: \"lunes, miércoles y viernes\". */\n joinDays: (segments: string[]) => string\n /** `(days, opens, closes) => \"De lunes a viernes de 08:00 a 20:00\"` */\n sentence: (daysPhrase: string, windows: string) => string\n /** `(opens, closes) => \"de 08:00 a 20:00\"` */\n timeWindow: (opens: string, closes: string) => string\n /** Joins multiple time windows for the same days: \"y de 16:00 a 20:00\". */\n joinWindows: (windows: string[]) => string\n}\n\nconst LOCALES: Record<OpeningHoursLocale, LocaleStrings> = {\n es: {\n dayNames: {\n MON: 'lunes',\n TUE: 'martes',\n WED: 'miércoles',\n THU: 'jueves',\n FRI: 'viernes',\n SAT: 'sábado',\n SUN: 'domingo',\n },\n dayRange: (from, to) => `de ${from} a ${to}`,\n joinDays: (segments) => joinWithAnd(segments, 'y'),\n sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),\n timeWindow: (opens, closes) => `de ${opens} a ${closes}`,\n joinWindows: (windows) => joinWithAnd(windows, 'y'),\n },\n en: {\n dayNames: {\n MON: 'Monday',\n TUE: 'Tuesday',\n WED: 'Wednesday',\n THU: 'Thursday',\n FRI: 'Friday',\n SAT: 'Saturday',\n SUN: 'Sunday',\n },\n dayRange: (from, to) => `${from} to ${to}`,\n joinDays: (segments) => joinWithAnd(segments, 'and'),\n sentence: (daysPhrase, windows) => capitalize(`${daysPhrase} ${windows}`),\n timeWindow: (opens, closes) => `from ${opens} to ${closes}`,\n joinWindows: (windows) => joinWithAnd(windows, 'and'),\n },\n}\n\n/** Monday-first, matching every `LocaleStrings.dayNames` table above. */\nconst DAY_ORDER: ConfigOpeningHoursDay[] = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']\n\nfunction joinWithAnd(items: string[], conjunction: string): string {\n if (items.length === 0) return ''\n if (items.length === 1) return items[0]\n if (items.length === 2) return `${items[0]} ${conjunction} ${items[1]}`\n return `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`\n}\n\nfunction capitalize(text: string): string {\n if (text.length === 0) return text\n return text[0].toUpperCase() + text.slice(1)\n}\n\n/**\n * Groups a day set into contiguous runs (Monday-first) and renders each run\n * as either a single day name or a \"de X a Y\" range, then joins the\n * segments. `['MON','TUE','WED','THU','FRI']` → \"de lunes a viernes\";\n * `['MON','WED','FRI']` → \"lunes, miércoles y viernes\" (no false contiguity).\n */\nfunction formatDaysPhrase(days: ConfigOpeningHoursDay[], locale: LocaleStrings): string {\n const present = new Set(days)\n const ordered = DAY_ORDER.filter((d) => present.has(d))\n if (ordered.length === 0) return ''\n\n const segments: string[] = []\n let runStart = ordered[0]\n let runEnd = ordered[0]\n\n const flush = () => {\n segments.push(\n runStart === runEnd\n ? locale.dayNames[runStart]\n : locale.dayRange(locale.dayNames[runStart], locale.dayNames[runEnd]),\n )\n }\n\n for (let i = 1; i < ordered.length; i += 1) {\n const prevIndex = DAY_ORDER.indexOf(runEnd)\n const isConsecutive = DAY_ORDER[prevIndex + 1] === ordered[i]\n if (isConsecutive) {\n runEnd = ordered[i]\n } else {\n flush()\n runStart = ordered[i]\n runEnd = ordered[i]\n }\n }\n flush()\n\n return locale.joinDays(segments)\n}\n\n/** The days-key used to GROUP ranges that share the exact same day set. */\nfunction dayKey(days: ConfigOpeningHoursDay[]): string {\n return DAY_ORDER.filter((d) => days.includes(d)).join(',')\n}\n\n/**\n * Keeps only recognised day codes, in canonical Monday-first order. The\n * `Config.openingHours` type is a compile-time guarantee only — a row\n * written by an older schema version, hand-edited, or otherwise corrupt can\n * hand this function a `days` array containing a code outside the known\n * `ConfigOpeningHoursDay` union. Filtering (rather than trusting the type)\n * here, once, before grouping, is what stops an unrecognised code from\n * reaching `earliestDayIndex` — where `DAY_ORDER.indexOf` would silently\n * return -1 and corrupt the sentence sort order.\n */\nfunction normalizedDays(days: ConfigOpeningHoursDay[]): ConfigOpeningHoursDay[] {\n return DAY_ORDER.filter((d) => days.includes(d))\n}\n\nexport interface FormatOpeningHoursOptions {\n /** @default 'es' */\n locale?: OpeningHoursLocale\n}\n\n/**\n * Formats a typed `Config.openingHours` value into human-readable prose,\n * Spanish by default. Ranges sharing the exact same `days` set are grouped\n * into one sentence with multiple time windows joined by \"y\"/\"and\" (a split\n * schedule, e.g. a lunch closure). Groups with different day sets become\n * separate sentences, joined by a newline. The optional `note` is appended as\n * its own trailing line, TRIMMED of leading/trailing whitespace but otherwise\n * unchanged (it is free text the owner wrote).\n *\n * A range whose `days` is empty, or whose codes are all unrecognised once\n * normalised (see `normalizedDays`), is dropped entirely rather than\n * producing a sentence with no day phrase in front of the time window (a\n * leading \" de 08:00 a 20:00.\" — the defect this filtering exists to\n * prevent). The API only guarantees the `ConfigOpeningHours` shape at\n * compile time, not at runtime.\n *\n * Returns `null` when there is nothing to show (no ranges and no note) —\n * never an empty string a caller would have to additionally guard against.\n */\nexport function formatOpeningHours(\n openingHours: ConfigOpeningHours | null | undefined,\n options: FormatOpeningHoursOptions = {},\n): string | null {\n const locale = LOCALES[options.locale ?? 'es']\n const note = openingHours?.note?.trim()\n const ranges = (openingHours?.ranges ?? [])\n .map((range) => ({ ...range, days: normalizedDays(range.days) }))\n .filter((range) => range.days.length > 0)\n\n if (ranges.length === 0) return note || null\n\n const groups = new Map<string, { days: ConfigOpeningHoursDay[]; windows: string[] }>()\n for (const range of ranges) {\n const key = dayKey(range.days)\n const group = groups.get(key) ?? { days: range.days, windows: [] }\n group.windows.push(locale.timeWindow(range.opens, range.closes))\n groups.set(key, group)\n }\n\n const earliestDayIndex = (days: ConfigOpeningHoursDay[]) =>\n Math.min(...days.map((d) => DAY_ORDER.indexOf(d)))\n\n const sentences = [...groups.values()]\n // Stable, deterministic order: by each group's earliest day in the week.\n .sort((a, b) => earliestDayIndex(a.days) - earliestDayIndex(b.days))\n .map((group) => {\n const daysPhrase = formatDaysPhrase(group.days, locale)\n const windows = locale.joinWindows(group.windows)\n return `${locale.sentence(daysPhrase, windows)}.`\n })\n\n return note ? [...sentences, note].join('\\n') : sentences.join('\\n')\n}\n"],"mappings":"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","names":[]}
|