@reblu/site-next 1.12.0 → 1.13.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 +34 -0
- package/dist/analytics.cjs.map +1 -1
- package/dist/analytics.js.map +1 -1
- 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-server.cjs +5 -4
- package/dist/shop-server.cjs.map +1 -1
- package/dist/shop-server.js +5 -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.js +5 -4
- package/dist/shop.js.map +1 -1
- package/package.json +13 -3
|
@@ -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":[]}
|
package/dist/shop-server.cjs
CHANGED
|
@@ -53,7 +53,8 @@ function parseBracketParams(searchParams, prefix) {
|
|
|
53
53
|
const match = rawKey.match(re);
|
|
54
54
|
if (!match) continue;
|
|
55
55
|
const key = match[1];
|
|
56
|
-
const value = rawValue.slice(0, MAX_VALUE_LEN);
|
|
56
|
+
const value = rawValue.trim().slice(0, MAX_VALUE_LEN);
|
|
57
|
+
if (!value) continue;
|
|
57
58
|
if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue;
|
|
58
59
|
const existing = out[key];
|
|
59
60
|
if (existing === void 0) out[key] = value;
|
|
@@ -109,14 +110,14 @@ function serializeShopParams(params) {
|
|
|
109
110
|
if (params.onSale) sp.set("onSale", "true");
|
|
110
111
|
if (params.taxonomy) {
|
|
111
112
|
for (const [key, value] of Object.entries(params.taxonomy)) {
|
|
112
|
-
if (value) sp.set(`taxonomy[${key}]`, value);
|
|
113
|
+
if (typeof value === "string" && value.trim()) sp.set(`taxonomy[${key}]`, value);
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
if (params.attr) {
|
|
116
117
|
for (const [key, value] of Object.entries(params.attr)) {
|
|
117
118
|
if (Array.isArray(value)) {
|
|
118
|
-
for (const v of value) if (v) sp.append(`attr[${key}]`, v);
|
|
119
|
-
} else if (value) {
|
|
119
|
+
for (const v of value) if (typeof v === "string" && v.trim()) sp.append(`attr[${key}]`, v);
|
|
120
|
+
} else if (typeof value === "string" && value.trim()) {
|
|
120
121
|
sp.set(`attr[${key}]`, value);
|
|
121
122
|
}
|
|
122
123
|
}
|
package/dist/shop-server.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/shop/server.ts","../src/shop/params.ts","../src/shop/collection.ts"],"sourcesContent":["/**\n * `@reblu/site-next/shop-server` (REB-809) — the server-safe half of the shop\n * filters glue. Every export here has ZERO React and must NEVER carry\n * `'use client'` — same isolation direction as `./route-handlers` and\n * `./academy-server` (see `tsup.config.ts`). A Server Component (a route's\n * `page.tsx`) calls these directly: parse its own `searchParams` prop,\n * fetch the collection, build hrefs, and render\n * `@reblu/site-blocks`'s `ShopFilters` with NO `onChange` and NO client\n * boundary at all — the exact \"importable from a Server Component\" shape\n * REB-647/REB-809 require. `./shop` re-exports the same pure helpers\n * alongside the `'use client'` navigation hook for a page that wants the\n * client-reactive version instead.\n */\nexport {\n parseShopSearchParams,\n serializeShopParams,\n toURLSearchParams,\n buildShopFilterHrefs,\n type ShopFilterHrefs,\n} from './params'\nexport { getShopCollection, type ShopCollection } from './collection'\n","import type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * REB-809 — URL ↔ `ShopProductsParams` glue for a SITE's shop/collection page.\n *\n * Mirrors base-api's own `parseListParams`\n * (`apps/base-api/src/lib/shop/catalogue-query.ts`) deliberately: the SAME\n * bracket shape (`taxonomy[<key>]=<slug>`, `attr[<key>]=<value>`, repeatable\n * for `attr`), the SAME MVP collapse for `taxonomy` (one term slug per axis —\n * a repeat keeps only the first), and the SAME defensive caps on bracket\n * key/value counts. Keeping this a mirror rather than a re-implementation\n * means a URL this module builds is EXACTLY the URL the server will parse\n * back to the same params — there is no second, drifting parser.\n */\n\nconst MAX_Q_LEN = 200\nconst MAX_FILTER_KEYS = 30\nconst MAX_VALUE_LEN = 120\nconst MAX_VALUES_PER_KEY = 30\n/**\n * Mirrors `shopProductListParamsSchema`'s `limit: z.number().int().min(1).max(50)`\n * in `apps/base-api/src/lib/contracts/shop.contract.ts` — keep the two in sync;\n * a caller-side limit above this is server-rejected/clamped anyway, but parsing\n * it through here means a URL this module builds never claims a limit the\n * server will refuse.\n */\nconst MAX_LIMIT = 50\n\nconst SHOP_SORTS = ['price_asc', 'price_desc', 'newest', 'on_sale'] as const\n\nfunction isShopSort(value: string): value is (typeof SHOP_SORTS)[number] {\n return (SHOP_SORTS as readonly string[]).includes(value)\n}\n\n/**\n * Parse a non-negative integer, STRICT digits only — `/^\\d+$/`, so '12abc'\n * and '1.5' are rejected outright rather than `Number.parseInt` silently\n * taking the leading '12'/'1' and discarding the rest as if it were valid\n * input. Named for exactly what it accepts: >= 0, not \"positive\" (which\n * excludes 0) — callers that need >= 1 (page, limit) enforce that bound\n * themselves on top of this.\n */\nfunction nonNegativeInt(raw: string | null): number | undefined {\n if (raw === null || !/^\\d+$/.test(raw)) return undefined\n const n = Number.parseInt(raw, 10)\n return Number.isNaN(n) ? undefined : n\n}\n\n/**\n * Copy a (possibly null-prototype) object's own enumerable properties into a\n * fresh PLAIN object — `Object.prototype` in its chain, nothing else.\n *\n * R3-001: this module's internal accumulators use `Object.create(null)` for\n * prototype-pollution safety (see `parseBracketParams`' own doc below), but\n * that safety property must NEVER leak into `ShopProductsParams.taxonomy`/\n * `.attr`, the values a SITE actually receives. React Flight — the\n * serialization React Server Components use to send props across the\n * Server→Client boundary — refuses a non-plain object (`Object.\n * getPrototypeOf(x) !== Object.prototype`) with a hard runtime error. This\n * package's own README tells a Client Component caller to pass\n * `active={params}` to `ShopFilters`' `onChange` mode, so a null-prototype\n * `taxonomy`/`attr` reaching that boundary would crash every SITE that\n * followed the documented pattern. Object spread (`{ ...source }`) always\n * produces a plain object regardless of the source's own prototype, which is\n * exactly the property this needs and `Object.assign({}, source)` also has —\n * spread is used here for clarity.\n */\nfunction toPlainObject<T>(source: Record<string, T>): Record<string, T> {\n return { ...source }\n}\n\n/**\n * Parse `bracket[key]` params into a map, collapsing repeats into arrays.\n *\n * Built on `Object.create(null)` rather than a plain `{}` — a query string is\n * attacker-controlled input, and `taxonomy[constructor]=x` or\n * `taxonomy[toString]=x` against a plain object reads/writes through the\n * INHERITED `Object.prototype` property of that name before this function's\n * own `key in out` / `out[key]` logic ever sees an actual own property,\n * corrupting the key-count bookkeeping and the parsed value for that key. A\n * null-prototype object has no inherited properties at all, so `in` and\n * indexed access only ever see what this loop itself has written — the same\n * defense `JSON.parse` reviewers reach for against prototype-pollution-style\n * bracket keys, applied here to a hand-rolled key/value accumulator.\n *\n * The RETURNED object is converted back to a plain one via\n * {@link toPlainObject} — the null prototype is this function's OWN internal\n * bookkeeping safety, never part of its public output (see that helper's doc\n * for why: React Flight/RSC serialization).\n */\nfunction parseBracketParams(\n searchParams: URLSearchParams,\n prefix: string,\n): Record<string, string | string[]> | undefined {\n const out: Record<string, string | string[]> = Object.create(null)\n const re = new RegExp(`^${prefix}\\\\[(.+)\\\\]$`)\n for (const [rawKey, rawValue] of searchParams.entries()) {\n const match = rawKey.match(re)\n if (!match) continue\n const key = match[1]\n const value = rawValue.slice(0, MAX_VALUE_LEN)\n if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue\n const existing = out[key]\n if (existing === undefined) out[key] = value\n else if (Array.isArray(existing)) {\n if (existing.length < MAX_VALUES_PER_KEY) existing.push(value)\n } else out[key] = [existing, value]\n }\n return Object.keys(out).length > 0 ? toPlainObject(out) : undefined\n}\n\n/**\n * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`\n * from `next/navigation`, or a plain `new URLSearchParams(record)`) into\n * `ShopProductsParams`. Hostile/unknown params are dropped: only the known\n * scalar keys and the two bracket groups are ever read.\n */\nexport function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams {\n const params: ShopProductsParams = {}\n\n const page = nonNegativeInt(searchParams.get('page'))\n if (page !== undefined && page >= 1) params.page = page\n\n const limit = nonNegativeInt(searchParams.get('limit'))\n if (limit !== undefined && limit >= 1 && limit <= MAX_LIMIT) params.limit = limit\n\n const q = searchParams.get('q')?.trim().slice(0, MAX_Q_LEN)\n if (q) params.q = q\n\n const sort = searchParams.get('sort')\n if (sort !== null && isShopSort(sort)) params.sort = sort\n\n const category = searchParams.get('category')?.trim()\n if (category) params.category = category\n\n let priceMin = nonNegativeInt(searchParams.get('priceMin'))\n let priceMax = nonNegativeInt(searchParams.get('priceMax'))\n // An inverted range (priceMin > priceMax) would otherwise always match zero\n // products server-side — swap rather than pass through a query that can\n // never return a result, or silently drop half of it.\n if (priceMin !== undefined && priceMax !== undefined && priceMin > priceMax) {\n ;[priceMin, priceMax] = [priceMax, priceMin]\n }\n // Only assign when actually present — an explicit `priceMin: undefined` key\n // is never written, so `toStrictEqual`/`toEqual` against a literal object\n // without that key both hold, and a downstream `'priceMin' in params` check\n // reflects reality.\n if (priceMin !== undefined) params.priceMin = priceMin\n if (priceMax !== undefined) params.priceMax = priceMax\n\n const onSale = searchParams.get('onSale')\n if (onSale === 'true' || onSale === '1') params.onSale = true\n\n const taxonomyRaw = parseBracketParams(searchParams, 'taxonomy')\n if (taxonomyRaw) {\n // A taxonomy axis takes a single term slug in MVP — collapse any repeats,\n // exactly like the server does (see module doc). Object.create(null) for\n // the SAME reason as parseBracketParams' own accumulator: `key` is\n // attacker-controlled (it is the bracket-key from the query string), and\n // a plain `{}` here reintroduces the exact bug parseBracketParams just\n // fixed one step downstream — `taxonomy['__proto__'] = value` on a plain\n // object hits the inherited `__proto__` ACCESSOR, which silently no-ops\n // for a non-object value instead of creating an own property, so that\n // key would vanish from the result entirely (not merely mis-set).\n const taxonomy: Record<string, string> = Object.create(null)\n for (const [key, value] of Object.entries(taxonomyRaw)) {\n taxonomy[key] = Array.isArray(value) ? value[0] : value\n }\n // Plain object out, same reason as parseBracketParams' own return value —\n // see toPlainObject's doc (R3-001, React Flight/RSC serialization).\n params.taxonomy = toPlainObject(taxonomy)\n }\n\n const attr = parseBracketParams(searchParams, 'attr')\n if (attr) params.attr = attr\n\n return params\n}\n\n/**\n * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of\n * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so\n * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`\n * for any already-canonical (non-empty-string, in-range) input — that is the\n * property the round-trip tests pin.\n */\nexport function serializeShopParams(params: ShopProductsParams): URLSearchParams {\n const sp = new URLSearchParams()\n\n if (params.page !== undefined) sp.set('page', String(params.page))\n if (params.limit !== undefined) sp.set('limit', String(params.limit))\n if (params.q) sp.set('q', params.q)\n if (params.sort) sp.set('sort', params.sort)\n if (params.category) sp.set('category', params.category)\n if (params.priceMin !== undefined) sp.set('priceMin', String(params.priceMin))\n if (params.priceMax !== undefined) sp.set('priceMax', String(params.priceMax))\n if (params.onSale) sp.set('onSale', 'true')\n\n if (params.taxonomy) {\n for (const [key, value] of Object.entries(params.taxonomy)) {\n if (value) sp.set(`taxonomy[${key}]`, value)\n }\n }\n\n if (params.attr) {\n for (const [key, value] of Object.entries(params.attr)) {\n if (Array.isArray(value)) {\n for (const v of value) if (v) sp.append(`attr[${key}]`, v)\n } else if (value) {\n sp.set(`attr[${key}]`, value)\n }\n }\n }\n\n return sp\n}\n\n/**\n * Convert Next's `searchParams` route prop —\n * `Record<string, string | string[] | undefined>`, already URL-decoded, with\n * a repeated query key grouped into an array — into a `URLSearchParams` for\n * {@link parseShopSearchParams}.\n *\n * NOT the same as `new URLSearchParams(searchParams)`: the native\n * `URLSearchParams` constructor only accepts `Record<string, string>` —\n * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls\n * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two\n * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently\n * breaks `parseShopSearchParams`'s repeat-count-based array/scalar\n * distinction (see its own module doc) for every multi-value `attr[<key>]`\n * filter — this helper is what actually preserves the repeat.\n */\nexport function toURLSearchParams(\n searchParams: Record<string, string | string[] | undefined>,\n): URLSearchParams {\n const usp = new URLSearchParams()\n for (const [key, value] of Object.entries(searchParams)) {\n if (value === undefined) continue\n if (Array.isArray(value)) {\n for (const v of value) usp.append(key, v)\n } else {\n usp.append(key, value)\n }\n }\n return usp\n}\n\n/**\n * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept\n * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package\n * dependency on `@reblu/site-blocks` (this package has none today, and this\n * one shape does not warrant adding one). Any object matching this shape is\n * assignable to `ShopFiltersHrefs` by structural typing.\n */\nexport interface ShopFilterHrefs {\n taxonomy: (axisKey: string, value: string | undefined) => string\n attr: (fieldKey: string, value: string) => string\n price: (min: number | undefined, max: number | undefined) => string\n onSale: (nextActive: boolean) => string\n clearAll: () => string\n}\n\n/**\n * Build `ShopFilters`' href set from the CURRENT params and pathname. Every\n * href preserves every OTHER current param (category, q, sort, every other\n * taxonomy/attr filter) and resets pagination (`page`) — a filter change is\n * never silently stuck on page 4 of a now-smaller result set.\n *\n * Pure and framework-agnostic (no `next/navigation`): a Server Component calls\n * it directly with its own `searchParams` prop and route pathname — no\n * `'use client'` boundary required. See `./use-shop-filter-links` for the\n * client-side hook that wraps this with `usePathname`/`useSearchParams`.\n */\nexport function buildShopFilterHrefs(\n current: ShopProductsParams,\n pathname: string,\n): ShopFilterHrefs {\n function hrefFor(patch: Partial<ShopProductsParams>): string {\n // Any filter change resets pagination — the previous page number almost\n // never still makes sense against the new (usually smaller) result set.\n const next: ShopProductsParams = { ...current, ...patch, page: undefined }\n const qs = serializeShopParams(next).toString()\n return qs ? `${pathname}?${qs}` : pathname\n }\n\n return {\n taxonomy: (axisKey, value) => {\n const taxonomy = { ...current.taxonomy }\n if (value === undefined) delete taxonomy[axisKey]\n else taxonomy[axisKey] = value\n return hrefFor({ taxonomy })\n },\n attr: (fieldKey, value) => {\n const existing = current.attr?.[fieldKey]\n const values = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]\n const next = values.includes(value) ? values.filter((v) => v !== value) : [...values, value]\n const attr = { ...current.attr }\n if (next.length === 0) delete attr[fieldKey]\n else attr[fieldKey] = next\n return hrefFor({ attr })\n },\n price: (min, max) => hrefFor({ priceMin: min, priceMax: max }),\n onSale: (nextActive) => hrefFor({ onSale: nextActive || undefined }),\n // Clears every FACET dimension only — q/category/sort (the non-facet part\n // of the query) are deliberately preserved, mirroring ShopFilters' own\n // onChange clear-all patch.\n clearAll: () =>\n hrefFor({ taxonomy: undefined, attr: undefined, priceMin: undefined, priceMax: undefined, onSale: undefined }),\n }\n}\n","import type { RebluClient, ListResult } from '@reblu/site-client'\nimport type { ShopFacets, ShopProductListItem } from '@reblu/site-contracts'\nimport type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * `getShopCollection` (REB-809) — fetches the product list AND its facets with\n * the EXACT SAME params in one call. This is the seam that keeps a shop\n * collection page correct: facet counts must reflect the same filter context\n * the product list is showing, and a hand-rolled page that calls\n * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`\n * separately is exactly how they drift apart.\n *\n * Server-safe (no `'use client'`, no React) — call it directly from an async\n * Server Component (route `page.tsx`) with the params parsed via\n * {@link parseShopSearchParams} from that page's `searchParams` prop.\n */\nexport interface ShopCollection {\n products: ListResult<ShopProductListItem>\n facets: ShopFacets\n /**\n * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;\n * `'unavailable'` when it failed and `facets` above is the degraded\n * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an\n * empty `facets` as \"this tenant configured no filterable axes\" when it may\n * really mean \"the facets endpoint is down right now\". A logged\n * `console.warn` (see below) covers the case nobody checks this field.\n */\n facetsStatus: 'ok' | 'unavailable'\n}\n\n/**\n * Returned when `client.shop.facets(...)` fails — a `ShopFilters` that\n * receives this simply renders nothing (`shouldRenderShopFilters` is false\n * for it with no active selection), which is the correct degraded state: no\n * filter UI, rather than no page at all. Paired with `facetsStatus:\n * 'unavailable'` on the returned {@link ShopCollection}, so a caller that\n * DOES care can tell \"no axes configured\" apart from \"facets endpoint down\".\n */\nconst EMPTY_FACETS: ShopFacets = {\n taxonomies: [],\n attributes: [],\n price: { min: 0, max: 0 },\n onSale: { count: 0 },\n}\n\n/**\n * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT\n * equally critical. A product listing with no products is not a listing —\n * that failure must propagate so the caller's own error handling (or a\n * thrown-away `try/catch` degrading the whole page) sees it. A product\n * listing with no FACETS is still a perfectly good listing, just without\n * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead\n * of letting one upstream endpoint's outage take the other down too, is what\n * `Promise.allSettled` (rather than `Promise.all`) buys here.\n *\n * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged\n * `[getShopCollection]` — the same convention `@reblu/site-next` already uses\n * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,\n * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the\n * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on\n * it (retry, surface a banner, alert) do so without parsing a log line.\n */\nexport async function getShopCollection(\n client: RebluClient,\n params?: ShopProductsParams,\n): Promise<ShopCollection> {\n const [productsResult, facetsResult] = await Promise.allSettled([\n client.shop.products(params),\n client.shop.facets(params),\n ])\n\n if (productsResult.status === 'rejected') {\n throw productsResult.reason\n }\n\n if (facetsResult.status === 'rejected') {\n console.warn('[getShopCollection] facets fetch failed, degrading to empty ShopFacets:', facetsResult.reason)\n return { products: productsResult.value, facets: EMPTY_FACETS, facetsStatus: 'unavailable' }\n }\n\n return { products: productsResult.value, facets: facetsResult.value, facetsStatus: 'ok' }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/shop/server.ts","../src/shop/params.ts","../src/shop/collection.ts"],"sourcesContent":["/**\n * `@reblu/site-next/shop-server` (REB-809) — the server-safe half of the shop\n * filters glue. Every export here has ZERO React and must NEVER carry\n * `'use client'` — same isolation direction as `./route-handlers` and\n * `./academy-server` (see `tsup.config.ts`). A Server Component (a route's\n * `page.tsx`) calls these directly: parse its own `searchParams` prop,\n * fetch the collection, build hrefs, and render\n * `@reblu/site-blocks`'s `ShopFilters` with NO `onChange` and NO client\n * boundary at all — the exact \"importable from a Server Component\" shape\n * REB-647/REB-809 require. `./shop` re-exports the same pure helpers\n * alongside the `'use client'` navigation hook for a page that wants the\n * client-reactive version instead.\n */\nexport {\n parseShopSearchParams,\n serializeShopParams,\n toURLSearchParams,\n buildShopFilterHrefs,\n type ShopFilterHrefs,\n} from './params'\nexport { getShopCollection, type ShopCollection } from './collection'\n","import type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * REB-809 — URL ↔ `ShopProductsParams` glue for a SITE's shop/collection page.\n *\n * Mirrors base-api's own `parseListParams`\n * (`apps/base-api/src/lib/shop/catalogue-query.ts`) deliberately: the SAME\n * bracket shape (`taxonomy[<key>]=<slug>`, `attr[<key>]=<value>`, repeatable\n * for `attr`), the SAME MVP collapse for `taxonomy` (one term slug per axis —\n * a repeat keeps only the first), and the SAME defensive caps on bracket\n * key/value counts. Keeping this a mirror rather than a re-implementation\n * means a URL this module builds is EXACTLY the URL the server will parse\n * back to the same params — there is no second, drifting parser.\n */\n\nconst MAX_Q_LEN = 200\nconst MAX_FILTER_KEYS = 30\nconst MAX_VALUE_LEN = 120\nconst MAX_VALUES_PER_KEY = 30\n/**\n * Mirrors `shopProductListParamsSchema`'s `limit: z.number().int().min(1).max(50)`\n * in `apps/base-api/src/lib/contracts/shop.contract.ts` — keep the two in sync;\n * a caller-side limit above this is server-rejected/clamped anyway, but parsing\n * it through here means a URL this module builds never claims a limit the\n * server will refuse.\n */\nconst MAX_LIMIT = 50\n\nconst SHOP_SORTS = ['price_asc', 'price_desc', 'newest', 'on_sale'] as const\n\nfunction isShopSort(value: string): value is (typeof SHOP_SORTS)[number] {\n return (SHOP_SORTS as readonly string[]).includes(value)\n}\n\n/**\n * Parse a non-negative integer, STRICT digits only — `/^\\d+$/`, so '12abc'\n * and '1.5' are rejected outright rather than `Number.parseInt` silently\n * taking the leading '12'/'1' and discarding the rest as if it were valid\n * input. Named for exactly what it accepts: >= 0, not \"positive\" (which\n * excludes 0) — callers that need >= 1 (page, limit) enforce that bound\n * themselves on top of this.\n */\nfunction nonNegativeInt(raw: string | null): number | undefined {\n if (raw === null || !/^\\d+$/.test(raw)) return undefined\n const n = Number.parseInt(raw, 10)\n return Number.isNaN(n) ? undefined : n\n}\n\n/**\n * Copy a (possibly null-prototype) object's own enumerable properties into a\n * fresh PLAIN object — `Object.prototype` in its chain, nothing else.\n *\n * R3-001: this module's internal accumulators use `Object.create(null)` for\n * prototype-pollution safety (see `parseBracketParams`' own doc below), but\n * that safety property must NEVER leak into `ShopProductsParams.taxonomy`/\n * `.attr`, the values a SITE actually receives. React Flight — the\n * serialization React Server Components use to send props across the\n * Server→Client boundary — refuses a non-plain object (`Object.\n * getPrototypeOf(x) !== Object.prototype`) with a hard runtime error. This\n * package's own README tells a Client Component caller to pass\n * `active={params}` to `ShopFilters`' `onChange` mode, so a null-prototype\n * `taxonomy`/`attr` reaching that boundary would crash every SITE that\n * followed the documented pattern. Object spread (`{ ...source }`) always\n * produces a plain object regardless of the source's own prototype, which is\n * exactly the property this needs and `Object.assign({}, source)` also has —\n * spread is used here for clarity.\n */\nfunction toPlainObject<T>(source: Record<string, T>): Record<string, T> {\n return { ...source }\n}\n\n/**\n * Parse `bracket[key]` params into a map, collapsing repeats into arrays.\n *\n * Built on `Object.create(null)` rather than a plain `{}` — a query string is\n * attacker-controlled input, and `taxonomy[constructor]=x` or\n * `taxonomy[toString]=x` against a plain object reads/writes through the\n * INHERITED `Object.prototype` property of that name before this function's\n * own `key in out` / `out[key]` logic ever sees an actual own property,\n * corrupting the key-count bookkeeping and the parsed value for that key. A\n * null-prototype object has no inherited properties at all, so `in` and\n * indexed access only ever see what this loop itself has written — the same\n * defense `JSON.parse` reviewers reach for against prototype-pollution-style\n * bracket keys, applied here to a hand-rolled key/value accumulator.\n *\n * The RETURNED object is converted back to a plain one via\n * {@link toPlainObject} — the null prototype is this function's OWN internal\n * bookkeeping safety, never part of its public output (see that helper's doc\n * for why: React Flight/RSC serialization).\n */\nfunction parseBracketParams(\n searchParams: URLSearchParams,\n prefix: string,\n): Record<string, string | string[]> | undefined {\n const out: Record<string, string | string[]> = Object.create(null)\n const re = new RegExp(`^${prefix}\\\\[(.+)\\\\]$`)\n for (const [rawKey, rawValue] of searchParams.entries()) {\n const match = rawKey.match(re)\n if (!match) continue\n const key = match[1]\n const value = rawValue.trim().slice(0, MAX_VALUE_LEN)\n // REB-827: an empty/whitespace-only value (e.g. a cleared `taxonomy[marca]=`,\n // or a stale/hand-built link) is never a real filter — trim and drop it\n // here, mirroring how `q` is trimmed and dropped when empty, so a key left\n // with no surviving value never enters `out` and never reaches the caller\n // as `{ key: '' }`/`{ key: [] }`.\n if (!value) continue\n if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue\n const existing = out[key]\n if (existing === undefined) out[key] = value\n else if (Array.isArray(existing)) {\n if (existing.length < MAX_VALUES_PER_KEY) existing.push(value)\n } else out[key] = [existing, value]\n }\n return Object.keys(out).length > 0 ? toPlainObject(out) : undefined\n}\n\n/**\n * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`\n * from `next/navigation`, or a plain `new URLSearchParams(record)`) into\n * `ShopProductsParams`. Hostile/unknown params are dropped: only the known\n * scalar keys and the two bracket groups are ever read.\n */\nexport function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams {\n const params: ShopProductsParams = {}\n\n const page = nonNegativeInt(searchParams.get('page'))\n if (page !== undefined && page >= 1) params.page = page\n\n const limit = nonNegativeInt(searchParams.get('limit'))\n if (limit !== undefined && limit >= 1 && limit <= MAX_LIMIT) params.limit = limit\n\n const q = searchParams.get('q')?.trim().slice(0, MAX_Q_LEN)\n if (q) params.q = q\n\n const sort = searchParams.get('sort')\n if (sort !== null && isShopSort(sort)) params.sort = sort\n\n const category = searchParams.get('category')?.trim()\n if (category) params.category = category\n\n let priceMin = nonNegativeInt(searchParams.get('priceMin'))\n let priceMax = nonNegativeInt(searchParams.get('priceMax'))\n // An inverted range (priceMin > priceMax) would otherwise always match zero\n // products server-side — swap rather than pass through a query that can\n // never return a result, or silently drop half of it.\n if (priceMin !== undefined && priceMax !== undefined && priceMin > priceMax) {\n ;[priceMin, priceMax] = [priceMax, priceMin]\n }\n // Only assign when actually present — an explicit `priceMin: undefined` key\n // is never written, so `toStrictEqual`/`toEqual` against a literal object\n // without that key both hold, and a downstream `'priceMin' in params` check\n // reflects reality.\n if (priceMin !== undefined) params.priceMin = priceMin\n if (priceMax !== undefined) params.priceMax = priceMax\n\n const onSale = searchParams.get('onSale')\n if (onSale === 'true' || onSale === '1') params.onSale = true\n\n const taxonomyRaw = parseBracketParams(searchParams, 'taxonomy')\n if (taxonomyRaw) {\n // A taxonomy axis takes a single term slug in MVP — collapse any repeats,\n // exactly like the server does (see module doc). Object.create(null) for\n // the SAME reason as parseBracketParams' own accumulator: `key` is\n // attacker-controlled (it is the bracket-key from the query string), and\n // a plain `{}` here reintroduces the exact bug parseBracketParams just\n // fixed one step downstream — `taxonomy['__proto__'] = value` on a plain\n // object hits the inherited `__proto__` ACCESSOR, which silently no-ops\n // for a non-object value instead of creating an own property, so that\n // key would vanish from the result entirely (not merely mis-set).\n const taxonomy: Record<string, string> = Object.create(null)\n for (const [key, value] of Object.entries(taxonomyRaw)) {\n taxonomy[key] = Array.isArray(value) ? value[0] : value\n }\n // Plain object out, same reason as parseBracketParams' own return value —\n // see toPlainObject's doc (R3-001, React Flight/RSC serialization).\n params.taxonomy = toPlainObject(taxonomy)\n }\n\n const attr = parseBracketParams(searchParams, 'attr')\n if (attr) params.attr = attr\n\n return params\n}\n\n/**\n * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of\n * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so\n * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`\n * for any already-canonical (non-empty-string, in-range) input — that is the\n * property the round-trip tests pin.\n */\nexport function serializeShopParams(params: ShopProductsParams): URLSearchParams {\n const sp = new URLSearchParams()\n\n if (params.page !== undefined) sp.set('page', String(params.page))\n if (params.limit !== undefined) sp.set('limit', String(params.limit))\n if (params.q) sp.set('q', params.q)\n if (params.sort) sp.set('sort', params.sort)\n if (params.category) sp.set('category', params.category)\n if (params.priceMin !== undefined) sp.set('priceMin', String(params.priceMin))\n if (params.priceMax !== undefined) sp.set('priceMax', String(params.priceMax))\n if (params.onSale) sp.set('onSale', 'true')\n\n // REB-827: an empty/whitespace-only value is never a real filter — never\n // emit it, matching what parseShopSearchParams would now drop on the way\n // back in anyway (a caller handing this an already-empty `active` value,\n // e.g. a stale object a SITE built itself, must not see it reach the URL).\n //\n // R3-serialize-trim-nullish-throw (review follow-up): `ShopProductsParams`\n // types these values as plain strings, but a JS caller building this object\n // dynamically very commonly ends up with an actual `undefined`/`null` value\n // at runtime regardless of the type (e.g. `taxonomy: { marca: someOptional }`).\n // `typeof value === 'string'` guards `.trim()` itself — a nullish entry is\n // treated exactly like an empty one (never emitted), not a thrown TypeError.\n if (params.taxonomy) {\n for (const [key, value] of Object.entries(params.taxonomy)) {\n if (typeof value === 'string' && value.trim()) sp.set(`taxonomy[${key}]`, value)\n }\n }\n\n if (params.attr) {\n for (const [key, value] of Object.entries(params.attr)) {\n if (Array.isArray(value)) {\n for (const v of value) if (typeof v === 'string' && v.trim()) sp.append(`attr[${key}]`, v)\n } else if (typeof value === 'string' && value.trim()) {\n sp.set(`attr[${key}]`, value)\n }\n }\n }\n\n return sp\n}\n\n/**\n * Convert Next's `searchParams` route prop —\n * `Record<string, string | string[] | undefined>`, already URL-decoded, with\n * a repeated query key grouped into an array — into a `URLSearchParams` for\n * {@link parseShopSearchParams}.\n *\n * NOT the same as `new URLSearchParams(searchParams)`: the native\n * `URLSearchParams` constructor only accepts `Record<string, string>` —\n * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls\n * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two\n * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently\n * breaks `parseShopSearchParams`'s repeat-count-based array/scalar\n * distinction (see its own module doc) for every multi-value `attr[<key>]`\n * filter — this helper is what actually preserves the repeat.\n */\nexport function toURLSearchParams(\n searchParams: Record<string, string | string[] | undefined>,\n): URLSearchParams {\n const usp = new URLSearchParams()\n for (const [key, value] of Object.entries(searchParams)) {\n if (value === undefined) continue\n if (Array.isArray(value)) {\n for (const v of value) usp.append(key, v)\n } else {\n usp.append(key, value)\n }\n }\n return usp\n}\n\n/**\n * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept\n * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package\n * dependency on `@reblu/site-blocks` (this package has none today, and this\n * one shape does not warrant adding one). Any object matching this shape is\n * assignable to `ShopFiltersHrefs` by structural typing.\n */\nexport interface ShopFilterHrefs {\n taxonomy: (axisKey: string, value: string | undefined) => string\n attr: (fieldKey: string, value: string) => string\n price: (min: number | undefined, max: number | undefined) => string\n onSale: (nextActive: boolean) => string\n clearAll: () => string\n}\n\n/**\n * Build `ShopFilters`' href set from the CURRENT params and pathname. Every\n * href preserves every OTHER current param (category, q, sort, every other\n * taxonomy/attr filter) and resets pagination (`page`) — a filter change is\n * never silently stuck on page 4 of a now-smaller result set.\n *\n * Pure and framework-agnostic (no `next/navigation`): a Server Component calls\n * it directly with its own `searchParams` prop and route pathname — no\n * `'use client'` boundary required. See `./use-shop-filter-links` for the\n * client-side hook that wraps this with `usePathname`/`useSearchParams`.\n */\nexport function buildShopFilterHrefs(\n current: ShopProductsParams,\n pathname: string,\n): ShopFilterHrefs {\n function hrefFor(patch: Partial<ShopProductsParams>): string {\n // Any filter change resets pagination — the previous page number almost\n // never still makes sense against the new (usually smaller) result set.\n const next: ShopProductsParams = { ...current, ...patch, page: undefined }\n const qs = serializeShopParams(next).toString()\n return qs ? `${pathname}?${qs}` : pathname\n }\n\n return {\n taxonomy: (axisKey, value) => {\n const taxonomy = { ...current.taxonomy }\n if (value === undefined) delete taxonomy[axisKey]\n else taxonomy[axisKey] = value\n return hrefFor({ taxonomy })\n },\n attr: (fieldKey, value) => {\n const existing = current.attr?.[fieldKey]\n const values = existing === undefined ? [] : Array.isArray(existing) ? existing : [existing]\n const next = values.includes(value) ? values.filter((v) => v !== value) : [...values, value]\n const attr = { ...current.attr }\n if (next.length === 0) delete attr[fieldKey]\n else attr[fieldKey] = next\n return hrefFor({ attr })\n },\n price: (min, max) => hrefFor({ priceMin: min, priceMax: max }),\n onSale: (nextActive) => hrefFor({ onSale: nextActive || undefined }),\n // Clears every FACET dimension only — q/category/sort (the non-facet part\n // of the query) are deliberately preserved, mirroring ShopFilters' own\n // onChange clear-all patch.\n clearAll: () =>\n hrefFor({ taxonomy: undefined, attr: undefined, priceMin: undefined, priceMax: undefined, onSale: undefined }),\n }\n}\n","import type { RebluClient, ListResult } from '@reblu/site-client'\nimport type { ShopFacets, ShopProductListItem } from '@reblu/site-contracts'\nimport type { ShopProductsParams } from '@reblu/site-client'\n\n/**\n * `getShopCollection` (REB-809) — fetches the product list AND its facets with\n * the EXACT SAME params in one call. This is the seam that keeps a shop\n * collection page correct: facet counts must reflect the same filter context\n * the product list is showing, and a hand-rolled page that calls\n * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`\n * separately is exactly how they drift apart.\n *\n * Server-safe (no `'use client'`, no React) — call it directly from an async\n * Server Component (route `page.tsx`) with the params parsed via\n * {@link parseShopSearchParams} from that page's `searchParams` prop.\n */\nexport interface ShopCollection {\n products: ListResult<ShopProductListItem>\n facets: ShopFacets\n /**\n * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;\n * `'unavailable'` when it failed and `facets` above is the degraded\n * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an\n * empty `facets` as \"this tenant configured no filterable axes\" when it may\n * really mean \"the facets endpoint is down right now\". A logged\n * `console.warn` (see below) covers the case nobody checks this field.\n */\n facetsStatus: 'ok' | 'unavailable'\n}\n\n/**\n * Returned when `client.shop.facets(...)` fails — a `ShopFilters` that\n * receives this simply renders nothing (`shouldRenderShopFilters` is false\n * for it with no active selection), which is the correct degraded state: no\n * filter UI, rather than no page at all. Paired with `facetsStatus:\n * 'unavailable'` on the returned {@link ShopCollection}, so a caller that\n * DOES care can tell \"no axes configured\" apart from \"facets endpoint down\".\n */\nconst EMPTY_FACETS: ShopFacets = {\n taxonomies: [],\n attributes: [],\n price: { min: 0, max: 0 },\n onSale: { count: 0 },\n}\n\n/**\n * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT\n * equally critical. A product listing with no products is not a listing —\n * that failure must propagate so the caller's own error handling (or a\n * thrown-away `try/catch` degrading the whole page) sees it. A product\n * listing with no FACETS is still a perfectly good listing, just without\n * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead\n * of letting one upstream endpoint's outage take the other down too, is what\n * `Promise.allSettled` (rather than `Promise.all`) buys here.\n *\n * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged\n * `[getShopCollection]` — the same convention `@reblu/site-next` already uses\n * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,\n * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the\n * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on\n * it (retry, surface a banner, alert) do so without parsing a log line.\n */\nexport async function getShopCollection(\n client: RebluClient,\n params?: ShopProductsParams,\n): Promise<ShopCollection> {\n const [productsResult, facetsResult] = await Promise.allSettled([\n client.shop.products(params),\n client.shop.facets(params),\n ])\n\n if (productsResult.status === 'rejected') {\n throw productsResult.reason\n }\n\n if (facetsResult.status === 'rejected') {\n console.warn('[getShopCollection] facets fetch failed, degrading to empty ShopFacets:', facetsResult.reason)\n return { products: productsResult.value, facets: EMPTY_FACETS, facetsStatus: 'unavailable' }\n }\n\n return { products: productsResult.value, facets: facetsResult.value, facetsStatus: 'ok' }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACeA,IAAM,YAAY;AAClB,IAAM,kBAAkB;AACxB,IAAM,gBAAgB;AACtB,IAAM,qBAAqB;AAQ3B,IAAM,YAAY;AAElB,IAAM,aAAa,CAAC,aAAa,cAAc,UAAU,SAAS;AAElE,SAAS,WAAW,OAAqD;AACvE,SAAQ,WAAiC,SAAS,KAAK;AACzD;AAUA,SAAS,eAAe,KAAwC;AAC9D,MAAI,QAAQ,QAAQ,CAAC,QAAQ,KAAK,GAAG,EAAG,QAAO;AAC/C,QAAM,IAAI,OAAO,SAAS,KAAK,EAAE;AACjC,SAAO,OAAO,MAAM,CAAC,IAAI,SAAY;AACvC;AAqBA,SAAS,cAAiB,QAA8C;AACtE,SAAO,EAAE,GAAG,OAAO;AACrB;AAqBA,SAAS,mBACP,cACA,QAC+C;AAC/C,QAAM,MAAyC,uBAAO,OAAO,IAAI;AACjE,QAAM,KAAK,IAAI,OAAO,IAAI,MAAM,aAAa;AAC7C,aAAW,CAAC,QAAQ,QAAQ,KAAK,aAAa,QAAQ,GAAG;AACvD,UAAM,QAAQ,OAAO,MAAM,EAAE;AAC7B,QAAI,CAAC,MAAO;AACZ,UAAM,MAAM,MAAM,CAAC;AACnB,UAAM,QAAQ,SAAS,KAAK,EAAE,MAAM,GAAG,aAAa;AAMpD,QAAI,CAAC,MAAO;AACZ,QAAI,EAAE,OAAO,QAAQ,OAAO,KAAK,GAAG,EAAE,UAAU,gBAAiB;AACjE,UAAM,WAAW,IAAI,GAAG;AACxB,QAAI,aAAa,OAAW,KAAI,GAAG,IAAI;AAAA,aAC9B,MAAM,QAAQ,QAAQ,GAAG;AAChC,UAAI,SAAS,SAAS,mBAAoB,UAAS,KAAK,KAAK;AAAA,IAC/D,MAAO,KAAI,GAAG,IAAI,CAAC,UAAU,KAAK;AAAA,EACpC;AACA,SAAO,OAAO,KAAK,GAAG,EAAE,SAAS,IAAI,cAAc,GAAG,IAAI;AAC5D;AAQO,SAAS,sBAAsB,cAAmD;AACvF,QAAM,SAA6B,CAAC;AAEpC,QAAM,OAAO,eAAe,aAAa,IAAI,MAAM,CAAC;AACpD,MAAI,SAAS,UAAa,QAAQ,EAAG,QAAO,OAAO;AAEnD,QAAM,QAAQ,eAAe,aAAa,IAAI,OAAO,CAAC;AACtD,MAAI,UAAU,UAAa,SAAS,KAAK,SAAS,UAAW,QAAO,QAAQ;AAE5E,QAAM,IAAI,aAAa,IAAI,GAAG,GAAG,KAAK,EAAE,MAAM,GAAG,SAAS;AAC1D,MAAI,EAAG,QAAO,IAAI;AAElB,QAAM,OAAO,aAAa,IAAI,MAAM;AACpC,MAAI,SAAS,QAAQ,WAAW,IAAI,EAAG,QAAO,OAAO;AAErD,QAAM,WAAW,aAAa,IAAI,UAAU,GAAG,KAAK;AACpD,MAAI,SAAU,QAAO,WAAW;AAEhC,MAAI,WAAW,eAAe,aAAa,IAAI,UAAU,CAAC;AAC1D,MAAI,WAAW,eAAe,aAAa,IAAI,UAAU,CAAC;AAI1D,MAAI,aAAa,UAAa,aAAa,UAAa,WAAW,UAAU;AAC3E;AAAC,KAAC,UAAU,QAAQ,IAAI,CAAC,UAAU,QAAQ;AAAA,EAC7C;AAKA,MAAI,aAAa,OAAW,QAAO,WAAW;AAC9C,MAAI,aAAa,OAAW,QAAO,WAAW;AAE9C,QAAM,SAAS,aAAa,IAAI,QAAQ;AACxC,MAAI,WAAW,UAAU,WAAW,IAAK,QAAO,SAAS;AAEzD,QAAM,cAAc,mBAAmB,cAAc,UAAU;AAC/D,MAAI,aAAa;AAUf,UAAM,WAAmC,uBAAO,OAAO,IAAI;AAC3D,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,WAAW,GAAG;AACtD,eAAS,GAAG,IAAI,MAAM,QAAQ,KAAK,IAAI,MAAM,CAAC,IAAI;AAAA,IACpD;AAGA,WAAO,WAAW,cAAc,QAAQ;AAAA,EAC1C;AAEA,QAAM,OAAO,mBAAmB,cAAc,MAAM;AACpD,MAAI,KAAM,QAAO,OAAO;AAExB,SAAO;AACT;AASO,SAAS,oBAAoB,QAA6C;AAC/E,QAAM,KAAK,IAAI,gBAAgB;AAE/B,MAAI,OAAO,SAAS,OAAW,IAAG,IAAI,QAAQ,OAAO,OAAO,IAAI,CAAC;AACjE,MAAI,OAAO,UAAU,OAAW,IAAG,IAAI,SAAS,OAAO,OAAO,KAAK,CAAC;AACpE,MAAI,OAAO,EAAG,IAAG,IAAI,KAAK,OAAO,CAAC;AAClC,MAAI,OAAO,KAAM,IAAG,IAAI,QAAQ,OAAO,IAAI;AAC3C,MAAI,OAAO,SAAU,IAAG,IAAI,YAAY,OAAO,QAAQ;AACvD,MAAI,OAAO,aAAa,OAAW,IAAG,IAAI,YAAY,OAAO,OAAO,QAAQ,CAAC;AAC7E,MAAI,OAAO,aAAa,OAAW,IAAG,IAAI,YAAY,OAAO,OAAO,QAAQ,CAAC;AAC7E,MAAI,OAAO,OAAQ,IAAG,IAAI,UAAU,MAAM;AAa1C,MAAI,OAAO,UAAU;AACnB,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,QAAQ,GAAG;AAC1D,UAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAG,IAAG,IAAI,YAAY,GAAG,KAAK,KAAK;AAAA,IACjF;AAAA,EACF;AAEA,MAAI,OAAO,MAAM;AACf,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,IAAI,GAAG;AACtD,UAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,mBAAW,KAAK,MAAO,KAAI,OAAO,MAAM,YAAY,EAAE,KAAK,EAAG,IAAG,OAAO,QAAQ,GAAG,KAAK,CAAC;AAAA,MAC3F,WAAW,OAAO,UAAU,YAAY,MAAM,KAAK,GAAG;AACpD,WAAG,IAAI,QAAQ,GAAG,KAAK,KAAK;AAAA,MAC9B;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAiBO,SAAS,kBACd,cACiB;AACjB,QAAM,MAAM,IAAI,gBAAgB;AAChC,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,YAAY,GAAG;AACvD,QAAI,UAAU,OAAW;AACzB,QAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,iBAAW,KAAK,MAAO,KAAI,OAAO,KAAK,CAAC;AAAA,IAC1C,OAAO;AACL,UAAI,OAAO,KAAK,KAAK;AAAA,IACvB;AAAA,EACF;AACA,SAAO;AACT;AA4BO,SAAS,qBACd,SACA,UACiB;AACjB,WAAS,QAAQ,OAA4C;AAG3D,UAAM,OAA2B,EAAE,GAAG,SAAS,GAAG,OAAO,MAAM,OAAU;AACzE,UAAM,KAAK,oBAAoB,IAAI,EAAE,SAAS;AAC9C,WAAO,KAAK,GAAG,QAAQ,IAAI,EAAE,KAAK;AAAA,EACpC;AAEA,SAAO;AAAA,IACL,UAAU,CAAC,SAAS,UAAU;AAC5B,YAAM,WAAW,EAAE,GAAG,QAAQ,SAAS;AACvC,UAAI,UAAU,OAAW,QAAO,SAAS,OAAO;AAAA,UAC3C,UAAS,OAAO,IAAI;AACzB,aAAO,QAAQ,EAAE,SAAS,CAAC;AAAA,IAC7B;AAAA,IACA,MAAM,CAAC,UAAU,UAAU;AACzB,YAAM,WAAW,QAAQ,OAAO,QAAQ;AACxC,YAAM,SAAS,aAAa,SAAY,CAAC,IAAI,MAAM,QAAQ,QAAQ,IAAI,WAAW,CAAC,QAAQ;AAC3F,YAAM,OAAO,OAAO,SAAS,KAAK,IAAI,OAAO,OAAO,CAAC,MAAM,MAAM,KAAK,IAAI,CAAC,GAAG,QAAQ,KAAK;AAC3F,YAAM,OAAO,EAAE,GAAG,QAAQ,KAAK;AAC/B,UAAI,KAAK,WAAW,EAAG,QAAO,KAAK,QAAQ;AAAA,UACtC,MAAK,QAAQ,IAAI;AACtB,aAAO,QAAQ,EAAE,KAAK,CAAC;AAAA,IACzB;AAAA,IACA,OAAO,CAAC,KAAK,QAAQ,QAAQ,EAAE,UAAU,KAAK,UAAU,IAAI,CAAC;AAAA,IAC7D,QAAQ,CAAC,eAAe,QAAQ,EAAE,QAAQ,cAAc,OAAU,CAAC;AAAA;AAAA;AAAA;AAAA,IAInE,UAAU,MACR,QAAQ,EAAE,UAAU,QAAW,MAAM,QAAW,UAAU,QAAW,UAAU,QAAW,QAAQ,OAAU,CAAC;AAAA,EACjH;AACF;;;AChSA,IAAM,eAA2B;AAAA,EAC/B,YAAY,CAAC;AAAA,EACb,YAAY,CAAC;AAAA,EACb,OAAO,EAAE,KAAK,GAAG,KAAK,EAAE;AAAA,EACxB,QAAQ,EAAE,OAAO,EAAE;AACrB;AAmBA,eAAsB,kBACpB,QACA,QACyB;AACzB,QAAM,CAAC,gBAAgB,YAAY,IAAI,MAAM,QAAQ,WAAW;AAAA,IAC9D,OAAO,KAAK,SAAS,MAAM;AAAA,IAC3B,OAAO,KAAK,OAAO,MAAM;AAAA,EAC3B,CAAC;AAED,MAAI,eAAe,WAAW,YAAY;AACxC,UAAM,eAAe;AAAA,EACvB;AAEA,MAAI,aAAa,WAAW,YAAY;AACtC,YAAQ,KAAK,2EAA2E,aAAa,MAAM;AAC3G,WAAO,EAAE,UAAU,eAAe,OAAO,QAAQ,cAAc,cAAc,cAAc;AAAA,EAC7F;AAEA,SAAO,EAAE,UAAU,eAAe,OAAO,QAAQ,aAAa,OAAO,cAAc,KAAK;AAC1F;","names":[]}
|
package/dist/shop-server.js
CHANGED
|
@@ -23,7 +23,8 @@ function parseBracketParams(searchParams, prefix) {
|
|
|
23
23
|
const match = rawKey.match(re);
|
|
24
24
|
if (!match) continue;
|
|
25
25
|
const key = match[1];
|
|
26
|
-
const value = rawValue.slice(0, MAX_VALUE_LEN);
|
|
26
|
+
const value = rawValue.trim().slice(0, MAX_VALUE_LEN);
|
|
27
|
+
if (!value) continue;
|
|
27
28
|
if (!(key in out) && Object.keys(out).length >= MAX_FILTER_KEYS) continue;
|
|
28
29
|
const existing = out[key];
|
|
29
30
|
if (existing === void 0) out[key] = value;
|
|
@@ -79,14 +80,14 @@ function serializeShopParams(params) {
|
|
|
79
80
|
if (params.onSale) sp.set("onSale", "true");
|
|
80
81
|
if (params.taxonomy) {
|
|
81
82
|
for (const [key, value] of Object.entries(params.taxonomy)) {
|
|
82
|
-
if (value) sp.set(`taxonomy[${key}]`, value);
|
|
83
|
+
if (typeof value === "string" && value.trim()) sp.set(`taxonomy[${key}]`, value);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
if (params.attr) {
|
|
86
87
|
for (const [key, value] of Object.entries(params.attr)) {
|
|
87
88
|
if (Array.isArray(value)) {
|
|
88
|
-
for (const v of value) if (v) sp.append(`attr[${key}]`, v);
|
|
89
|
-
} else if (value) {
|
|
89
|
+
for (const v of value) if (typeof v === "string" && v.trim()) sp.append(`attr[${key}]`, v);
|
|
90
|
+
} else if (typeof value === "string" && value.trim()) {
|
|
90
91
|
sp.set(`attr[${key}]`, value);
|
|
91
92
|
}
|
|
92
93
|
}
|