@stapel/categories-react 0.1.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 +1 -0
- package/MODULE.md +194 -0
- package/README.md +191 -0
- package/dist/api/categoriesApi.d.ts +121 -0
- package/dist/api/categoriesApi.d.ts.map +1 -0
- package/dist/api/categoriesApi.js +48 -0
- package/dist/api/categoriesApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +2321 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +92 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/catalog/labels.d.ts +103 -0
- package/dist/catalog/labels.d.ts.map +1 -0
- package/dist/catalog/labels.js +56 -0
- package/dist/catalog/labels.js.map +1 -0
- package/dist/catalog/pks.d.ts +25 -0
- package/dist/catalog/pks.d.ts.map +1 -0
- package/dist/catalog/pks.js +38 -0
- package/dist/catalog/pks.js.map +1 -0
- package/dist/catalog/sync.d.ts +103 -0
- package/dist/catalog/sync.d.ts.map +1 -0
- package/dist/catalog/sync.js +114 -0
- package/dist/catalog/sync.js.map +1 -0
- package/dist/catalog/tree.d.ts +119 -0
- package/dist/catalog/tree.d.ts.map +1 -0
- package/dist/catalog/tree.js +166 -0
- package/dist/catalog/tree.js.map +1 -0
- package/dist/default/CatalogPage.d.ts +16 -0
- package/dist/default/CatalogPage.d.ts.map +1 -0
- package/dist/default/CatalogPage.js +13 -0
- package/dist/default/CatalogPage.js.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts +20 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.js +35 -0
- package/dist/default/CategoryBreadcrumbsBar.js.map +1 -0
- package/dist/default/CategoryCarouselStrip.d.ts +22 -0
- package/dist/default/CategoryCarouselStrip.d.ts.map +1 -0
- package/dist/default/CategoryCarouselStrip.js +24 -0
- package/dist/default/CategoryCarouselStrip.js.map +1 -0
- package/dist/default/CategoryFeatureList.d.ts +23 -0
- package/dist/default/CategoryFeatureList.d.ts.map +1 -0
- package/dist/default/CategoryFeatureList.js +28 -0
- package/dist/default/CategoryFeatureList.js.map +1 -0
- package/dist/default/CategoryPage.d.ts +43 -0
- package/dist/default/CategoryPage.d.ts.map +1 -0
- package/dist/default/CategoryPage.js +21 -0
- package/dist/default/CategoryPage.js.map +1 -0
- package/dist/default/CategoryPickerField.d.ts +26 -0
- package/dist/default/CategoryPickerField.d.ts.map +1 -0
- package/dist/default/CategoryPickerField.js +40 -0
- package/dist/default/CategoryPickerField.js.map +1 -0
- package/dist/default/CategoryTreePane.d.ts +28 -0
- package/dist/default/CategoryTreePane.d.ts.map +1 -0
- package/dist/default/CategoryTreePane.js +25 -0
- package/dist/default/CategoryTreePane.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/index.d.ts +37 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +28 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +25 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +40 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/CategoriesProvider.d.ts +19 -0
- package/dist/headless/CategoriesProvider.d.ts.map +1 -0
- package/dist/headless/CategoriesProvider.js +15 -0
- package/dist/headless/CategoriesProvider.js.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts +40 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.js +42 -0
- package/dist/headless/CategoryBreadcrumbs.js.map +1 -0
- package/dist/headless/CategoryCarousel.d.ts +48 -0
- package/dist/headless/CategoryCarousel.d.ts.map +1 -0
- package/dist/headless/CategoryCarousel.js +34 -0
- package/dist/headless/CategoryCarousel.js.map +1 -0
- package/dist/headless/CategoryFeatures.d.ts +67 -0
- package/dist/headless/CategoryFeatures.d.ts.map +1 -0
- package/dist/headless/CategoryFeatures.js +46 -0
- package/dist/headless/CategoryFeatures.js.map +1 -0
- package/dist/headless/CategoryPicker.d.ts +69 -0
- package/dist/headless/CategoryPicker.d.ts.map +1 -0
- package/dist/headless/CategoryPicker.js +86 -0
- package/dist/headless/CategoryPicker.js.map +1 -0
- package/dist/headless/CategoryTree.d.ts +55 -0
- package/dist/headless/CategoryTree.d.ts.map +1 -0
- package/dist/headless/CategoryTree.js +51 -0
- package/dist/headless/CategoryTree.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +22 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +31 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +25 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +74 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +407 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +207 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +89 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +134 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +41 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +90 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +78 -0
- package/dist/index.js.map +1 -0
- package/dist/model/catalogStore.d.ts +23 -0
- package/dist/model/catalogStore.d.ts.map +1 -0
- package/dist/model/catalogStore.js +85 -0
- package/dist/model/catalogStore.js.map +1 -0
- package/dist/model/catalogSync.d.ts +49 -0
- package/dist/model/catalogSync.d.ts.map +1 -0
- package/dist/model/catalogSync.js +64 -0
- package/dist/model/catalogSync.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +124 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +147 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +30 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +16 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +31 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +31 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +37 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +125 -0
- package/manifest.json +918 -0
- package/nav-manifest.json +44 -0
- package/package.json +119 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/categoriesApi.ts +179 -0
- package/src/api/generated/schema.ts +2323 -0
- package/src/api/types.ts +99 -0
- package/src/catalog/labels.ts +129 -0
- package/src/catalog/pks.ts +35 -0
- package/src/catalog/sync.ts +169 -0
- package/src/catalog/tree.ts +242 -0
- package/src/default/CatalogPage.tsx +40 -0
- package/src/default/CategoryBreadcrumbsBar.tsx +93 -0
- package/src/default/CategoryCarouselStrip.tsx +102 -0
- package/src/default/CategoryFeatureList.tsx +125 -0
- package/src/default/CategoryPage.tsx +114 -0
- package/src/default/CategoryPickerField.tsx +173 -0
- package/src/default/CategoryTreePane.tsx +134 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/index.ts +36 -0
- package/src/default/theme.tsx +67 -0
- package/src/default/types.ts +12 -0
- package/src/headless/CategoriesProvider.tsx +20 -0
- package/src/headless/CategoryBreadcrumbs.tsx +78 -0
- package/src/headless/CategoryCarousel.tsx +80 -0
- package/src/headless/CategoryFeatures.tsx +100 -0
- package/src/headless/CategoryPicker.tsx +173 -0
- package/src/headless/CategoryTree.tsx +115 -0
- package/src/i18n/errorsMap.ts +46 -0
- package/src/i18n/es.ts +92 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +238 -0
- package/src/i18n/generated/errors.json +495 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +156 -0
- package/src/i18n/ru.ts +110 -0
- package/src/index.ts +191 -0
- package/src/model/catalogStore.ts +102 -0
- package/src/model/catalogSync.ts +115 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +226 -0
- package/src/model/queryKeys.ts +50 -0
- package/src/model/runtime.ts +39 -0
- package/src/nav/manifest.ts +66 -0
- package/tsconfig.json +26 -0
package/src/i18n/keys.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { I18nDictionary, I18nEngine } from "@stapel/core";
|
|
2
|
+
import { categoriesErrorBundleEn } from "./generated/errors.gen.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* categories-react's own translation KEYS (frontend-standard §4.2): headless
|
|
6
|
+
* components never render literal strings — hosts resolve these via core's
|
|
7
|
+
* i18n engine (`useT`). Backend error codes flow through the SAME contour: a
|
|
8
|
+
* `StapelApiError.code` is already a key, so the default bundle below ships
|
|
9
|
+
* English fallbacks for the 62 backend error codes (generated) plus the pair's
|
|
10
|
+
* own UI keys. All UI keys live under the `categories.` namespace.
|
|
11
|
+
*
|
|
12
|
+
* ── WHAT IS DELIBERATELY NOT IN THIS FILE ──────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* **A category name.** `category.electronics` is a key, and it is a key this
|
|
15
|
+
* library must never carry a text for: the catalogue is a deployment's
|
|
16
|
+
* content, not a library's chrome, and no two marketplaces have the same tree.
|
|
17
|
+
* `catalog/labels.ts` explains the mechanism in full — names arrive as keys
|
|
18
|
+
* because no serializer runs the module's `DISPLAY_TRANSLATOR` seam — and the
|
|
19
|
+
* pair's answer is to hand the key to the HOST's engine, whose bundle is where
|
|
20
|
+
* a deployment's catalogue copy belongs. When the key does not resolve the key
|
|
21
|
+
* is shown, on purpose: a visible `category.electronics` gets fixed, while a
|
|
22
|
+
* prettified "Electronics" ships for a year in the wrong language.
|
|
23
|
+
*
|
|
24
|
+
* The same goes for a feature's name and its option labels.
|
|
25
|
+
*
|
|
26
|
+
* **The twelve `error.400.feature_*` / `description_too_*` keys.** They are
|
|
27
|
+
* owned by `stapel_attributes` and translated by `@stapel/attributes-react`
|
|
28
|
+
* (`/i18n/ru`, `/i18n/es`) — the package that also draws and validates those
|
|
29
|
+
* values. Restating them here would give one refusal two sentences, which is
|
|
30
|
+
* the exact thing §13.2's note 3 closed. A host registers both bundles; the
|
|
31
|
+
* suite proves the union covers the whole registry.
|
|
32
|
+
*/
|
|
33
|
+
export const CATEGORIES_I18N_KEYS = {
|
|
34
|
+
unknownError: "categories.error.unknown",
|
|
35
|
+
|
|
36
|
+
// ── catalog / tree ───────────────────────────────────────────────────────
|
|
37
|
+
catalogTitle: "categories.catalog.title",
|
|
38
|
+
catalogLoading: "categories.catalog.loading",
|
|
39
|
+
catalogLoadFailed: "categories.catalog.load_failed",
|
|
40
|
+
catalogEmpty: "categories.catalog.empty",
|
|
41
|
+
catalogRetry: "categories.catalog.retry",
|
|
42
|
+
/** The sync walk hit its page budget — the tree on screen is incomplete. */
|
|
43
|
+
catalogTruncated: "categories.catalog.truncated",
|
|
44
|
+
catalogRefreshing: "categories.catalog.refreshing",
|
|
45
|
+
catalogAsOf: "categories.catalog.as_of",
|
|
46
|
+
|
|
47
|
+
// ── one category ─────────────────────────────────────────────────────────
|
|
48
|
+
categoryTitle: "categories.category.title",
|
|
49
|
+
categoryUnknownSlug: "categories.category.unknown_slug",
|
|
50
|
+
categorySubcategories: "categories.category.subcategories",
|
|
51
|
+
/** A leaf: no sub-categories, and that is not an error. */
|
|
52
|
+
categoryNoSubcategories: "categories.category.no_subcategories",
|
|
53
|
+
categoryOpen: "categories.category.open",
|
|
54
|
+
|
|
55
|
+
// ── breadcrumbs ──────────────────────────────────────────────────────────
|
|
56
|
+
breadcrumbsRoot: "categories.breadcrumbs.root",
|
|
57
|
+
breadcrumbsLabel: "categories.breadcrumbs.label",
|
|
58
|
+
|
|
59
|
+
// ── carousel ─────────────────────────────────────────────────────────────
|
|
60
|
+
carouselTitle: "categories.carousel.title",
|
|
61
|
+
carouselLoading: "categories.carousel.loading",
|
|
62
|
+
carouselLoadFailed: "categories.carousel.load_failed",
|
|
63
|
+
carouselEmpty: "categories.carousel.empty",
|
|
64
|
+
|
|
65
|
+
// ── picker (the compose form's chooser) ──────────────────────────────────
|
|
66
|
+
pickerTitle: "categories.picker.title",
|
|
67
|
+
pickerSearch: "categories.picker.search",
|
|
68
|
+
pickerLoading: "categories.picker.loading",
|
|
69
|
+
pickerLoadFailed: "categories.picker.load_failed",
|
|
70
|
+
pickerNoMatches: "categories.picker.no_matches",
|
|
71
|
+
pickerUp: "categories.picker.up",
|
|
72
|
+
pickerSelected: "categories.picker.selected",
|
|
73
|
+
pickerBlockedNothingSelected: "categories.picker.blocked.nothing_selected",
|
|
74
|
+
pickerBlockedNotALeaf: "categories.picker.blocked.not_a_leaf",
|
|
75
|
+
|
|
76
|
+
// ── feature schema ───────────────────────────────────────────────────────
|
|
77
|
+
featuresTitle: "categories.features.title",
|
|
78
|
+
featuresLoading: "categories.features.loading",
|
|
79
|
+
featuresLoadFailed: "categories.features.load_failed",
|
|
80
|
+
featuresEmpty: "categories.features.empty",
|
|
81
|
+
featuresMandatory: "categories.features.mandatory",
|
|
82
|
+
featuresType: "categories.features.type",
|
|
83
|
+
/** A feature whose `config` carries no `type` at all. */
|
|
84
|
+
featuresUntyped: "categories.features.untyped",
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
export type CategoriesI18nKey =
|
|
88
|
+
(typeof CATEGORIES_I18N_KEYS)[keyof typeof CATEGORIES_I18N_KEYS];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* English fallback bundle for categories-react UI keys + backend error codes.
|
|
92
|
+
* The generated backend texts are spread FIRST so coverage of the error
|
|
93
|
+
* registry is by construction; the pair's own copy follows.
|
|
94
|
+
*/
|
|
95
|
+
export const categoriesI18nBundleEn: Record<string, string> = {
|
|
96
|
+
...categoriesErrorBundleEn,
|
|
97
|
+
|
|
98
|
+
"categories.error.unknown": "Something went wrong with the catalogue",
|
|
99
|
+
|
|
100
|
+
"categories.catalog.title": "Catalogue",
|
|
101
|
+
"categories.catalog.loading": "Loading the catalogue…",
|
|
102
|
+
"categories.catalog.load_failed": "We could not load the catalogue",
|
|
103
|
+
"categories.catalog.empty": "This catalogue has no categories yet",
|
|
104
|
+
"categories.catalog.retry": "Try again",
|
|
105
|
+
"categories.catalog.truncated":
|
|
106
|
+
"Only part of the catalogue was loaded. A category missing here has not been read yet — it has not been removed.",
|
|
107
|
+
"categories.catalog.refreshing": "Checking for catalogue changes…",
|
|
108
|
+
"categories.catalog.as_of": "Catalogue as of revision {revision}",
|
|
109
|
+
|
|
110
|
+
"categories.category.title": "Category",
|
|
111
|
+
"categories.category.unknown_slug":
|
|
112
|
+
"There is no category at this address",
|
|
113
|
+
"categories.category.subcategories": "Subcategories",
|
|
114
|
+
"categories.category.no_subcategories": "This category has no subcategories",
|
|
115
|
+
"categories.category.open": "Open",
|
|
116
|
+
|
|
117
|
+
"categories.breadcrumbs.root": "All categories",
|
|
118
|
+
"categories.breadcrumbs.label": "You are here",
|
|
119
|
+
|
|
120
|
+
"categories.carousel.title": "Browse by category",
|
|
121
|
+
"categories.carousel.loading": "Loading categories…",
|
|
122
|
+
"categories.carousel.load_failed": "We could not load the categories",
|
|
123
|
+
"categories.carousel.empty": "No categories are featured right now",
|
|
124
|
+
|
|
125
|
+
"categories.picker.title": "Category",
|
|
126
|
+
"categories.picker.search": "Search categories",
|
|
127
|
+
"categories.picker.loading": "Loading categories…",
|
|
128
|
+
"categories.picker.load_failed": "We could not load the categories",
|
|
129
|
+
"categories.picker.no_matches": "No category matches that",
|
|
130
|
+
"categories.picker.up": "Up one level",
|
|
131
|
+
"categories.picker.selected": "Selected: {category}",
|
|
132
|
+
"categories.picker.blocked.nothing_selected": "Choose a category first",
|
|
133
|
+
"categories.picker.blocked.not_a_leaf":
|
|
134
|
+
"Choose a more specific category — this one has subcategories, and the details asked for depend on which",
|
|
135
|
+
|
|
136
|
+
"categories.features.title": "Details in this category",
|
|
137
|
+
"categories.features.loading": "Loading the details…",
|
|
138
|
+
"categories.features.load_failed": "We could not load the details",
|
|
139
|
+
"categories.features.empty": "This category asks for no extra details",
|
|
140
|
+
"categories.features.mandatory": "Required",
|
|
141
|
+
"categories.features.type": "{type}",
|
|
142
|
+
"categories.features.untyped": "This detail has no type and cannot be shown",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Register the English bundle into a core i18n engine. Locale bundles ship as
|
|
147
|
+
* opt-in subpaths (`@stapel/categories-react/i18n/ru`, `…/es`) so a host that
|
|
148
|
+
* needs only English never carries them.
|
|
149
|
+
*
|
|
150
|
+
* A host that renders a category's feature schema also registers
|
|
151
|
+
* `@stapel/attributes-react`'s bundles — that package owns the twelve
|
|
152
|
+
* `stapel_attributes` error keys in every locale.
|
|
153
|
+
*/
|
|
154
|
+
export function registerCategoriesI18n(i18n: I18nEngine): void {
|
|
155
|
+
i18n.registerBundle("en", categoriesI18nBundleEn as I18nDictionary);
|
|
156
|
+
}
|
package/src/i18n/ru.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { I18nDictionary, I18nEngine } from "@stapel/core";
|
|
2
|
+
import { categoriesErrorBundleRu } from "./generated/errors.ru.gen.js";
|
|
3
|
+
|
|
4
|
+
export { categoriesErrorBundleRu } from "./generated/errors.ru.gen.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Russian bundle for categories-react — the `@stapel/categories-react/i18n/ru`
|
|
8
|
+
* subpath (i18n-shipping.md §2) so the locale is opt-in: hosts that do not
|
|
9
|
+
* register it never carry these strings (the main entry does not import this
|
|
10
|
+
* module — gated by size-limit + the bundle-purity test).
|
|
11
|
+
*
|
|
12
|
+
* This is the storefront's DEFAULT language (storefront spec verdict F1:
|
|
13
|
+
* ru-first).
|
|
14
|
+
*
|
|
15
|
+
* ── What comes from where ──────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* `stapel-categories` ships **no `translations/` directory at all**, so 20 of
|
|
18
|
+
* the 62 registry codes can never come from an upstream catalogue. They split
|
|
19
|
+
* by owner, and the split is the whole point:
|
|
20
|
+
*
|
|
21
|
+
* - the 42 cross-cutting `stapel_core` codes are GENERATED, merged in from
|
|
22
|
+
* stapel-core's own catalogue by `pnpm gen:errors` (spread first below);
|
|
23
|
+
* - the **8 `stapel_categories` codes** are authored here — and when upstream
|
|
24
|
+
* ships a catalogue, eight lines are deleted and no key or text moves;
|
|
25
|
+
* - the **12 `stapel_attributes` codes** are NOT here. They belong to
|
|
26
|
+
* `@stapel/attributes-react`, which already translates them and which is a
|
|
27
|
+
* peer of this pair anyway. Two pairs must not give one refusal two
|
|
28
|
+
* sentences (§13.2 note 3). A host registers both bundles;
|
|
29
|
+
* `test/i18n.test.ts` proves the union covers the registry.
|
|
30
|
+
*
|
|
31
|
+
* PROVENANCE, stated rather than implied: the core catalogue ships
|
|
32
|
+
* `origin=seed:authored` and is UNREVIEWED; the pair-authored strings below
|
|
33
|
+
* are the same grade. Neither is a claim of review.
|
|
34
|
+
*
|
|
35
|
+
* And once more, because it is the fact this pair exists to be honest about:
|
|
36
|
+
* **no category name is translated here.** Category and feature names arrive
|
|
37
|
+
* as translation KEYS (`catalog/labels.ts`) and belong in the deployment's own
|
|
38
|
+
* bundle, not in a library's.
|
|
39
|
+
*/
|
|
40
|
+
export const categoriesI18nBundleRu: I18nDictionary = {
|
|
41
|
+
...categoriesErrorBundleRu,
|
|
42
|
+
|
|
43
|
+
// ── the 8 stapel_categories-owned codes, pair-authored ───────────────────
|
|
44
|
+
"error.400.categories_config_required": "Требуется объект config.",
|
|
45
|
+
"error.400.categories_database_error":
|
|
46
|
+
"Ошибка базы данных при сохранении изменений.",
|
|
47
|
+
"error.400.categories_duplicate_slug":
|
|
48
|
+
"Характеристика со слагом «{slug}» уже существует.",
|
|
49
|
+
"error.400.categories_expected_list": "Ожидался список объектов.",
|
|
50
|
+
"error.400.categories_feature_editor_invalid":
|
|
51
|
+
"Некорректный запрос редактора характеристик: {reason}",
|
|
52
|
+
"error.400.categories_invalid_conversion":
|
|
53
|
+
"Недопустимое преобразование типа (поддерживается только select ↔ string).",
|
|
54
|
+
"error.400.categories_not_deleted": "Категория не удалена.",
|
|
55
|
+
"error.409.categories_feature_editor_conflict":
|
|
56
|
+
"Категорию изменил другой редактор (ожидалась ревизия {expected}, сейчас {actual}); перезагрузите страницу и повторите.",
|
|
57
|
+
|
|
58
|
+
"categories.error.unknown": "Что-то пошло не так с каталогом.",
|
|
59
|
+
|
|
60
|
+
"categories.catalog.title": "Каталог",
|
|
61
|
+
"categories.catalog.loading": "Загружаем каталог…",
|
|
62
|
+
"categories.catalog.load_failed": "Не удалось загрузить каталог.",
|
|
63
|
+
"categories.catalog.empty": "В каталоге пока нет категорий.",
|
|
64
|
+
"categories.catalog.retry": "Повторить",
|
|
65
|
+
"categories.catalog.truncated":
|
|
66
|
+
"Каталог загружен не полностью. Категории, которой здесь нет, ещё не прочитали — её не удаляли.",
|
|
67
|
+
"categories.catalog.refreshing": "Проверяем изменения в каталоге…",
|
|
68
|
+
"categories.catalog.as_of": "Каталог на ревизию {revision}",
|
|
69
|
+
|
|
70
|
+
"categories.category.title": "Категория",
|
|
71
|
+
"categories.category.unknown_slug": "По этому адресу нет категории.",
|
|
72
|
+
"categories.category.subcategories": "Подкатегории",
|
|
73
|
+
"categories.category.no_subcategories": "У этой категории нет подкатегорий.",
|
|
74
|
+
"categories.category.open": "Открыть",
|
|
75
|
+
|
|
76
|
+
"categories.breadcrumbs.root": "Все категории",
|
|
77
|
+
"categories.breadcrumbs.label": "Вы здесь",
|
|
78
|
+
|
|
79
|
+
"categories.carousel.title": "Категории",
|
|
80
|
+
"categories.carousel.loading": "Загружаем категории…",
|
|
81
|
+
"categories.carousel.load_failed": "Не удалось загрузить категории.",
|
|
82
|
+
"categories.carousel.empty": "Сейчас ни одна категория не вынесена на витрину.",
|
|
83
|
+
|
|
84
|
+
"categories.picker.title": "Категория",
|
|
85
|
+
"categories.picker.search": "Поиск по категориям",
|
|
86
|
+
"categories.picker.loading": "Загружаем категории…",
|
|
87
|
+
"categories.picker.load_failed": "Не удалось загрузить категории.",
|
|
88
|
+
"categories.picker.no_matches": "Ни одна категория не подходит.",
|
|
89
|
+
"categories.picker.up": "На уровень выше",
|
|
90
|
+
"categories.picker.selected": "Выбрано: {category}",
|
|
91
|
+
"categories.picker.blocked.nothing_selected": "Сначала выберите категорию.",
|
|
92
|
+
"categories.picker.blocked.not_a_leaf":
|
|
93
|
+
"Выберите категорию точнее — у этой есть подкатегории, а от них зависит, какие характеристики спросят.",
|
|
94
|
+
|
|
95
|
+
"categories.features.title": "Характеристики в этой категории",
|
|
96
|
+
"categories.features.loading": "Загружаем характеристики…",
|
|
97
|
+
"categories.features.load_failed": "Не удалось загрузить характеристики.",
|
|
98
|
+
"categories.features.empty":
|
|
99
|
+
"В этой категории не нужно заполнять дополнительные характеристики.",
|
|
100
|
+
"categories.features.mandatory": "Обязательно",
|
|
101
|
+
"categories.features.type": "{type}",
|
|
102
|
+
"categories.features.untyped":
|
|
103
|
+
"У этой характеристики нет типа, показать её нельзя.",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Register the `ru` bundle. Call AFTER `registerCategoriesI18n` so it
|
|
107
|
+
* overrides the English floor. */
|
|
108
|
+
export function registerCategoriesI18nRu(engine: I18nEngine, locale = "ru"): void {
|
|
109
|
+
engine.registerBundle(locale, categoriesI18nBundleRu);
|
|
110
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@stapel/categories-react` — the headless React pair for stapel-categories
|
|
3
|
+
* (frontend-standard §2). Business + state only, zero visual opinion; the antd
|
|
4
|
+
* skin lives behind the `./default` subpath, so a host that renders its own
|
|
5
|
+
* catalogue chrome never carries it.
|
|
6
|
+
*
|
|
7
|
+
* ── The one-liner ──────────────────────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const runtime = createCategoriesRuntime({ baseUrl: "/categories/api/v1/" });
|
|
11
|
+
* <CategoriesProvider runtime={runtime}>
|
|
12
|
+
* <CatalogPage /> // or <CategoryPage slug={slug} renderListings={…} />
|
|
13
|
+
* </CategoriesProvider>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* No session, no workspace id, no auth client: every endpoint this pair calls
|
|
17
|
+
* is a SAFE method under `ReadOnlyOrStaff`, so a catalogue renders for a
|
|
18
|
+
* visitor who will never sign in.
|
|
19
|
+
*
|
|
20
|
+
* ── The four properties this pair exists to guarantee ──────────────────────
|
|
21
|
+
*
|
|
22
|
+
* 1. **The client builds the tree, because the server has none.**
|
|
23
|
+
* `GET /categories/` returns FLAT rows ordered by `revision`, with treenode
|
|
24
|
+
* ancestry as COMMA-JOINED pk strings (`"1,7,12"`, `""` for a root).
|
|
25
|
+
* `catalog/tree.ts` parses and assembles; `catalog/pks.ts` is the parser,
|
|
26
|
+
* and it exists because the spec described those columns as arrays.
|
|
27
|
+
* 2. **The catalogue is synced by delta, not refetched.** A stored snapshot
|
|
28
|
+
* (`createRepository`, `scope: "app"`) plus `?min_revision=` is the module's
|
|
29
|
+
* own documented protocol; without it every storefront page pulls the whole
|
|
30
|
+
* catalogue. Two things the documentation does not say are implemented
|
|
31
|
+
* anyway and explained where they live: `revisions.deleted_ids` is the
|
|
32
|
+
* complete tombstone channel (the `deleted: true` rows are paginated and a
|
|
33
|
+
* short walk misses them), and a multi-page walk must pin `max_revision` or
|
|
34
|
+
* a concurrent write shifts its page boundaries.
|
|
35
|
+
* 3. **A slug is resolved by the client, because the server cannot.**
|
|
36
|
+
* `lookup_field` is never overridden and the list endpoint has no slug
|
|
37
|
+
* filter, so `/c/:slug` resolves against the synced tree. That is a second,
|
|
38
|
+
* independent reason the tree is cached.
|
|
39
|
+
* 4. **Names are translation KEYS, and this pair says so.** No serializer runs
|
|
40
|
+
* the module's `DISPLAY_TRANSLATOR` seam, so `name` arrives as
|
|
41
|
+
* `category.electronics` even on a deployment with a real translator
|
|
42
|
+
* configured, and `translatable` / `translate` say per row whether a string
|
|
43
|
+
* is a key at all. `catalog/labels.ts` carries the whole answer, and the
|
|
44
|
+
* pair ships no catalogue of category names — those belong to the host.
|
|
45
|
+
*
|
|
46
|
+
* Layers: api → catalog (pure) → model → headless → i18n. Generated surfaces
|
|
47
|
+
* (the typed schema, the error map, the manifest, the nav manifest, llms.txt)
|
|
48
|
+
* are produced by the monorepo `gen:*` drivers from stapel-categories' own
|
|
49
|
+
* `docs/` artifacts and stand under drift gates.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
// ── api ──────────────────────────────────────────────────────────────────────
|
|
53
|
+
export { categoryListParams, createCategoriesApi } from "./api/categoriesApi.js";
|
|
54
|
+
export type { CategoriesApi } from "./api/categoriesApi.js";
|
|
55
|
+
export type {
|
|
56
|
+
Category,
|
|
57
|
+
CategoryFeature,
|
|
58
|
+
CategoryListParams,
|
|
59
|
+
CategoryPage as CategoryListPage,
|
|
60
|
+
CategoryRevisions,
|
|
61
|
+
FeatureConfig,
|
|
62
|
+
MaxRevision,
|
|
63
|
+
Schemas,
|
|
64
|
+
} from "./api/types.js";
|
|
65
|
+
|
|
66
|
+
// ── catalog (pure: no React, no fetch, no storage) ───────────────────────────
|
|
67
|
+
export { parseTreenodePks } from "./catalog/pks.js";
|
|
68
|
+
export {
|
|
69
|
+
buildCategoryTree,
|
|
70
|
+
categoryAncestorIds,
|
|
71
|
+
categoryBreadcrumbs,
|
|
72
|
+
categoryChildIds,
|
|
73
|
+
flattenCategoryNodes,
|
|
74
|
+
resolveCategorySlug,
|
|
75
|
+
} from "./catalog/tree.js";
|
|
76
|
+
export type {
|
|
77
|
+
BuildCategoryTreeOptions,
|
|
78
|
+
CategoryIndex,
|
|
79
|
+
CategoryNode,
|
|
80
|
+
} from "./catalog/tree.js";
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
EMPTY_SNAPSHOT,
|
|
84
|
+
applyCategoryPage,
|
|
85
|
+
firstPageRequest,
|
|
86
|
+
isEmptySnapshot,
|
|
87
|
+
nextPageRequest,
|
|
88
|
+
parseSnapshot,
|
|
89
|
+
} from "./catalog/sync.js";
|
|
90
|
+
export type { CategorySnapshot } from "./catalog/sync.js";
|
|
91
|
+
|
|
92
|
+
export {
|
|
93
|
+
categoryLabel,
|
|
94
|
+
featureLabel,
|
|
95
|
+
featureOptionsAreKeys,
|
|
96
|
+
renderCategoryLabel,
|
|
97
|
+
} from "./catalog/labels.js";
|
|
98
|
+
export type { CategoryLabel, CategoryLabelKind } from "./catalog/labels.js";
|
|
99
|
+
|
|
100
|
+
// ── model (runtime wiring, persistence, sync driver, hooks) ──────────────────
|
|
101
|
+
export { createCategoriesRuntime } from "./model/runtime.js";
|
|
102
|
+
export type {
|
|
103
|
+
CategoriesRuntime,
|
|
104
|
+
CreateCategoriesRuntimeOptions,
|
|
105
|
+
} from "./model/runtime.js";
|
|
106
|
+
export {
|
|
107
|
+
CategoriesRuntimeContext,
|
|
108
|
+
useCategoriesAnalytics,
|
|
109
|
+
useCategoriesApi,
|
|
110
|
+
useCategoriesRuntime,
|
|
111
|
+
} from "./model/context.js";
|
|
112
|
+
export {
|
|
113
|
+
CATALOG_KEY,
|
|
114
|
+
CATALOG_NAMESPACE,
|
|
115
|
+
createCatalogStore,
|
|
116
|
+
memoryCatalogStore,
|
|
117
|
+
} from "./model/catalogStore.js";
|
|
118
|
+
export type {
|
|
119
|
+
CatalogStore,
|
|
120
|
+
CreateCatalogStoreOptions,
|
|
121
|
+
} from "./model/catalogStore.js";
|
|
122
|
+
export { MAX_SYNC_PAGES, syncCatalog } from "./model/catalogSync.js";
|
|
123
|
+
export type { SyncCatalogOptions, SyncCatalogResult } from "./model/catalogSync.js";
|
|
124
|
+
export { catalogKeyOptions, categoriesQueryKeys } from "./model/queryKeys.js";
|
|
125
|
+
export type { CatalogKeyOptions } from "./model/queryKeys.js";
|
|
126
|
+
export {
|
|
127
|
+
useCategoriesRevision,
|
|
128
|
+
useCategoryCarousel,
|
|
129
|
+
useCategoryCatalog,
|
|
130
|
+
useCategoryChildren,
|
|
131
|
+
useCategoryFeatures,
|
|
132
|
+
} from "./model/queries.js";
|
|
133
|
+
export type {
|
|
134
|
+
CategoryCatalog,
|
|
135
|
+
UseCategoryCatalogOptions,
|
|
136
|
+
} from "./model/queries.js";
|
|
137
|
+
|
|
138
|
+
// ── headless (renderless components) ─────────────────────────────────────────
|
|
139
|
+
export { CategoriesProvider } from "./headless/CategoriesProvider.js";
|
|
140
|
+
export { CategoryTree } from "./headless/CategoryTree.js";
|
|
141
|
+
export type { CategoryTreeBag, CategoryTreeProps } from "./headless/CategoryTree.js";
|
|
142
|
+
export { CategoryBreadcrumbs } from "./headless/CategoryBreadcrumbs.js";
|
|
143
|
+
export type {
|
|
144
|
+
CategoryBreadcrumbsBag,
|
|
145
|
+
CategoryBreadcrumbsProps,
|
|
146
|
+
CategoryCrumb,
|
|
147
|
+
} from "./headless/CategoryBreadcrumbs.js";
|
|
148
|
+
export { CategoryCarousel } from "./headless/CategoryCarousel.js";
|
|
149
|
+
export type {
|
|
150
|
+
CarouselEntry,
|
|
151
|
+
CategoryCarouselBag,
|
|
152
|
+
CategoryCarouselProps,
|
|
153
|
+
} from "./headless/CategoryCarousel.js";
|
|
154
|
+
export { CategoryPicker } from "./headless/CategoryPicker.js";
|
|
155
|
+
export type {
|
|
156
|
+
CategoryOption,
|
|
157
|
+
CategoryPickerBag,
|
|
158
|
+
CategoryPickerBlockedReason,
|
|
159
|
+
CategoryPickerProps,
|
|
160
|
+
} from "./headless/CategoryPicker.js";
|
|
161
|
+
export { CategoryFeatures } from "./headless/CategoryFeatures.js";
|
|
162
|
+
export type {
|
|
163
|
+
CategoryFeatureEntry,
|
|
164
|
+
CategoryFeaturesBag,
|
|
165
|
+
CategoryFeaturesProps,
|
|
166
|
+
} from "./headless/CategoryFeatures.js";
|
|
167
|
+
|
|
168
|
+
// ── nav manifest (the pair's public surface declaration) ─────────────────────
|
|
169
|
+
export { navEntries } from "./nav/manifest.js";
|
|
170
|
+
|
|
171
|
+
// ── i18n ─────────────────────────────────────────────────────────────────────
|
|
172
|
+
export {
|
|
173
|
+
CATEGORIES_I18N_KEYS,
|
|
174
|
+
categoriesI18nBundleEn,
|
|
175
|
+
registerCategoriesI18n,
|
|
176
|
+
} from "./i18n/keys.js";
|
|
177
|
+
export type { CategoriesI18nKey } from "./i18n/keys.js";
|
|
178
|
+
|
|
179
|
+
// ── errors map (code → status/params/remediation/en; generated) ──────────────
|
|
180
|
+
export {
|
|
181
|
+
CATEGORIES_ERRORS,
|
|
182
|
+
CATEGORIES_ERROR_CODES,
|
|
183
|
+
CATEGORIES_FEATURE_EDITOR_CONFLICT,
|
|
184
|
+
categoriesErrorBundleEn,
|
|
185
|
+
explainCategoriesError,
|
|
186
|
+
} from "./i18n/errorsMap.js";
|
|
187
|
+
export type {
|
|
188
|
+
CategoriesErrorCode,
|
|
189
|
+
CategoriesErrorSpec,
|
|
190
|
+
Remediation,
|
|
191
|
+
} from "./i18n/errorsMap.js";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the catalogue lives between page loads.
|
|
3
|
+
*
|
|
4
|
+
* `createRepository` is the ONE sanctioned client-side persistence primitive
|
|
5
|
+
* (`@stapel/core` `repository.ts`; direct `localStorage` outside it is a lint
|
|
6
|
+
* error, `stapel/no-raw-storage`), and the spec's verdict (§4.3) puts the tree
|
|
7
|
+
* in it with `scope: "app"`.
|
|
8
|
+
*
|
|
9
|
+
* **`scope: "app"` is a correctness choice, not a size one.** A user-scoped
|
|
10
|
+
* repository is encrypted with the per-session key and wiped on logout, with
|
|
11
|
+
* no opt-out — both of which are wrong here twice over: the category tree is
|
|
12
|
+
* a deployment's PUBLIC content, identical for every visitor and for a visitor
|
|
13
|
+
* with no session at all, and wiping it at logout would re-download the whole
|
|
14
|
+
* catalogue for somebody who just signed out of a storefront they are still
|
|
15
|
+
* browsing. App scope also means `createRepository` never asks for a
|
|
16
|
+
* `SessionManager`, so the store works on a page with no auth wired at all.
|
|
17
|
+
*
|
|
18
|
+
* A repository read can fail or return junk (a private window, cleared site
|
|
19
|
+
* data, a snapshot written by an older shape). Every read here degrades to
|
|
20
|
+
* "no snapshot" — a cold sync costs one request; a cache that throws costs
|
|
21
|
+
* the page.
|
|
22
|
+
*/
|
|
23
|
+
import { createRepository } from "@stapel/core";
|
|
24
|
+
import type { Repository } from "@stapel/core";
|
|
25
|
+
import { EMPTY_SNAPSHOT, parseSnapshot } from "../catalog/sync.js";
|
|
26
|
+
import type { CategorySnapshot } from "../catalog/sync.js";
|
|
27
|
+
|
|
28
|
+
/** The repository namespace. Changing it orphans every stored snapshot, which
|
|
29
|
+
* is the intended migration for a shape change too large for `version`. */
|
|
30
|
+
export const CATALOG_NAMESPACE = "categories.catalog";
|
|
31
|
+
|
|
32
|
+
/** The single key inside that namespace. */
|
|
33
|
+
export const CATALOG_KEY = "snapshot";
|
|
34
|
+
|
|
35
|
+
/** Read / write the catalogue snapshot, with every failure mode absorbed. */
|
|
36
|
+
export interface CatalogStore {
|
|
37
|
+
load(): Promise<CategorySnapshot>;
|
|
38
|
+
save(snapshot: CategorySnapshot): Promise<void>;
|
|
39
|
+
clear(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateCatalogStoreOptions {
|
|
43
|
+
/** Inject a repository (tests, SSR, a host with its own backend). Default:
|
|
44
|
+
* an app-scoped repository under {@link CATALOG_NAMESPACE}. */
|
|
45
|
+
readonly repository?: Repository<unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A store that remembers nothing — the honest fallback when persistence is
|
|
49
|
+
* unavailable, and what a caller passes to opt out of caching entirely. */
|
|
50
|
+
export function memoryCatalogStore(): CatalogStore {
|
|
51
|
+
let held: CategorySnapshot = EMPTY_SNAPSHOT;
|
|
52
|
+
return {
|
|
53
|
+
load: () => Promise.resolve(held),
|
|
54
|
+
save: (snapshot) => {
|
|
55
|
+
held = snapshot;
|
|
56
|
+
return Promise.resolve();
|
|
57
|
+
},
|
|
58
|
+
clear: () => {
|
|
59
|
+
held = EMPTY_SNAPSHOT;
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createCatalogStore(
|
|
66
|
+
options: CreateCatalogStoreOptions = {}
|
|
67
|
+
): CatalogStore {
|
|
68
|
+
let repository: Repository<unknown>;
|
|
69
|
+
try {
|
|
70
|
+
repository =
|
|
71
|
+
options.repository ??
|
|
72
|
+
createRepository<unknown>(CATALOG_NAMESPACE, { scope: "app" });
|
|
73
|
+
} catch {
|
|
74
|
+
// No storage backend at all (an exotic runtime, a locked-down origin).
|
|
75
|
+
return memoryCatalogStore();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async load() {
|
|
80
|
+
try {
|
|
81
|
+
return parseSnapshot(await repository.get(CATALOG_KEY)) ?? EMPTY_SNAPSHOT;
|
|
82
|
+
} catch {
|
|
83
|
+
return EMPTY_SNAPSHOT;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async save(snapshot) {
|
|
87
|
+
try {
|
|
88
|
+
await repository.set(CATALOG_KEY, snapshot);
|
|
89
|
+
} catch {
|
|
90
|
+
// A full quota or a blocked origin must not fail the render that
|
|
91
|
+
// triggered the sync: the catalogue is already in memory and correct.
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
async clear() {
|
|
95
|
+
try {
|
|
96
|
+
await repository.del(CATALOG_KEY);
|
|
97
|
+
} catch {
|
|
98
|
+
/* nothing to undo */
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync walk: stored snapshot → one request or several → a fresh snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Pure of React, so the same routine serves the hook, an SSR prefetch and a
|
|
5
|
+
* test. The rules it enforces are all in `catalog/sync.ts`; this is the loop
|
|
6
|
+
* that applies them, plus the two guards a loop over a paginated endpoint
|
|
7
|
+
* needs:
|
|
8
|
+
*
|
|
9
|
+
* - a **page budget**, because `has_next` is server-reported and a bug there
|
|
10
|
+
* (or a catalogue growing faster than the walk reads it) is an infinite
|
|
11
|
+
* request loop against someone's production API, not a slow render; and
|
|
12
|
+
* - **publish-on-completion**, so a walk that dies on page 3 leaves the
|
|
13
|
+
* previous catalogue intact instead of replacing it with two thirds of one.
|
|
14
|
+
*/
|
|
15
|
+
import type { CategoriesApi } from "../api/categoriesApi.js";
|
|
16
|
+
import type { CategoryListParams } from "../api/types.js";
|
|
17
|
+
import {
|
|
18
|
+
EMPTY_SNAPSHOT,
|
|
19
|
+
applyCategoryPage,
|
|
20
|
+
firstPageRequest,
|
|
21
|
+
isEmptySnapshot,
|
|
22
|
+
nextPageRequest,
|
|
23
|
+
} from "../catalog/sync.js";
|
|
24
|
+
import type { CategorySnapshot } from "../catalog/sync.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hard ceiling on pages per sync. At the server's max `page_size` of 1000 this
|
|
28
|
+
* is a million categories — far beyond any real catalogue, and small enough
|
|
29
|
+
* that a runaway loop stops in seconds instead of hammering a backend.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_SYNC_PAGES = 1000;
|
|
32
|
+
|
|
33
|
+
export interface SyncCatalogOptions {
|
|
34
|
+
readonly signal?: AbortSignal;
|
|
35
|
+
/** Rows per request. Server default 100, maximum 1000. */
|
|
36
|
+
readonly pageSize?: number;
|
|
37
|
+
readonly maxPages?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SyncCatalogResult {
|
|
41
|
+
readonly snapshot: CategorySnapshot;
|
|
42
|
+
/** `true` when the walk started from nothing (a cold catalogue). */
|
|
43
|
+
readonly wasFullSync: boolean;
|
|
44
|
+
/** How many requests the walk actually made. `0` is impossible — the
|
|
45
|
+
* freshness probe is the caller's job, not this function's. */
|
|
46
|
+
readonly pages: number;
|
|
47
|
+
/** The page budget stopped the walk before `has_next` did. The snapshot is
|
|
48
|
+
* a truncated catalogue and the caller must NOT treat it as complete. */
|
|
49
|
+
readonly truncated: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bring `stored` up to date.
|
|
54
|
+
*
|
|
55
|
+
* A cold start walks the whole catalogue into a fresh accumulator; a warm
|
|
56
|
+
* start folds the delta into the stored rows. Which one happens is decided by
|
|
57
|
+
* `firstPageRequest` and by what the accumulator starts as — there is no flag
|
|
58
|
+
* that a caller could set the wrong way (see `catalog/sync.ts`).
|
|
59
|
+
*/
|
|
60
|
+
export async function syncCatalog(
|
|
61
|
+
api: CategoriesApi,
|
|
62
|
+
stored: CategorySnapshot,
|
|
63
|
+
options: SyncCatalogOptions = {}
|
|
64
|
+
): Promise<SyncCatalogResult> {
|
|
65
|
+
const wasFullSync = isEmptySnapshot(stored);
|
|
66
|
+
const budget = options.maxPages ?? MAX_SYNC_PAGES;
|
|
67
|
+
|
|
68
|
+
let request: CategoryListParams | undefined = {
|
|
69
|
+
...firstPageRequest(stored),
|
|
70
|
+
...(options.pageSize !== undefined ? { pageSize: options.pageSize } : {}),
|
|
71
|
+
};
|
|
72
|
+
// A full sync must not inherit rows the server is no longer listing, so it
|
|
73
|
+
// accumulates from empty. A delta folds into what is already known.
|
|
74
|
+
let accumulator: CategorySnapshot = wasFullSync
|
|
75
|
+
? EMPTY_SNAPSHOT
|
|
76
|
+
: { ...stored };
|
|
77
|
+
let pages = 0;
|
|
78
|
+
let truncated = false;
|
|
79
|
+
|
|
80
|
+
while (request !== undefined) {
|
|
81
|
+
if (pages >= budget) {
|
|
82
|
+
truncated = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const current: CategoryListParams = request;
|
|
86
|
+
const page = await api.list(current, {
|
|
87
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
88
|
+
});
|
|
89
|
+
pages += 1;
|
|
90
|
+
accumulator = applyCategoryPage(accumulator, page, {
|
|
91
|
+
// The walk pinned its window with `max_revision`; the cursor may not
|
|
92
|
+
// claim to have read past it.
|
|
93
|
+
...(current.maxRevision !== undefined
|
|
94
|
+
? { cursorLimit: current.maxRevision }
|
|
95
|
+
: {}),
|
|
96
|
+
});
|
|
97
|
+
request = nextPageRequest(current, page);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (truncated) {
|
|
101
|
+
// The rows gathered so far are still worth showing — a partial catalogue
|
|
102
|
+
// beats a blank menu — but the CURSOR must not advance, or the next sync
|
|
103
|
+
// asks for a delta on top of a catalogue that was never fully read and the
|
|
104
|
+
// gap becomes permanent. Rewinding it here means the worst case is one
|
|
105
|
+
// repeated full walk, not a silently incomplete tree forever.
|
|
106
|
+
return {
|
|
107
|
+
snapshot: { ...accumulator, cursor: stored.cursor },
|
|
108
|
+
wasFullSync,
|
|
109
|
+
pages,
|
|
110
|
+
truncated,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { snapshot: accumulator, wasFullSync, pages, truncated };
|
|
115
|
+
}
|