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.
@@ -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 `//`, `/*`, `<!--`, a
799
- * block-comment gutter `*`, or nothing but leading whitespace. Everything a consumer actually
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 = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
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 isCommentOnlyLine = stripped.trim() === '' || COMMENT_ONLY_LINE.test(line)
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
- /** Icon links + manifest icon paths. `false` skips the head `<link>` icons entirely. */
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
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest192)),
621
- sizes: '192x192',
622
- type: 'image/png',
623
- },
624
- {
625
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest512)),
626
- sizes: '512x512',
627
- type: 'image/png',
628
- purpose: 'any maskable',
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
  })