basalt-ui 1.20.0 → 1.22.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/AGENTS.md +11 -5
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +231 -50
- package/README.md +57 -10
- package/agent/rules/basalt-app.md +49 -2
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +78 -16
- package/agent/skills/basalt-app/SKILL.md +5 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +218 -69
- package/configs/oxlint-plugin.test.ts +331 -9
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/cli/index.d.ts +55 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +521 -71
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +114 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +216 -33
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +4 -2
- package/dist/vite.d.ts +28 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +978 -133
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +352 -1
- package/src/cli/waiver-audit.test.ts +211 -0
- package/src/guard/check-source.test.ts +638 -1
- package/src/guard/index.ts +556 -63
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +158 -6
- package/src/vite.ts +176 -34
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"waiver-audit.test.d.ts","sourceRoot":"","sources":["../../src/cli/waiver-audit.test.ts"],"names":[],"mappings":""}
|
package/dist/guard/index.d.ts
CHANGED
|
@@ -8,6 +8,37 @@ import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types.js'
|
|
|
8
8
|
export type { Finding, GuardConfig, GuardKind, GuardSeverity };
|
|
9
9
|
/** Shared default config — CLI and tests import this to avoid duplication. */
|
|
10
10
|
export declare const DEFAULT_GUARD_CONFIG: GuardConfig;
|
|
11
|
+
/** One `exemptRules` entry that suppressed nothing, and why — see {@link unmatchedExemptPatterns}. */
|
|
12
|
+
export type UnmatchedExemptPattern = {
|
|
13
|
+
readonly kind: string;
|
|
14
|
+
readonly pattern: string;
|
|
15
|
+
readonly reason: 'unknown-kind' | 'no-match';
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Every `exemptRules` entry that matched no scanned file, so a run can say so instead of passing.
|
|
19
|
+
*
|
|
20
|
+
* An exemption is a claim ("this rule does not apply here"), and a claim that resolves to nothing is
|
|
21
|
+
* not harmless — it reads as coverage in a config review while enforcing exactly as much as an empty
|
|
22
|
+
* object. rollhook's `["public/site.webmanifest"]` was correct, matched nothing under the
|
|
23
|
+
* segments-only matcher, and reported no diagnostic; the finding it was written for kept failing the
|
|
24
|
+
* build with the config sitting right there looking like the answer. A silent no-op exemption is the
|
|
25
|
+
* same false-green family as a guard that reports green while enforcing nothing.
|
|
26
|
+
*
|
|
27
|
+
* Called once per run by the CLI with every relative path it scanned. Kind keys are checked too: a
|
|
28
|
+
* typo'd kind name is an exemption for a rule that does not exist.
|
|
29
|
+
*/
|
|
30
|
+
export declare function unmatchedExemptPatterns(cfg: GuardConfig, scannedRelPaths: readonly string[]): UnmatchedExemptPattern[];
|
|
31
|
+
/**
|
|
32
|
+
* The oxlint plugin's rule ids. Not guard kinds — they can never suppress a `checkSource` finding —
|
|
33
|
+
* but they ARE valid ids in a `theme-allow`, so an annotation scoped to one of them must parse as
|
|
34
|
+
* accountable rather than be reported as unscoped. Kept as a literal list because the plugin is a
|
|
35
|
+
* standalone `.js` file this module must not import (see the plugin's own header).
|
|
36
|
+
*
|
|
37
|
+
* Exported for `check-theme --audit-allows`: an annotation naming one of these is outside the
|
|
38
|
+
* guard's reach, and judging it needs a `oxlint` run rather than a `checkSource` one. Keep in step
|
|
39
|
+
* with the plugin's own `KNOWN_RULE_IDS`; `configs/oxlint-plugin.test.ts` asserts the two agree.
|
|
40
|
+
*/
|
|
41
|
+
export declare const PLUGIN_RULE_IDS: ReadonlySet<string>;
|
|
11
42
|
/**
|
|
12
43
|
* The exact first line of every stylesheet the CLI emits — the contract between `basalt-ui
|
|
13
44
|
* tokens:css` / `fonts:css` and this scanner, and the single source of truth for it (`src/cli`
|
|
@@ -178,13 +209,13 @@ export declare const GUARD_RULES: {
|
|
|
178
209
|
readonly 'theme-allow-unscoped': {
|
|
179
210
|
readonly kind: "theme-allow-unscoped";
|
|
180
211
|
readonly pattern: RegExp;
|
|
181
|
-
readonly message: "theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> — <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver — it suppresses nothing.";
|
|
212
|
+
readonly message: "theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> — <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver — it suppresses nothing. `theme-allow-file <rule-id> — <why>` declares the whole file, and must name its ids: a bare one waives nothing.";
|
|
182
213
|
};
|
|
183
214
|
readonly 'surface-shadow-override': {
|
|
184
215
|
readonly kind: "surface-shadow-override";
|
|
185
216
|
readonly pattern: RegExp;
|
|
186
217
|
readonly enabled: (cfg: GuardConfig) => boolean;
|
|
187
|
-
readonly message: "Token-composed boxShadow that REPLACES
|
|
218
|
+
readonly message: "Token-composed boxShadow that REPLACES surface depth — a card whose shadow drops --vx-shadow-card is the one card in the app with no depth. Compose with the depth token for its tier (`${VX.shadowCard}, <your ring>`; a fixed/floating surface wants --vx-shadow-overlay) rather than instead of it.";
|
|
188
219
|
};
|
|
189
220
|
readonly 'css-raw-surface': {
|
|
190
221
|
readonly kind: "css-raw-surface";
|
|
@@ -206,6 +237,31 @@ export declare const GUARD_RULES: {
|
|
|
206
237
|
readonly message: "Raw font-size literal — route through VX.text.* (numbers) / --vx-text-* (CSS), or the Mantine fz token. Same rule the oxlint plugin enforces as basalt/no-raw-font-size; this is the half a check-theme-only CI can see.";
|
|
207
238
|
};
|
|
208
239
|
};
|
|
240
|
+
/**
|
|
241
|
+
* The one-line remedy for `kind`, as a report reads it: `raw-hex: Route color through VX.* / …`.
|
|
242
|
+
*
|
|
243
|
+
* The registry has always carried a remedy per kind; `check-theme`'s `Fix:` epilogue read a
|
|
244
|
+
* hand-duplicated subset instead, and the subset was missing every kind added at 1.20.0. Since all
|
|
245
|
+
* five of those ship `warn` under grace, the findings whose whole argument is "this looks correct
|
|
246
|
+
* and is not" arrived with no argument at all — just "add a `theme-allow`", which reads as advice to
|
|
247
|
+
* waive. Sourcing the epilogue from here retires the duplicate rather than extending it.
|
|
248
|
+
*/
|
|
249
|
+
export declare function guardKindRemedy(kind: GuardKind): string;
|
|
250
|
+
/**
|
|
251
|
+
* How to waive a finding **in this file's syntax** — the closer a report prints under `Fix:`.
|
|
252
|
+
*
|
|
253
|
+
* `check-theme` printed one closer for every file class, and from 1.20.0 (when the scan reached
|
|
254
|
+
* `.webmanifest` / `.json`) it prescribed a `theme-allow` comment to files that cannot hold a
|
|
255
|
+
* comment. Two consumers read that, found no route, and fell back to a blanket `exemptRules` entry —
|
|
256
|
+
* the un-reviewable shape the release was spent retiring.
|
|
257
|
+
*
|
|
258
|
+
* A manifest gets the sharper answer first, because a hex there can never be *right*: a manifest
|
|
259
|
+
* cannot reference a CSS custom property, so the value is a hand-copy of a token that drifts the day
|
|
260
|
+
* the palette moves (which is exactly what both consumers found — two dead colours). The remedy is
|
|
261
|
+
* to stop hand-writing the file: `basaltAppPlugin` emits it from `SURFACE.bg`. The annotation is the
|
|
262
|
+
* fallback for a manifest basalt does not generate.
|
|
263
|
+
*/
|
|
264
|
+
export declare function guardWaiverHint(relPath: string): string;
|
|
209
265
|
/**
|
|
210
266
|
* Scan ONE file's text for theme-guard violations. Pure: same (text, relPath, cfg) → same Finding[].
|
|
211
267
|
* No FS, no walk, no console. The 3 dynamic regexes (forbiddenAccent, spacing, radius) are derived
|
|
@@ -217,4 +273,60 @@ export declare const GUARD_RULES: {
|
|
|
217
273
|
* if (findings.some((f) => f.kind === 'raw-hex')) { ... }
|
|
218
274
|
*/
|
|
219
275
|
export declare function checkSource(text: string, relPath: string, cfg: GuardConfig): Finding[];
|
|
276
|
+
/**
|
|
277
|
+
* One `theme-allow` annotation as WRITTEN, classified by whose reach its ids fall in.
|
|
278
|
+
*
|
|
279
|
+
* `guardKinds` is what `checkSource` can judge by re-running itself with the annotation
|
|
280
|
+
* neutralized. `pluginRules` is what only an `oxlint` run can judge. `unknownRules` names no rule
|
|
281
|
+
* at all. All three empty with `bare: true` is the legacy blanket form.
|
|
282
|
+
*/
|
|
283
|
+
export type AllowAnnotationSite = {
|
|
284
|
+
/** 1-based line the annotation is written on. */
|
|
285
|
+
readonly line: number;
|
|
286
|
+
/** `'file'` for the `theme-allow-file` declaration form, `'line'` otherwise. */
|
|
287
|
+
readonly scope: 'line' | 'file';
|
|
288
|
+
/** Every id the annotation names that resolves to a real rule, `basalt/` prefix stripped. */
|
|
289
|
+
readonly rules: readonly string[];
|
|
290
|
+
/** The subset of `rules` `checkSource` owns. */
|
|
291
|
+
readonly guardKinds: readonly GuardKind[];
|
|
292
|
+
/** The subset of `rules` the oxlint plugin owns — outside `checkSource`'s reach. */
|
|
293
|
+
readonly pluginRules: readonly string[];
|
|
294
|
+
/** Words that occupied the id slot but name no rule. Their presence makes the parse fail closed. */
|
|
295
|
+
readonly unknownRules: readonly string[];
|
|
296
|
+
/** A written reason follows the ids. */
|
|
297
|
+
readonly hasReason: boolean;
|
|
298
|
+
/** The legacy blanket form — no ids at all, known or unknown. Only covers anything at line scope. */
|
|
299
|
+
readonly bare: boolean;
|
|
300
|
+
/** The source line, verbatim. */
|
|
301
|
+
readonly text: string;
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Every allow annotation in one file, with the ids it names classified by reach.
|
|
305
|
+
*
|
|
306
|
+
* Pure, and it shares `collectAllowAnnotations` with {@link checkSource} — so a caller enumerating
|
|
307
|
+
* annotations sees exactly the set the scan honours, rather than a second regex that can drift from
|
|
308
|
+
* it. `--audit-allows` carried such a mirror and it was already one alternation behind.
|
|
309
|
+
*
|
|
310
|
+
* A file-scoped declaration appears once, at the line it is written on.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* for (const site of findAllowAnnotations(text, rel, cfg)) {
|
|
314
|
+
* if (site.pluginRules.length > 0) probeWithOxlint(neutralizeAllowAnnotation(text, site.line, cfg))
|
|
315
|
+
* }
|
|
316
|
+
*/
|
|
317
|
+
export declare function findAllowAnnotations(text: string, relPath: string, cfg: GuardConfig): AllowAnnotationSite[];
|
|
318
|
+
/** The token a neutralized annotation is rewritten to. Shares no substring with `theme-allow`. */
|
|
319
|
+
export declare const NEUTRALIZED_ALLOW_TOKEN = "basalt-audit-neutralized";
|
|
320
|
+
/**
|
|
321
|
+
* `text` with the allow token on ONE (1-based) line rewritten to {@link NEUTRALIZED_ALLOW_TOKEN},
|
|
322
|
+
* leaving every other annotation intact.
|
|
323
|
+
*
|
|
324
|
+
* The probe half of an audit: re-scan the result and the findings that appear are exactly what that
|
|
325
|
+
* one annotation suppresses. Shipped rather than left to each caller so the guard probe and the
|
|
326
|
+
* oxlint probe neutralize identically — a `--audit-allows` verdict that differs between the two
|
|
327
|
+
* halves by a substitution detail is worse than no verdict.
|
|
328
|
+
*
|
|
329
|
+
* A line with no token comes back unchanged.
|
|
330
|
+
*/
|
|
331
|
+
export declare function neutralizeAllowAnnotation(text: string, line: number, cfg: GuardConfig): string;
|
|
220
332
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/guard/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE7E,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/guard/index.ts"],"names":[],"mappings":"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"}
|
package/dist/guard/index.js
CHANGED
|
@@ -54,6 +54,38 @@ const INLINE_SPACING = new RegExp(
|
|
|
54
54
|
"g"
|
|
55
55
|
);
|
|
56
56
|
const MICRO_SPACING_CEILING_PX = 10;
|
|
57
|
+
const STYLE_ATTRIBUTE_OPENER = /\b(?:style|styles|sx|css)\s*=\s*\{?\s*$/;
|
|
58
|
+
const OBJECT_DECLARATION = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::([^=]*))?=\s*$/;
|
|
59
|
+
const STYLE_OBJECT_NAME = /(?:Style|Styles|Css)$|^(?:style|styles|css|sx)$/;
|
|
60
|
+
const STYLE_OBJECT_TYPE = /\bCSS(?:Properties|Object)\b|\bStyle(?:s|Prop)?\b/;
|
|
61
|
+
const STYLE_BOUND_IDENTIFIER = /\b(?:style|styles|sx|css)=\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
62
|
+
const NESTED_OBJECT_KEY = /(?:[\w$]+|'[^']*'|"[^"]*"|\[[^\]]*\])\s*:\s*$/;
|
|
63
|
+
const MAX_STYLE_CONTEXT_SCAN = 4e3;
|
|
64
|
+
function isStyleObjectContext(codeText, at, styleIdentifiers) {
|
|
65
|
+
let depth = 0;
|
|
66
|
+
const stop = Math.max(0, at - MAX_STYLE_CONTEXT_SCAN);
|
|
67
|
+
for (let i = at - 1; i >= stop; i--) {
|
|
68
|
+
const ch = codeText[i];
|
|
69
|
+
if (ch === "}") depth++;
|
|
70
|
+
else if (ch === "{") {
|
|
71
|
+
if (depth > 0) {
|
|
72
|
+
depth--;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const before = codeText.slice(Math.max(0, i - 120), i);
|
|
76
|
+
if (STYLE_ATTRIBUTE_OPENER.test(before)) return true;
|
|
77
|
+
const declaration = OBJECT_DECLARATION.exec(before);
|
|
78
|
+
if (declaration !== null) {
|
|
79
|
+
const [, name = "", annotation = ""] = declaration;
|
|
80
|
+
return STYLE_OBJECT_NAME.test(name) || STYLE_OBJECT_TYPE.test(annotation) || styleIdentifiers.has(name);
|
|
81
|
+
}
|
|
82
|
+
if (before.endsWith("{")) continue;
|
|
83
|
+
if (NESTED_OBJECT_KEY.test(before)) continue;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
57
89
|
const ROOT_FONT_SIZE_PX = 16;
|
|
58
90
|
function isSubScaleCssSpacing(line, matchIndex) {
|
|
59
91
|
const colon = line.indexOf(":", matchIndex);
|
|
@@ -129,13 +161,52 @@ const EXEMPT_RULE_ALIASES = {
|
|
|
129
161
|
"surface-shadow-override": "raw-surface",
|
|
130
162
|
"css-raw-surface": "raw-surface"
|
|
131
163
|
};
|
|
164
|
+
function normalizeExemptPattern(pattern) {
|
|
165
|
+
return pattern.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
166
|
+
}
|
|
167
|
+
function exemptGlobToRegExp(pattern) {
|
|
168
|
+
const body = pattern.split(/(\*\*\/|\*\*|\*|\?)/).map((part) => {
|
|
169
|
+
if (part === "**/") return "(?:.*/)?";
|
|
170
|
+
if (part === "**") return ".*";
|
|
171
|
+
if (part === "*") return "[^/]*";
|
|
172
|
+
if (part === "?") return "[^/]";
|
|
173
|
+
return part.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
174
|
+
}).join("");
|
|
175
|
+
return new RegExp(`^${body}$`);
|
|
176
|
+
}
|
|
177
|
+
function exemptPatternMatches(pattern, relPath) {
|
|
178
|
+
const p = normalizeExemptPattern(pattern);
|
|
179
|
+
if (p === "") return false;
|
|
180
|
+
if (relPath === p || relPath.startsWith(`${p}/`)) return true;
|
|
181
|
+
if (/[*?]/.test(p)) {
|
|
182
|
+
const re = exemptGlobToRegExp(p);
|
|
183
|
+
if (re.test(relPath)) return true;
|
|
184
|
+
if (!p.includes("/") && re.test(relPath.split("/").pop() ?? "")) return true;
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return !p.includes("/") && relPath.split("/").includes(p);
|
|
188
|
+
}
|
|
132
189
|
function isRuleExempt(kind, relPath, exemptRules) {
|
|
133
190
|
const alias = EXEMPT_RULE_ALIASES[kind];
|
|
134
191
|
if (alias !== void 0 && isRuleExempt(alias, relPath, exemptRules)) return true;
|
|
135
192
|
const patterns = exemptRules?.[kind];
|
|
136
193
|
if (patterns === void 0 || patterns.length === 0) return false;
|
|
137
|
-
|
|
138
|
-
|
|
194
|
+
return patterns.some((pattern) => exemptPatternMatches(pattern, relPath));
|
|
195
|
+
}
|
|
196
|
+
function unmatchedExemptPatterns(cfg, scannedRelPaths) {
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const [kind, patterns] of Object.entries(cfg.exemptRules ?? {})) {
|
|
199
|
+
const known = Object.hasOwn(GUARD_RULES, kind);
|
|
200
|
+
for (const pattern of patterns ?? []) {
|
|
201
|
+
if (!known) {
|
|
202
|
+
out.push({ kind, pattern, reason: "unknown-kind" });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (scannedRelPaths.some((relPath) => exemptPatternMatches(pattern, relPath))) continue;
|
|
206
|
+
out.push({ kind, pattern, reason: "no-match" });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
139
210
|
}
|
|
140
211
|
const PLUGIN_RULE_IDS = /* @__PURE__ */ new Set([
|
|
141
212
|
"no-raw-font-size",
|
|
@@ -157,7 +228,7 @@ const PLUGIN_RULE_IDS = /* @__PURE__ */ new Set([
|
|
|
157
228
|
"ai-sdk-major"
|
|
158
229
|
]);
|
|
159
230
|
const MIN_ALLOW_REASON_LENGTH = 4;
|
|
160
|
-
function parseAllowAnnotation(rest) {
|
|
231
|
+
function parseAllowAnnotation(rest, scope) {
|
|
161
232
|
const rules = [];
|
|
162
233
|
const unknownRules = [];
|
|
163
234
|
let remainder = rest.replace(/^[\s,]+/, "");
|
|
@@ -176,51 +247,89 @@ function parseAllowAnnotation(rest) {
|
|
|
176
247
|
inIdSlot = /^\s*,/.test(after);
|
|
177
248
|
}
|
|
178
249
|
const reason = remainder.replace(/^(?:—|–|-{1,2}|:)\s*/, "").trim();
|
|
179
|
-
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH };
|
|
250
|
+
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH, scope };
|
|
180
251
|
}
|
|
181
252
|
const MAX_CSS_CONTINUATION_LINES = 8;
|
|
253
|
+
const MAX_COMMENT_BLOCK_LINES = 8;
|
|
182
254
|
const COMMENT_OPENER_BEFORE = /(?:\/\/|\/\*|<!--)\s*$/;
|
|
183
255
|
const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/;
|
|
184
|
-
const
|
|
256
|
+
const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/;
|
|
257
|
+
const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*\*?|<!--|^\s*\*)\s*$/;
|
|
258
|
+
function escapeRegExp(value) {
|
|
259
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
260
|
+
}
|
|
261
|
+
function allowTokenRe(allowComment) {
|
|
262
|
+
return new RegExp(`${escapeRegExp(allowComment)}(-file)?(?![\\w-])`, "g");
|
|
263
|
+
}
|
|
264
|
+
function jsonAllowKeyRe(allowComment) {
|
|
265
|
+
return new RegExp(`"basalt:${escapeRegExp(allowComment)}(-file)?"\\s*:\\s*"([^"]*)"`, "g");
|
|
266
|
+
}
|
|
185
267
|
function collectAllowAnnotations(lines, codeLines, allowComment, syntax) {
|
|
186
268
|
const byLine = /* @__PURE__ */ new Map();
|
|
269
|
+
const fileScoped = [];
|
|
187
270
|
const declared = [];
|
|
188
271
|
const add = (index, annotation) => {
|
|
189
272
|
const list = byLine.get(index) ?? [];
|
|
190
273
|
list.push(annotation);
|
|
191
274
|
byLine.set(index, list);
|
|
192
275
|
};
|
|
276
|
+
const record = (index, annotation, trailing) => {
|
|
277
|
+
declared.push({ line: index + 1, annotation });
|
|
278
|
+
if (annotation.scope === "file") {
|
|
279
|
+
fileScoped.push(annotation);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
add(index, annotation);
|
|
283
|
+
if (!trailing) {
|
|
284
|
+
for (let ahead = 1; ahead <= MAX_COMMENT_BLOCK_LINES; ahead++) {
|
|
285
|
+
const raw = lines[index + ahead] ?? "";
|
|
286
|
+
add(index + ahead, annotation);
|
|
287
|
+
if (raw.trim() !== "" && (codeLines[index + ahead] ?? "").trim() !== "") break;
|
|
288
|
+
if (raw.trim() === "") break;
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (syntax !== "css") return;
|
|
293
|
+
for (let back = 1; back <= MAX_CSS_CONTINUATION_LINES; back++) {
|
|
294
|
+
const previous = (codeLines[index - back] ?? "").trim();
|
|
295
|
+
if (previous === "" || /[;{}]$/.test(previous)) break;
|
|
296
|
+
add(index - back, annotation);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
const token = allowTokenRe(allowComment);
|
|
300
|
+
const jsonKey = jsonAllowKeyRe(allowComment);
|
|
193
301
|
for (let i = 0; i < lines.length; i++) {
|
|
194
302
|
const line = lines[i] ?? "";
|
|
195
303
|
const stripped = codeLines[i] ?? "";
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
from = at + allowComment.length;
|
|
202
|
-
if (stripped[at] !== " " && !COMMENT_OPENER_BEFORE.test(line.slice(0, at))) continue;
|
|
203
|
-
const annotation = parseAllowAnnotation(line.slice(from));
|
|
204
|
-
if (ANNOTATION_PREFIX.test(line.slice(0, at))) declared.push({ line: i + 1, annotation });
|
|
205
|
-
add(i, annotation);
|
|
206
|
-
if (isCommentOnlyLine) {
|
|
207
|
-
add(i + 1, annotation);
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
if (syntax !== "css") continue;
|
|
211
|
-
for (let back = 1; back <= MAX_CSS_CONTINUATION_LINES; back++) {
|
|
212
|
-
const previous = (codeLines[i - back] ?? "").trim();
|
|
213
|
-
if (previous === "" || /[;{}]$/.test(previous)) break;
|
|
214
|
-
add(i - back, annotation);
|
|
304
|
+
const code = stripped.trim();
|
|
305
|
+
const isCommentOnlyLine = code === "" || COMMENT_ONLY_LINE.test(line) || code === "}" && JSX_COMMENT_CLOSE.test(line);
|
|
306
|
+
if (syntax === "markup") {
|
|
307
|
+
for (const m of line.matchAll(jsonKey)) {
|
|
308
|
+
record(i, parseAllowAnnotation(m[2] ?? "", m[1] === void 0 ? "line" : "file"), false);
|
|
215
309
|
}
|
|
216
310
|
}
|
|
311
|
+
for (const m of line.matchAll(token)) {
|
|
312
|
+
const at = m.index;
|
|
313
|
+
const before = line.slice(0, at);
|
|
314
|
+
if (stripped[at] !== " " && !COMMENT_OPENER_BEFORE.test(before)) continue;
|
|
315
|
+
if (!ANNOTATION_PREFIX.test(before)) continue;
|
|
316
|
+
const rest = line.slice(at + m[0].length);
|
|
317
|
+
record(
|
|
318
|
+
i,
|
|
319
|
+
parseAllowAnnotation(rest, m[1] === void 0 ? "line" : "file"),
|
|
320
|
+
!isCommentOnlyLine
|
|
321
|
+
);
|
|
322
|
+
}
|
|
217
323
|
}
|
|
218
|
-
return { waivers: byLine, declared };
|
|
324
|
+
return { waivers: byLine, fileScoped, declared };
|
|
325
|
+
}
|
|
326
|
+
function annotationCovers(annotation, kind) {
|
|
327
|
+
if (annotation.rules.includes(kind)) return true;
|
|
328
|
+
if (annotation.scope === "file") return false;
|
|
329
|
+
return annotation.rules.length === 0 && annotation.unknownRules.length === 0;
|
|
219
330
|
}
|
|
220
331
|
function annotationsCover(annotations, kind) {
|
|
221
|
-
return (annotations ?? []).some(
|
|
222
|
-
(a) => a.rules.length === 0 && a.unknownRules.length === 0 || a.rules.includes(kind)
|
|
223
|
-
);
|
|
332
|
+
return (annotations ?? []).some((a) => annotationCovers(a, kind));
|
|
224
333
|
}
|
|
225
334
|
function guardSyntaxFor(relPath) {
|
|
226
335
|
if (relPath.endsWith(".css")) return "css";
|
|
@@ -606,14 +715,14 @@ const GUARD_RULES = {
|
|
|
606
715
|
kind: "theme-allow-unscoped",
|
|
607
716
|
pattern: /theme-allow/,
|
|
608
717
|
// handled inline (annotation pass); entry keeps the registry complete
|
|
609
|
-
message: "theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> \u2014 <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver \u2014 it suppresses nothing."
|
|
718
|
+
message: "theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> \u2014 <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver \u2014 it suppresses nothing. `theme-allow-file <rule-id> \u2014 <why>` declares the whole file, and must name its ids: a bare one waives nothing."
|
|
610
719
|
},
|
|
611
720
|
"surface-shadow-override": {
|
|
612
721
|
kind: "surface-shadow-override",
|
|
613
722
|
pattern: SHADOW_DECL,
|
|
614
723
|
// handled inline (value is judged, not just matched)
|
|
615
724
|
enabled: (cfg) => cfg.rawSurface,
|
|
616
|
-
message: "Token-composed boxShadow that REPLACES
|
|
725
|
+
message: "Token-composed boxShadow that REPLACES surface depth \u2014 a card whose shadow drops --vx-shadow-card is the one card in the app with no depth. Compose with the depth token for its tier (`${VX.shadowCard}, <your ring>`; a fixed/floating surface wants --vx-shadow-overlay) rather than instead of it."
|
|
617
726
|
},
|
|
618
727
|
"css-raw-surface": {
|
|
619
728
|
kind: "css-raw-surface",
|
|
@@ -638,6 +747,19 @@ const GUARD_RULES = {
|
|
|
638
747
|
message: "Raw font-size literal \u2014 route through VX.text.* (numbers) / --vx-text-* (CSS), or the Mantine fz token. Same rule the oxlint plugin enforces as basalt/no-raw-font-size; this is the half a check-theme-only CI can see."
|
|
639
748
|
}
|
|
640
749
|
};
|
|
750
|
+
function guardKindRemedy(kind) {
|
|
751
|
+
return `${kind}: ${GUARD_RULES[kind].message}`;
|
|
752
|
+
}
|
|
753
|
+
const JSON_FAMILY_FILE = /\.(?:webmanifest|json)$/;
|
|
754
|
+
function guardWaiverHint(relPath) {
|
|
755
|
+
if (relPath.endsWith(".webmanifest")) {
|
|
756
|
+
return 'A manifest cannot reference a CSS variable, so a hex here is a hand-copy that drifts: let basaltAppPlugin emit it (`manifest: { \u2026 }` in vite.config.ts) instead of hand-writing it. For a manifest basalt does not generate, declare the exception with a `"basalt:theme-allow-file": "raw-hex \u2014 <why>"` member.';
|
|
757
|
+
}
|
|
758
|
+
if (JSON_FAMILY_FILE.test(relPath)) {
|
|
759
|
+
return 'JSON has no comments \u2014 declare a deliberate exception with a `"basalt:theme-allow-file": "<rule-id> \u2014 <why>"` member.';
|
|
760
|
+
}
|
|
761
|
+
return "Add a `theme-allow <rule-id> \u2014 <why>` comment for a deliberate exception (`theme-allow-file` declares the whole file).";
|
|
762
|
+
}
|
|
641
763
|
function ruleApplies(kind, relPath) {
|
|
642
764
|
const appliesTo = GUARD_RULES[kind].appliesTo;
|
|
643
765
|
return appliesTo === void 0 || appliesTo(relPath);
|
|
@@ -655,8 +777,19 @@ function checkSource(text, relPath, cfg) {
|
|
|
655
777
|
const syntax = guardSyntaxFor(relPath);
|
|
656
778
|
const codeText = syntax === "markup" ? stripMarkupComments(text) : stripComments(text, syntax);
|
|
657
779
|
const codeLines = codeText.split("\n");
|
|
658
|
-
const { waivers, declared } = collectAllowAnnotations(
|
|
659
|
-
|
|
780
|
+
const { waivers, fileScoped, declared } = collectAllowAnnotations(
|
|
781
|
+
lines,
|
|
782
|
+
codeLines,
|
|
783
|
+
cfg.allowComment,
|
|
784
|
+
syntax
|
|
785
|
+
);
|
|
786
|
+
const fileWaived = /* @__PURE__ */ new Set();
|
|
787
|
+
for (const annotation of fileScoped) {
|
|
788
|
+
for (const rule of annotation.rules) {
|
|
789
|
+
if (Object.hasOwn(GUARD_RULES, rule)) fileWaived.add(rule);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const isAllowed = (line, kind) => fileWaived.has(kind) || annotationsCover(waivers.get(line - 1), kind);
|
|
660
793
|
const isAllowedInRange = (start, end, kind) => {
|
|
661
794
|
for (let n = start; n <= end; n++) if (isAllowed(n, kind)) return true;
|
|
662
795
|
return false;
|
|
@@ -678,6 +811,14 @@ function checkSource(text, relPath, cfg) {
|
|
|
678
811
|
});
|
|
679
812
|
}
|
|
680
813
|
const rawHtmlLayoutLines = /* @__PURE__ */ new Set();
|
|
814
|
+
const lineStarts = [];
|
|
815
|
+
for (let n = 0, i = 0; i < codeLines.length; i++) {
|
|
816
|
+
lineStarts.push(n);
|
|
817
|
+
n += (codeLines[i] ?? "").length + 1;
|
|
818
|
+
}
|
|
819
|
+
const styleIdentifiers = new Set(
|
|
820
|
+
[...codeText.matchAll(STYLE_BOUND_IDENTIFIER)].map((m) => m[1] ?? "")
|
|
821
|
+
);
|
|
681
822
|
for (let i = 0; i < lines.length; i++) {
|
|
682
823
|
const line = codeLines[i] ?? "";
|
|
683
824
|
for (const m of line.matchAll(GUARD_RULES["raw-hex"].pattern)) {
|
|
@@ -751,6 +892,9 @@ function checkSource(text, relPath, cfg) {
|
|
|
751
892
|
if (GUARD_RULES["inline-spacing"].enabled(cfg)) {
|
|
752
893
|
for (const m of line.matchAll(GUARD_RULES["inline-spacing"].pattern)) {
|
|
753
894
|
if (syntax === "css" && isSubScaleCssSpacing(line, m.index)) continue;
|
|
895
|
+
const hasUnit = /^[a-z%]/.test(line.slice(m.index + m[0].length));
|
|
896
|
+
if (syntax === "ts" && !hasUnit && !isStyleObjectContext(codeText, lineStarts[i] + m.index, styleIdentifiers))
|
|
897
|
+
continue;
|
|
754
898
|
push("inline-spacing", i + 1, m[0]);
|
|
755
899
|
}
|
|
756
900
|
}
|
|
@@ -855,10 +999,49 @@ function checkSource(text, relPath, cfg) {
|
|
|
855
999
|
severity: severityOf(f.kind, cfg)
|
|
856
1000
|
}));
|
|
857
1001
|
}
|
|
1002
|
+
function findAllowAnnotations(text, relPath, cfg) {
|
|
1003
|
+
const lines = text.split("\n");
|
|
1004
|
+
const syntax = guardSyntaxFor(relPath);
|
|
1005
|
+
const codeText = syntax === "markup" ? stripMarkupComments(text) : stripComments(text, syntax);
|
|
1006
|
+
const { declared } = collectAllowAnnotations(
|
|
1007
|
+
lines,
|
|
1008
|
+
codeText.split("\n"),
|
|
1009
|
+
cfg.allowComment,
|
|
1010
|
+
syntax
|
|
1011
|
+
);
|
|
1012
|
+
return declared.map(({ line, annotation }) => ({
|
|
1013
|
+
line,
|
|
1014
|
+
scope: annotation.scope,
|
|
1015
|
+
rules: [...annotation.rules],
|
|
1016
|
+
guardKinds: annotation.rules.filter((id) => Object.hasOwn(GUARD_RULES, id)),
|
|
1017
|
+
pluginRules: annotation.rules.filter(
|
|
1018
|
+
(id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id)
|
|
1019
|
+
),
|
|
1020
|
+
unknownRules: [...annotation.unknownRules],
|
|
1021
|
+
hasReason: annotation.hasReason,
|
|
1022
|
+
bare: annotation.rules.length === 0 && annotation.unknownRules.length === 0,
|
|
1023
|
+
text: lines[line - 1] ?? ""
|
|
1024
|
+
}));
|
|
1025
|
+
}
|
|
1026
|
+
const NEUTRALIZED_ALLOW_TOKEN = "basalt-audit-neutralized";
|
|
1027
|
+
function neutralizeAllowAnnotation(text, line, cfg) {
|
|
1028
|
+
const lines = text.split("\n");
|
|
1029
|
+
const target = lines[line - 1];
|
|
1030
|
+
if (target === void 0) return text;
|
|
1031
|
+
lines[line - 1] = target.replaceAll(cfg.allowComment, NEUTRALIZED_ALLOW_TOKEN);
|
|
1032
|
+
return lines.join("\n");
|
|
1033
|
+
}
|
|
858
1034
|
export {
|
|
859
1035
|
DEFAULT_GUARD_CONFIG,
|
|
860
1036
|
GENERATED_HEADER_LINE,
|
|
861
1037
|
GUARD_RULES,
|
|
1038
|
+
NEUTRALIZED_ALLOW_TOKEN,
|
|
1039
|
+
PLUGIN_RULE_IDS,
|
|
862
1040
|
TOKENS_ONLY_DISABLED_KINDS,
|
|
863
|
-
checkSource
|
|
1041
|
+
checkSource,
|
|
1042
|
+
findAllowAnnotations,
|
|
1043
|
+
guardKindRemedy,
|
|
1044
|
+
guardWaiverHint,
|
|
1045
|
+
neutralizeAllowAnnotation,
|
|
1046
|
+
unmatchedExemptPatterns
|
|
864
1047
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* 2. `useStore` — React hook: `const [values, persist] = store.useStore()`.
|
|
9
9
|
* Call `persist(next)` alongside `navigate` so the selection survives navigation.
|
|
10
10
|
* 3. `readStored` — plain function for non-React contexts (tests, guards).
|
|
11
|
+
* 4. `linkSearch` — the click-time `search:` thunk for a nav link, ready-made.
|
|
11
12
|
*
|
|
12
13
|
* Headless — no Mantine, no JSX. Same tier as `createSearchParamStore`.
|
|
13
14
|
*
|
|
@@ -73,6 +74,18 @@ export type MultiSearchParamStore<T extends string, P extends string = string> =
|
|
|
73
74
|
useStore: () => readonly [readonly T[], (next: readonly T[]) => void];
|
|
74
75
|
/** Plain read — for use outside React (tests, guards, fallback reads). */
|
|
75
76
|
readStored: () => readonly T[] | null;
|
|
77
|
+
/**
|
|
78
|
+
* The click-time `search:` thunk for a nav link — `() => ({ [param]: readStored() ?? fallback })`.
|
|
79
|
+
* Pass it BY REFERENCE (`search: store.linkSearch`), never call it; see `createSearchParamStore`
|
|
80
|
+
* for why a module-scope literal silently defeats the store.
|
|
81
|
+
*
|
|
82
|
+
* No dev warning rides on this one, unlike the single-value store: an empty array in the URL is
|
|
83
|
+
* indistinguishable from an absent param here (`validateSearch` falls back to storage for both),
|
|
84
|
+
* so a link declaring `{ tags: [] }` restores correctly and there is no broken state to detect.
|
|
85
|
+
*/
|
|
86
|
+
linkSearch: () => {
|
|
87
|
+
[K in P]: readonly T[];
|
|
88
|
+
};
|
|
76
89
|
};
|
|
77
90
|
export declare function createMultiSearchParamStore<const T extends string, const P extends string>(opts: MultiSearchParamStoreOptions<T, P>): MultiSearchParamStore<T, P>;
|
|
78
91
|
//# sourceMappingURL=multi-search-param-store.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"multi-search-param-store.d.ts","sourceRoot":"","sources":["../../src/router-tanstack/multi-search-param-store.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"multi-search-param-store.d.ts","sourceRoot":"","sources":["../../src/router-tanstack/multi-search-param-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAMH,MAAM,MAAM,4BAA4B,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI;IACtF,kEAAkE;IAClE,GAAG,EAAE,MAAM,CAAA;IACX,6DAA6D;IAC7D,KAAK,EAAE,CAAC,CAAA;IACR,0FAA0F;IAC1F,MAAM,EAAE,SAAS,CAAC,EAAE,CAAA;IACpB,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,SAAS,CAAC,EAAE,CAAA;IACvB,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,qBAAqB,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI;IAC/E;;;OAGG;IACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK;SAAG,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,EAAE;KAAE,CAAA;IAC/E;;;OAGG;IACH,QAAQ,EAAE,MAAM,SAAS,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,CAAC,CAAA;IACrE,0EAA0E;IAC1E,UAAU,EAAE,MAAM,SAAS,CAAC,EAAE,GAAG,IAAI,CAAA;IACrC;;;;;;;;OAQG;IACH,UAAU,EAAE,MAAM;SAAG,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,EAAE;KAAE,CAAA;CAC7C,CAAA;AAID,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,CAAC,CAAC,SAAS,MAAM,EACxF,IAAI,EAAE,4BAA4B,CAAC,CAAC,EAAE,CAAC,CAAC,GACvC,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC,CAwC7B"}
|
|
@@ -16,6 +16,9 @@ function createMultiSearchParamStore(opts) {
|
|
|
16
16
|
if (!Array.isArray(raw)) return null;
|
|
17
17
|
return normalize(raw);
|
|
18
18
|
};
|
|
19
|
+
const linkSearch = () => {
|
|
20
|
+
return { [opts.param]: readStored() ?? fallback };
|
|
21
|
+
};
|
|
19
22
|
const validateSearch = (search) => {
|
|
20
23
|
const fromUrl = normalize(search[opts.param]);
|
|
21
24
|
if (fromUrl.length > 0) {
|
|
@@ -28,7 +31,7 @@ function createMultiSearchParamStore(opts) {
|
|
|
28
31
|
version,
|
|
29
32
|
initial: fallback
|
|
30
33
|
});
|
|
31
|
-
return { validateSearch, useStore, readStored };
|
|
34
|
+
return { validateSearch, useStore, readStored, linkSearch };
|
|
32
35
|
}
|
|
33
36
|
export {
|
|
34
37
|
createMultiSearchParamStore
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
* Call `persist(next)` alongside `navigate` so the selection survives
|
|
10
10
|
* navigation away from and back to the route.
|
|
11
11
|
* 3. `readStored` — plain function for non-React contexts (tests, guards).
|
|
12
|
+
* 4. `linkSearch` — the click-time `search:` thunk a nav link needs, ready-made.
|
|
13
|
+
*
|
|
14
|
+
* ## The half that is easy to skip
|
|
15
|
+
*
|
|
16
|
+
* `validateSearch` restores the value when you navigate TO the route with no param. It cannot do
|
|
17
|
+
* anything about a nav link that declares the param itself — and a link written as
|
|
18
|
+
* `search: { range: '30d' }` at module scope pins the fallback on every click, which silently
|
|
19
|
+
* defeats the whole store. The reference consumer shipped exactly that: three features adopted the
|
|
20
|
+
* persistence, the reader had ZERO call sites, and "remember my window" had never once worked.
|
|
21
|
+
*
|
|
22
|
+
* `linkSearch` exists so that failure is unreachable from the API rather than only warned about in
|
|
23
|
+
* this comment — the mistake is made while typing `search:`, so the remedy has to be something
|
|
24
|
+
* autocomplete offers at that keystroke. The JSDoc was already here when it happened.
|
|
12
25
|
*
|
|
13
26
|
* Headless — no Mantine, no JSX. Same tier as `useBasaltNav` and
|
|
14
27
|
* `useRouterBreadcrumbs`.
|
|
@@ -51,17 +64,18 @@
|
|
|
51
64
|
*
|
|
52
65
|
* **5. Nav links** — carry the param across sub-pages, set per destination in
|
|
53
66
|
* the nav definition so only the dashboard sub-tree inherits it and every
|
|
54
|
-
* other link stays clean.
|
|
55
|
-
*
|
|
56
|
-
* const dashSearch = () => ({ range: dashboardRange.readStored() ?? '30d' })
|
|
67
|
+
* other link stays clean. Pass `linkSearch` BY REFERENCE — it is the click-time thunk:
|
|
57
68
|
*
|
|
58
69
|
* navGroup({ id: 'dash', label: 'Dashboard' }, [
|
|
59
70
|
* { id: 'dash-overview', label: 'Overview', icon: <IconHome />,
|
|
60
|
-
* link: linkOptions({ to: '/dashboard', search:
|
|
71
|
+
* link: linkOptions({ to: '/dashboard', search: dashboardRange.linkSearch }) },
|
|
61
72
|
* { id: 'dash-activity', label: 'Activity', icon: <IconPulse />,
|
|
62
|
-
* link: linkOptions({ to: '/dashboard/activity', search:
|
|
73
|
+
* link: linkOptions({ to: '/dashboard/activity', search: dashboardRange.linkSearch }) },
|
|
63
74
|
* ])
|
|
64
75
|
*
|
|
76
|
+
* `linkSearch` IS that thunk (`() => ({ [param]: readStored() ?? fallback })`) — hand-rolling it
|
|
77
|
+
* over `readStored` still works and is what you need when the link also carries other params.
|
|
78
|
+
*
|
|
65
79
|
* NOT `search: true`. That flag means "keep the current search" and TanStack
|
|
66
80
|
* only offers it where the target's search is OPTIONAL — a route wired to a
|
|
67
81
|
* store's `validateSearch` always returns the param, so the router requires a
|
|
@@ -97,6 +111,16 @@ export type SearchParamStore<T extends string, P extends string = string> = {
|
|
|
97
111
|
useStore: () => readonly [T, (next: T) => void];
|
|
98
112
|
/** Plain read — for use outside React (tests, guards, fallback reads). */
|
|
99
113
|
readStored: () => T | null;
|
|
114
|
+
/**
|
|
115
|
+
* The click-time `search:` thunk for a nav link — `() => ({ [param]: readStored() ?? fallback })`.
|
|
116
|
+
*
|
|
117
|
+
* Pass it BY REFERENCE (`search: store.linkSearch`), never call it: `<Link>` re-evaluates the
|
|
118
|
+
* thunk on every click, so arriving from outside the sub-tree restores the last selection,
|
|
119
|
+
* whereas a value computed once at module scope goes stale immediately and pins the fallback.
|
|
120
|
+
*/
|
|
121
|
+
linkSearch: () => {
|
|
122
|
+
[K in P]: T;
|
|
123
|
+
};
|
|
100
124
|
};
|
|
101
125
|
export declare function createSearchParamStore<const T extends string, const P extends string>(opts: SearchParamStoreOptions<T, P>): SearchParamStore<T, P>;
|
|
102
126
|
//# sourceMappingURL=search-param-store.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search-param-store.d.ts","sourceRoot":"","sources":["../../src/router-tanstack/search-param-store.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"search-param-store.d.ts","sourceRoot":"","sources":["../../src/router-tanstack/search-param-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AAMH,MAAM,MAAM,uBAAuB,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI;IACjF,kEAAkE;IAClE,GAAG,EAAE,MAAM,CAAA;IACX,mEAAmE;IACnE,KAAK,EAAE,CAAC,CAAA;IACR,2DAA2D;IAC3D,MAAM,EAAE,SAAS,CAAC,EAAE,CAAA;IACpB,kEAAkE;IAClE,QAAQ,EAAE,CAAC,CAAA;IACX,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,gBAAgB,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI;IAC1E;;;OAGG;IACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK;SAAG,CAAC,IAAI,CAAC,GAAG,CAAC;KAAE,CAAA;IACpE;;;OAGG;IACH,QAAQ,EAAE,MAAM,SAAS,CAAC,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC,CAAA;IAC/C,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IAC1B;;;;;;OAMG;IACH,UAAU,EAAE,MAAM;SAAG,CAAC,IAAI,CAAC,GAAG,CAAC;KAAE,CAAA;CAClC,CAAA;AAgCD,wBAAgB,sBAAsB,CAAC,KAAK,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,CAAC,CAAC,SAAS,MAAM,EACnF,IAAI,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,CAAC,GAClC,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAyDxB"}
|