basalt-ui 1.21.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 +3 -1
- package/CHANGELOG.md +14 -0
- package/MIGRATING.md +93 -10
- package/README.md +20 -7
- package/agent/rules/basalt-app.md +15 -9
- package/agent/rules/basalt-charts.md +2 -2
- package/agent/rules/basalt-router.md +1 -1
- package/agent/rules/basalt-state.md +4 -4
- package/agent/rules/basalt-tokens.md +9 -4
- package/agent/skills/basalt-app/SKILL.md +4 -3
- package/agent/skills/basalt-charts/SKILL.md +1 -1
- package/configs/oxlint-plugin.js +37 -1
- package/configs/oxlint-plugin.test.ts +116 -0
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +230 -42
- package/dist/guard/index.d.ts +67 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +40 -2
- package/dist/vite.d.ts +4 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +21 -13
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +434 -68
- package/src/cli/toolchain-wiring.test.ts +192 -3
- package/src/cli/waiver-audit.test.ts +84 -4
- package/src/guard/check-source.test.ts +197 -0
- package/src/guard/index.ts +129 -6
- package/src/vite.test.ts +33 -0
- package/src/vite.ts +28 -14
package/src/guard/index.ts
CHANGED
|
@@ -661,8 +661,12 @@ export function unmatchedExemptPatterns(
|
|
|
661
661
|
* but they ARE valid ids in a `theme-allow`, so an annotation scoped to one of them must parse as
|
|
662
662
|
* accountable rather than be reported as unscoped. Kept as a literal list because the plugin is a
|
|
663
663
|
* standalone `.js` file this module must not import (see the plugin's own header).
|
|
664
|
+
*
|
|
665
|
+
* Exported for `check-theme --audit-allows`: an annotation naming one of these is outside the
|
|
666
|
+
* guard's reach, and judging it needs a `oxlint` run rather than a `checkSource` one. Keep in step
|
|
667
|
+
* with the plugin's own `KNOWN_RULE_IDS`; `configs/oxlint-plugin.test.ts` asserts the two agree.
|
|
664
668
|
*/
|
|
665
|
-
const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
|
|
669
|
+
export const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
|
|
666
670
|
'no-raw-font-size',
|
|
667
671
|
'raw-size-literal',
|
|
668
672
|
'card-inset',
|
|
@@ -784,6 +788,23 @@ const COMMENT_OPENER_BEFORE = /(?:\/\/|\/\*|<!--)\s*$/
|
|
|
784
788
|
/** A line that is NOTHING but a comment — the placement that waives the line below it. */
|
|
785
789
|
const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
786
790
|
|
|
791
|
+
/**
|
|
792
|
+
* A JSX expression container closing immediately after the comment that filled it — the `*\/}` of a
|
|
793
|
+
* `{/* … *\/}` whose annotation wrapped onto its own line.
|
|
794
|
+
*
|
|
795
|
+
* `stripComments` blanks the comment and leaves the bare `}` behind, so the line read as CODE and
|
|
796
|
+
* the annotation was classified TRAILING — scoped to a line that holds nothing but a brace, and
|
|
797
|
+
* therefore waiving nothing. That is the shape linewatch writes for every hand-composed chart axis
|
|
798
|
+
* (`{/*` on one line, the annotation and `*\/}` on the next), which worked only because the rules it
|
|
799
|
+
* names live in the oxlint plugin, whose own placement test is comment-node-based and never saw the
|
|
800
|
+
* brace. Any guard kind annotated this way was silently unwaivable.
|
|
801
|
+
*
|
|
802
|
+
* Deliberately narrow: the `}` must abut the comment CLOSE. A `}` that is real code (`} // theme-
|
|
803
|
+
* allow …` closing a block) keeps its trailing classification, so the annotation stays on its own
|
|
804
|
+
* line rather than reaching the statement below.
|
|
805
|
+
*/
|
|
806
|
+
const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
|
|
807
|
+
|
|
787
808
|
/**
|
|
788
809
|
* Everything allowed between the comment opener (or the start of the line) and the annotation
|
|
789
810
|
* token, for the token to count as an ANNOTATION rather than prose that mentions one.
|
|
@@ -795,9 +816,14 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
|
795
816
|
* own waivers in a docblock and thereby disarmed the file — the third hole found in this contract
|
|
796
817
|
* in two rounds, and the only one that was a false NEGATIVE.
|
|
797
818
|
*
|
|
798
|
-
* So an annotation must START its comment, or start a line inside one: after `//`,
|
|
799
|
-
* block-comment gutter `*`, or nothing but leading whitespace.
|
|
800
|
-
* writes qualifies; a sentence about the escape hatch does not.
|
|
819
|
+
* So an annotation must START its comment, or start a line inside one: after `//`, `/*` (or a
|
|
820
|
+
* docblock's `/**`), `<!--`, a block-comment gutter `*`, or nothing but leading whitespace.
|
|
821
|
+
* Everything a consumer actually writes qualifies; a sentence about the escape hatch does not.
|
|
822
|
+
*
|
|
823
|
+
* `/**` is in the alternation because the oxlint plugin's copy of this test always accepted it and
|
|
824
|
+
* this one did not: a one-line `/** theme-allow raw-hex — why *\/` waived under `oxlint` and
|
|
825
|
+
* reported under `check-theme`. The two halves of one contract must not disagree about what an
|
|
826
|
+
* annotation IS — only about which rules each can judge.
|
|
801
827
|
*
|
|
802
828
|
* What the annotation then WAIVES is a separate, fail-closed question — see
|
|
803
829
|
* {@link parseAllowAnnotation}. A word in the id slot that names no rule waives nothing, so
|
|
@@ -809,7 +835,7 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
|
809
835
|
* it anyway: the kind that fires is the UNSUPPRESSED one (`raw-hex`), not `theme-allow-unscoped`,
|
|
810
836
|
* and downgrading `raw-hex` to waive-by-default is the hole this closed.
|
|
811
837
|
*/
|
|
812
|
-
const ANNOTATION_PREFIX = /(
|
|
838
|
+
const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*\*?|<!--|^\s*\*)\s*$/
|
|
813
839
|
|
|
814
840
|
/** Escape a config-supplied string for literal use inside a RegExp. */
|
|
815
841
|
function escapeRegExp(value: string): string {
|
|
@@ -942,7 +968,9 @@ function collectAllowAnnotations(
|
|
|
942
968
|
for (let i = 0; i < lines.length; i++) {
|
|
943
969
|
const line = lines[i] ?? ''
|
|
944
970
|
const stripped = codeLines[i] ?? ''
|
|
945
|
-
const
|
|
971
|
+
const code = stripped.trim()
|
|
972
|
+
const isCommentOnlyLine =
|
|
973
|
+
code === '' || COMMENT_ONLY_LINE.test(line) || (code === '}' && JSX_COMMENT_CLOSE.test(line))
|
|
946
974
|
|
|
947
975
|
// The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
|
|
948
976
|
if (syntax === 'markup') {
|
|
@@ -2050,3 +2078,98 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2050
2078
|
severity: severityOf(f.kind, cfg),
|
|
2051
2079
|
}))
|
|
2052
2080
|
}
|
|
2081
|
+
|
|
2082
|
+
// ── Allow-annotation enumeration (check-theme --audit-allows) ─────────────────────────────────────
|
|
2083
|
+
|
|
2084
|
+
/**
|
|
2085
|
+
* One `theme-allow` annotation as WRITTEN, classified by whose reach its ids fall in.
|
|
2086
|
+
*
|
|
2087
|
+
* `guardKinds` is what `checkSource` can judge by re-running itself with the annotation
|
|
2088
|
+
* neutralized. `pluginRules` is what only an `oxlint` run can judge. `unknownRules` names no rule
|
|
2089
|
+
* at all. All three empty with `bare: true` is the legacy blanket form.
|
|
2090
|
+
*/
|
|
2091
|
+
export type AllowAnnotationSite = {
|
|
2092
|
+
/** 1-based line the annotation is written on. */
|
|
2093
|
+
readonly line: number
|
|
2094
|
+
/** `'file'` for the `theme-allow-file` declaration form, `'line'` otherwise. */
|
|
2095
|
+
readonly scope: 'line' | 'file'
|
|
2096
|
+
/** Every id the annotation names that resolves to a real rule, `basalt/` prefix stripped. */
|
|
2097
|
+
readonly rules: readonly string[]
|
|
2098
|
+
/** The subset of `rules` `checkSource` owns. */
|
|
2099
|
+
readonly guardKinds: readonly GuardKind[]
|
|
2100
|
+
/** The subset of `rules` the oxlint plugin owns — outside `checkSource`'s reach. */
|
|
2101
|
+
readonly pluginRules: readonly string[]
|
|
2102
|
+
/** Words that occupied the id slot but name no rule. Their presence makes the parse fail closed. */
|
|
2103
|
+
readonly unknownRules: readonly string[]
|
|
2104
|
+
/** A written reason follows the ids. */
|
|
2105
|
+
readonly hasReason: boolean
|
|
2106
|
+
/** The legacy blanket form — no ids at all, known or unknown. Only covers anything at line scope. */
|
|
2107
|
+
readonly bare: boolean
|
|
2108
|
+
/** The source line, verbatim. */
|
|
2109
|
+
readonly text: string
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* Every allow annotation in one file, with the ids it names classified by reach.
|
|
2114
|
+
*
|
|
2115
|
+
* Pure, and it shares `collectAllowAnnotations` with {@link checkSource} — so a caller enumerating
|
|
2116
|
+
* annotations sees exactly the set the scan honours, rather than a second regex that can drift from
|
|
2117
|
+
* it. `--audit-allows` carried such a mirror and it was already one alternation behind.
|
|
2118
|
+
*
|
|
2119
|
+
* A file-scoped declaration appears once, at the line it is written on.
|
|
2120
|
+
*
|
|
2121
|
+
* @example
|
|
2122
|
+
* for (const site of findAllowAnnotations(text, rel, cfg)) {
|
|
2123
|
+
* if (site.pluginRules.length > 0) probeWithOxlint(neutralizeAllowAnnotation(text, site.line, cfg))
|
|
2124
|
+
* }
|
|
2125
|
+
*/
|
|
2126
|
+
export function findAllowAnnotations(
|
|
2127
|
+
text: string,
|
|
2128
|
+
relPath: string,
|
|
2129
|
+
cfg: GuardConfig,
|
|
2130
|
+
): AllowAnnotationSite[] {
|
|
2131
|
+
const lines = text.split('\n')
|
|
2132
|
+
const syntax = guardSyntaxFor(relPath)
|
|
2133
|
+
const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
|
|
2134
|
+
const { declared } = collectAllowAnnotations(
|
|
2135
|
+
lines,
|
|
2136
|
+
codeText.split('\n'),
|
|
2137
|
+
cfg.allowComment,
|
|
2138
|
+
syntax,
|
|
2139
|
+
)
|
|
2140
|
+
return declared.map(({ line, annotation }) => ({
|
|
2141
|
+
line,
|
|
2142
|
+
scope: annotation.scope,
|
|
2143
|
+
rules: [...annotation.rules],
|
|
2144
|
+
guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
|
|
2145
|
+
pluginRules: annotation.rules.filter(
|
|
2146
|
+
(id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
|
|
2147
|
+
),
|
|
2148
|
+
unknownRules: [...annotation.unknownRules],
|
|
2149
|
+
hasReason: annotation.hasReason,
|
|
2150
|
+
bare: annotation.rules.length === 0 && annotation.unknownRules.length === 0,
|
|
2151
|
+
text: lines[line - 1] ?? '',
|
|
2152
|
+
}))
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/** The token a neutralized annotation is rewritten to. Shares no substring with `theme-allow`. */
|
|
2156
|
+
export const NEUTRALIZED_ALLOW_TOKEN = 'basalt-audit-neutralized'
|
|
2157
|
+
|
|
2158
|
+
/**
|
|
2159
|
+
* `text` with the allow token on ONE (1-based) line rewritten to {@link NEUTRALIZED_ALLOW_TOKEN},
|
|
2160
|
+
* leaving every other annotation intact.
|
|
2161
|
+
*
|
|
2162
|
+
* The probe half of an audit: re-scan the result and the findings that appear are exactly what that
|
|
2163
|
+
* one annotation suppresses. Shipped rather than left to each caller so the guard probe and the
|
|
2164
|
+
* oxlint probe neutralize identically — a `--audit-allows` verdict that differs between the two
|
|
2165
|
+
* halves by a substitution detail is worse than no verdict.
|
|
2166
|
+
*
|
|
2167
|
+
* A line with no token comes back unchanged.
|
|
2168
|
+
*/
|
|
2169
|
+
export function neutralizeAllowAnnotation(text: string, line: number, cfg: GuardConfig): string {
|
|
2170
|
+
const lines = text.split('\n')
|
|
2171
|
+
const target = lines[line - 1]
|
|
2172
|
+
if (target === undefined) return text
|
|
2173
|
+
lines[line - 1] = target.replaceAll(cfg.allowComment, NEUTRALIZED_ALLOW_TOKEN)
|
|
2174
|
+
return lines.join('\n')
|
|
2175
|
+
}
|
package/src/vite.test.ts
CHANGED
|
@@ -201,6 +201,39 @@ describe('basaltAppPlugin — base handling', () => {
|
|
|
201
201
|
false,
|
|
202
202
|
)
|
|
203
203
|
})
|
|
204
|
+
|
|
205
|
+
// The head half of `icons: false` shipped; the manifest half did not. `{ manifest: true, icons:
|
|
206
|
+
// false }` emitted a site.webmanifest naming two PNGs the app never ships — an installable app
|
|
207
|
+
// with two 404s, which is why rb went hybrid rather than use the plugin's manifest at all.
|
|
208
|
+
test('icons: false reaches the MANIFEST, not just the head links', () => {
|
|
209
|
+
const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR, icons: false })
|
|
210
|
+
const middleware = getDevMiddleware(plugin, '/myapp/')
|
|
211
|
+
const manifest = JSON.parse(runMiddleware(middleware, '/myapp/site.webmanifest') ?? '{}')
|
|
212
|
+
|
|
213
|
+
expect(manifest.icons).toBeUndefined()
|
|
214
|
+
expect(JSON.stringify(manifest)).not.toContain('.png')
|
|
215
|
+
// …and the rest of the manifest is unaffected.
|
|
216
|
+
expect(manifest.start_url).toBe('/myapp/')
|
|
217
|
+
expect(manifest.name).toBe('Test App')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('the default (icons on) still carries both manifest icon entries', () => {
|
|
221
|
+
const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
|
|
222
|
+
const middleware = getDevMiddleware(plugin, '/')
|
|
223
|
+
const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
|
|
224
|
+
expect(manifest.icons).toHaveLength(2)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('icons.dir still redirects the manifest icon srcs', () => {
|
|
228
|
+
const plugin = getPlugin({
|
|
229
|
+
name: 'Test App',
|
|
230
|
+
themeColor: THEME_COLOR,
|
|
231
|
+
icons: { dir: '/brand' },
|
|
232
|
+
})
|
|
233
|
+
const middleware = getDevMiddleware(plugin, '/')
|
|
234
|
+
const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
|
|
235
|
+
expect(manifest.icons[0].src).toBe('/brand/web-app-manifest-192x192.png')
|
|
236
|
+
})
|
|
204
237
|
})
|
|
205
238
|
|
|
206
239
|
describe('basaltAppPlugin — the anti-FOUC boot rule (V1)', () => {
|
package/src/vite.ts
CHANGED
|
@@ -242,7 +242,10 @@ export type BasaltAppOptions = {
|
|
|
242
242
|
scope?: string
|
|
243
243
|
/** Manifest `id`. Default: the resolved `startUrl` (default- or explicitly-derived, per above). */
|
|
244
244
|
id?: string
|
|
245
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* Icon links + manifest icon paths. `false` skips BOTH — the head `<link>` icons and the
|
|
247
|
+
* manifest's `icons` array — so a manifest is never emitted pointing at files that 404.
|
|
248
|
+
*/
|
|
246
249
|
icons?: false | { dir?: string }
|
|
247
250
|
/** Emits `<meta name="darkreader-lock">` when `'lock'` (default). Pass `false` to omit it. */
|
|
248
251
|
darkreader?: 'lock' | false
|
|
@@ -588,6 +591,7 @@ function buildManifestJson(input: {
|
|
|
588
591
|
display: NonNullable<BasaltAppOptions['display']>
|
|
589
592
|
schemeColor: string
|
|
590
593
|
backgroundColor: string
|
|
594
|
+
iconsEnabled: boolean
|
|
591
595
|
iconsDir: string | undefined
|
|
592
596
|
base: string
|
|
593
597
|
}): string {
|
|
@@ -600,6 +604,7 @@ function buildManifestJson(input: {
|
|
|
600
604
|
display,
|
|
601
605
|
schemeColor,
|
|
602
606
|
backgroundColor,
|
|
607
|
+
iconsEnabled,
|
|
603
608
|
iconsDir,
|
|
604
609
|
base,
|
|
605
610
|
} = input
|
|
@@ -615,19 +620,27 @@ function buildManifestJson(input: {
|
|
|
615
620
|
// `<meta name="theme-color">` pair. Unchanged for the default (`'dark'`) and for `'auto'`.
|
|
616
621
|
theme_color: schemeColor,
|
|
617
622
|
background_color: backgroundColor,
|
|
618
|
-
icons:
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
623
|
+
// `icons: false` reaches HERE too, not just the head links. The array was unconditional, so
|
|
624
|
+
// `{ manifest: true, icons: false }` shipped a manifest pointing at two PNGs the app never
|
|
625
|
+
// ships — an installable app with broken icons, and the reason rb went hybrid (plugin for the
|
|
626
|
+
// head, a hand-written manifest for the rest). A manifest with NO icons member is valid.
|
|
627
|
+
...(iconsEnabled
|
|
628
|
+
? {
|
|
629
|
+
icons: [
|
|
630
|
+
{
|
|
631
|
+
src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest192)),
|
|
632
|
+
sizes: '192x192',
|
|
633
|
+
type: 'image/png',
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest512)),
|
|
637
|
+
sizes: '512x512',
|
|
638
|
+
type: 'image/png',
|
|
639
|
+
purpose: 'any maskable',
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
}
|
|
643
|
+
: {}),
|
|
631
644
|
}
|
|
632
645
|
return JSON.stringify(manifest, null, 2)
|
|
633
646
|
}
|
|
@@ -680,6 +693,7 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
|
|
|
680
693
|
display,
|
|
681
694
|
schemeColor,
|
|
682
695
|
backgroundColor,
|
|
696
|
+
iconsEnabled,
|
|
683
697
|
iconsDir,
|
|
684
698
|
base,
|
|
685
699
|
})
|