@tenphi/tasty 3.6.0 → 3.7.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr-collector-ref-COs_ioWl.js","names":[],"sources":["../src/ssr/ssr-collector-ref.ts"],"sourcesContent":["/**\n * Global reference to the SSR collector getter function.\n *\n * This indirection avoids importing 'node:async_hooks' in the browser bundle.\n * The SSR entry point sets this ref when loaded on the server. The useStyles\n * hook calls it if set; on the client it stays null and is never called.\n *\n * Uses a module-level variable as the primary mechanism. In Next.js App\n * Router the RSC and SSR module graphs load separate copies of this module,\n * so the getter registered by TastyRegistry (SSR layer) is invisible to\n * server components (RSC layer) — which correctly fall through to inline\n * RSC styles.\n *\n * A globalThis fallback (`registerSSRCollectorGetterGlobal`) is provided\n * for frameworks like Astro where middleware and page components live in\n * different module graphs and must share the getter across them.\n */\n\nimport type { ServerStyleCollector } from './collector';\n\ntype SSRCollectorGetter = () => ServerStyleCollector | null;\n\nconst GETTER_KEY = '__tasty_ssr_collector_getter__';\n\nlet _getSSRCollector: SSRCollectorGetter | null = null;\n\n/**\n * Register the collector getter in the current module graph only.\n * Used by Next.js TastyRegistry.\n */\nexport function registerSSRCollectorGetter(fn: SSRCollectorGetter): void {\n _getSSRCollector = fn;\n}\n\n/**\n * Register the collector getter on globalThis so it is visible across\n * separate module graphs (e.g. Astro middleware ↔ page components).\n */\nexport function registerSSRCollectorGetterGlobal(fn: SSRCollectorGetter): void {\n (globalThis as Record<string, unknown>)[GETTER_KEY] = fn;\n}\n\n/**\n * Retrieve the SSR collector: module-level first, globalThis fallback.\n */\nexport function getRegisteredSSRCollector(): ServerStyleCollector | null {\n if (_getSSRCollector) return _getSSRCollector();\n const getter = (globalThis as Record<string, unknown>)[GETTER_KEY] as\n SSRCollectorGetter | undefined;\n return getter ? getter() : null;\n}\n"],"mappings":";AAsBA,MAAM,aAAa;AAEnB,IAAI,mBAA8C;;;;;AAMlD,SAAgB,2BAA2B,IAA8B;CACvE,mBAAmB;AACrB;;;;;AAMA,SAAgB,iCAAiC,IAA8B;CAC7E,WAAwC,cAAc;AACxD;;;;AAKA,SAAgB,4BAAyD;CACvE,IAAI,kBAAkB,OAAO,iBAAiB;CAC9C,MAAM,SAAU,WAAuC;CAEvD,OAAO,SAAS,OAAO,IAAI;AAC7B"}
package/docs/debug.md CHANGED
@@ -54,6 +54,19 @@ interface DebugOptions {
54
54
 
55
55
  When `raw` is `false` (the default), results are logged to the console **and** returned. When `raw` is `true`, results are returned silently.
56
56
 
57
+ ### Environments Without a DOM
58
+
59
+ `tastyDebug` reads the document, so on a server — SSR, a Node REPL, a test
60
+ runner in the `node` environment — there is nothing for it to read. Every
61
+ method returns its empty result (`''`, an empty `InspectResult`, a summary of
62
+ zeroes, `metrics: null`) instead of throwing, and explains itself once with a
63
+ console warning. `{ raw: true }` suppresses that warning along with the rest of
64
+ the logging.
65
+
66
+ To see the styles a server render produced, read the `ServerStyleCollector`
67
+ instead — `collector.getCSS()` and `collector.getRenderedClassNames()`. See
68
+ [SSR](./ssr.md).
69
+
57
70
  ---
58
71
 
59
72
  ## API Reference
@@ -1,17 +1,22 @@
1
1
  # Runtime Benchmarks
2
2
 
3
3
  Tasty keeps its performance claims in reproducible benchmarks rather than
4
- combining unlike measurements into one score. The repository measures three
4
+ combining unlike measurements into one score. The repository measures five
5
5
  different costs:
6
6
 
7
7
  1. Style parsing and generation in Node.
8
8
  2. The React overhead of an empty `tasty({})` wrapper.
9
9
  3. Cold browser generation and injection compared with equivalent CSS that is
10
10
  already on the page.
11
+ 4. The steady-state interaction path — mod flips and styled subtrees opening
12
+ and closing after the page has loaded.
13
+ 5. Page-load cold start: network, module compilation, execution and first
14
+ paint, end to end in a throttled browser.
11
15
 
12
- These are focused microbenchmarks, not page-load or interaction scores. Run
13
- them several times on an otherwise idle machine and use a production profile
14
- to decide whether any cost matters in an application.
16
+ The first four are focused microbenchmarks, not page-level scores. The fifth is
17
+ a page-level measurement and is the only one that answers "what does a visitor
18
+ wait for". Run them several times on an otherwise idle machine and use a
19
+ production profile to decide whether any cost matters in an application.
15
20
 
16
21
  ## Reproducing the Results
17
22
 
@@ -19,17 +24,21 @@ to decide whether any cost matters in an application.
19
24
  pnpm bench
20
25
  pnpm bench:overhead
21
26
  pnpm bench:injection
27
+ pnpm bench:interaction
28
+ pnpm bench:cold-start
22
29
  ```
23
30
 
24
- `pnpm bench` runs the core pipeline benchmarks in Node. The other two commands
25
- use production code paths in headless Chromium. The first checkout may require
26
- `pnpm test:setup` to download Chromium.
31
+ `pnpm bench` runs the core pipeline benchmarks in Node. The rest use production
32
+ code paths in headless Chromium. The first checkout may require
33
+ `pnpm test:setup` to download Chromium, and `pnpm bench:cold-start` needs a
34
+ current `dist/` — run `pnpm build` first.
27
35
 
28
36
  Run the Node and browser suites separately so they do not compete for CPU. The
29
- browser timer has 0.1 ms resolution, so both browser benchmarks perform many
37
+ browser timer has 0.1 ms resolution, so the browser benchmarks perform many
30
38
  matched operations per sample and divide the absolute difference by the number
31
- of elements or rules. Machine load, browser versions, and CPU power will move
32
- the results.
39
+ of elements, rules or interactions. Machine load, browser versions, and CPU
40
+ power will move the results — on a loaded machine the absolute columns drift
41
+ several percent while the raw/Tasty delta holds, so read the delta.
33
42
 
34
43
  ## Core Style Pipeline
35
44
 
@@ -159,10 +168,165 @@ together. Because the same resolution pattern is present in each workload's
159
168
  control, the difference answers the narrower delivery question: how much extra
160
169
  work did Tasty perform when the same CSS was not already there?
161
170
 
171
+ ## Steady-State Interaction
172
+
173
+ The benchmarks above measure mounting and whole-tree updates. A running
174
+ application spends most of its time on neither. It flips mods — hovered,
175
+ pressed, selected, expanded — on elements whose styles never change, one
176
+ element at a time, and it mounts and unmounts small styled subtrees as menus
177
+ and dialogs open. Both paths go through the state-map and ref-counting
178
+ machinery rather than the parser, so a regression in them is invisible to every
179
+ other benchmark here.
180
+
181
+ [`tasty-interaction.bench.tsx`](../src/tasty-interaction.bench.tsx) pairs each
182
+ case with a raw-DOM equivalent driven by a hand-written stylesheet that
183
+ produces the same computed color and background in both states. The benchmark
184
+ fails if either arm resolves to anything else, so an arm that quietly rendered
185
+ unstyled elements cannot report a flattering number.
186
+
187
+ Each leaf owns its own `useState`, which is what keeps a single-element
188
+ interaction single: re-rendering the root to flip one row would time the whole
189
+ tree. One toggle is far below Chromium's 0.1 ms timer resolution, so a sample
190
+ flips a 100-element tree three times over — 300 commits — and the churn case
191
+ performs 20 open/close cycles. Divide the raw/Tasty difference by those counts.
192
+
193
+ Two things had to be sized deliberately, and both are the difference between a
194
+ readable number and noise:
195
+
196
+ - **The tree is small (100 elements), not large.** React locates a leaf's
197
+ pending update by walking the sibling list, so in a 1,000-element tree a
198
+ single-element update costs ~68 us of traversal — identical in both arms and
199
+ an order of magnitude above anything the styling layer contributes.
200
+ - **The sample resolves style once, not once per flip.** Forcing a recalc
201
+ between flips costs ~70 us in both arms, which buries the delta the same way.
202
+ The browser's side of an interaction is real, but it is the browser's;
203
+ resolution boundaries are what the injection benchmark above measures.
204
+
205
+ The contract check also reads the injected CSS **before** any toggle and fails
206
+ if the hovered rule is not already there. That a style map's states all ship in
207
+ one chunk on first render is the premise of this case; if the hovered rule
208
+ arrived lazily, the first sample would be timing injection.
209
+
210
+ On an Apple M1 Max with React 19.2.8 and Chromium 151, across several runs:
211
+
212
+ | Workload | Raw elements | Tasty mods | Extra per unit |
213
+ | ---------------------------------------------------- | -----------: | -----------: | -----------------------: |
214
+ | 300 single-element mod toggles in a 100-element tree | 2.1–2.5 ms | 2.7–3.1 ms | 1.6–2.1 us / interaction |
215
+ | 20 mount + unmount cycles of a 200-element subtree | 7.6–8.0 ms | 12.6–12.7 ms | 1.22–1.27 us / element |
216
+
217
+ The absolute columns move several percent with machine load; the delta between
218
+ the arms is the stable quantity, so read that rather than either column.
219
+
220
+ Two things are worth reading out of this.
221
+
222
+ A mod flip on an already-mounted element costs about 2 us. The CSS for both
223
+ states already exists — Tasty emits every state of a style map in one chunk on
224
+ first render — so both arms perform the same commit, and what is left is
225
+ Tasty's props and mod handling. That is the same order as the ~1 us empty
226
+ wrapper measured above, which is most of where it comes from.
227
+
228
+ Subtree churn is not about styling at all. Its ~1.25 us per element sits right
229
+ on the empty-wrapper mount cost, because the styles are already cached:
230
+ reopening a menu re-pays the React wrapper, not the style pipeline.
231
+
232
+ ## Page-Load Cold Start
233
+
234
+ Every benchmark above deliberately excludes the network, module compilation and
235
+ the first render. [`scripts/cold-start`](../scripts/cold-start) measures exactly
236
+ those: what a visitor waits for between requesting a page and seeing styled
237
+ content, in a real Chromium under CDP network and CPU throttling.
238
+
239
+ Three pages render the same 50 styled components and are verified, before any
240
+ timing, to produce the same 50 elements at the same computed color:
241
+
242
+ - **baseline** — the components server-rendered: identical markup, identical
243
+ class names, a linked stylesheet, and no Tasty on the page. Every other
244
+ column is a delta against this one.
245
+ - **runtime** — Tasty generates the CSS in the browser, as a client-rendered
246
+ application does. The run asserts it really did (69 rules generated).
247
+ - **prewarm** — the same, after one throwaway `computeStyles()` against a
248
+ detached root before the first component renders.
249
+
250
+ Each cell is the median of 5 uncached loads in a fresh browser context. The run
251
+ ends at the first contentful paint, observed through a `PerformanceObserver`
252
+ rather than counted in animation frames — `requestAnimationFrame` fires before
253
+ paint, so a page that commits fast can reach its second frame with nothing
254
+ painted yet.
255
+
256
+ Two things about the payload decide whether this measures a deployment or a
257
+ straw man, so both are enforced rather than assumed:
258
+
259
+ - **Assets are served brotli-compressed**, the way a static host serves them.
260
+ The bundle is 52.0 KB on the wire and 186 KB decoded; putting the decoded
261
+ bytes on a 1.6 Mbps link would add ~700 ms and charge it to Tasty. The run
262
+ reads `encodedBodySize` back out of resource timing and fails if what
263
+ crossed the wire is not the compressed size the table reports.
264
+ - **The bundle is built from what the page imports** (`tasty`, `configure`,
265
+ `computeStyles`, `tastyDebug`), so it is tree-shaken as an application's
266
+ would be. Re-exporting the whole library adds ~4 KB brotli of code no page
267
+ here calls.
268
+
269
+ On an Apple M1 Max with React 19.2.8 and Chromium 151, first contentful paint:
270
+
271
+ | Link / CPU | baseline | runtime | prewarm | Tasty's cost |
272
+ | --------------------- | -------: | ------: | ------: | -----------: |
273
+ | No throttling, 1x | 40 ms | 52 ms | 52 ms | +12 ms |
274
+ | Fast 4G, 1x | 624 ms | 680 ms | 676 ms | +56 ms |
275
+ | Slow 4G, 1x | 2028 ms | 2304 ms | 2304 ms | +276 ms |
276
+ | No throttling, 4x CPU | 148 ms | 196 ms | 196 ms | +48 ms |
277
+ | Fast 4G, 4x CPU | 684 ms | 784 ms | 788 ms | +100 ms |
278
+ | Slow 4G, 4x CPU | 2096 ms | 2416 ms | 2408 ms | +320 ms |
279
+
280
+ That is one full run of the matrix; a second moved every cell by a few percent.
281
+
282
+ **The cost is the bundle, not the work.** On Slow 4G the extra transfer alone
283
+ accounts for 262 ms of the 276 ms FCP delta — nearly all of it. Everything
284
+ Tasty then *does* is small by comparison:
285
+
286
+ | Phase (Slow 4G, 1x) | baseline | runtime | prewarm |
287
+ | ----------------------- | -------: | ------: | ------: |
288
+ | js+css transfer | 1420 ms | 1682 ms | 1681 ms |
289
+ | module compile (shared) | 1.2 ms | 1.5 ms | 2.0 ms |
290
+ | tasty top-level execute | — | 1.2 ms | 0.9 ms |
291
+ | `configure()` | — | 0.6 ms | 0.5 ms |
292
+ | prewarm | — | — | 5.3 ms |
293
+ | render 1st component | 2.0 ms | 8.2 ms | 2.7 ms |
294
+ | render 49 more | 1.0 ms | 7.1 ms | 6.0 ms |
295
+
296
+ Importing Tasty costs about 1 ms of top-level execution; `configure()` costs
297
+ half of one. The rest of the CPU delta — about 13 ms for 50 components — is
298
+ generation and injection, which is the cost the injection benchmark isolates.
299
+
300
+ One asymmetry is worth naming: the control links a render-blocking stylesheet
301
+ and the runtime modes have none, so the control's first paint waits for CSS the
302
+ runtime modes never request. That is the real difference between the two
303
+ delivery models, not a thumb on the scale, but it means the FCP delta is not
304
+ purely "what Tasty costs to execute".
305
+
306
+ **Prewarming moves the wake-up, it does not remove it.** The first styled render
307
+ is ~5 ms more expensive than the ones after it, because that is when the
308
+ engine's deferred payload is actually compiled. A throwaway `computeStyles()`
309
+ against a detached root pays it early: `render 1st` drops from 8.2 ms to
310
+ 2.7 ms. The prewarm itself costs 5.3 ms, so FCP does not move. It is worth
311
+ doing only when something else can overlap it, or when the first render is on a
312
+ latency-critical path and the page has idle time before it.
313
+
314
+ **Retained heap.** After a forced collection, the runtime page holds about
315
+ 1,013 KB more than the control (2,632 KB vs 1,619 KB) for 50 components — the
316
+ parser caches, the chunk cache, the injector's registry and the generated CSS.
317
+ The control is not zero either; most of its 1.6 MB is React and the DOM.
318
+
319
+ CPU throttling changes which line moves. At 4x, module compilation of the
320
+ larger graph becomes visible (5.9 ms → 25 ms) where at 1x it is free: V8
321
+ pre-parses at import and compiles lazily, so a slower CPU pays for code the
322
+ faster one never fully compiled. Transfer numbers from the unthrottled cells
323
+ are not worth reading — with no emulated link, resource timings are scheduling
324
+ jitter.
325
+
162
326
  ## Reading the Results Together
163
327
 
164
- Do not add all three benchmark numbers to estimate an application blindly.
165
- They describe different paths:
328
+ Do not add the microbenchmark numbers together to estimate an application
329
+ blindly. They describe different paths:
166
330
 
167
331
  - A stable `tasty()` factory can skip the style pipeline on later renders, but
168
332
  its React wrapper still participates in reconciliation.
@@ -170,9 +334,18 @@ They describe different paths:
170
334
  rule.
171
335
  - A genuinely new style pays generation and injection once, then becomes
172
336
  reusable.
337
+ - A mod flip on an already-styled element pays neither; it is a class-name
338
+ change.
173
339
  - Browser style resolution, layout, and paint depend on the actual document and
174
340
  need application-level profiling.
175
341
 
342
+ The cold-start measurement is the one that puts the rest in proportion. On a
343
+ slow connection, nearly all of Tasty's page-load cost is transferring the
344
+ library — 262 ms of a 276 ms delta — while the generation and injection the
345
+ microbenchmarks obsess over is ~13 ms for 50 components. Bundle size is
346
+ therefore the lever with the largest effect on first paint, and the runtime
347
+ levers matter for what happens after it.
348
+
176
349
  The practical optimization target is therefore repeated work: keep style input
177
350
  stable when possible, reuse generated chunks, and generate CSS at build or
178
351
  server time when runtime flexibility is unnecessary.
package/docs/ssr.md CHANGED
@@ -86,6 +86,92 @@ export default function RootLayout({
86
86
 
87
87
  That's it. All `tasty()` components inside the tree automatically get SSR support. No per-component changes needed.
88
88
 
89
+ ### Optional shared globals stylesheet
90
+
91
+ By default, configured global CSS is included in the streamed Tasty style tag
92
+ for every route. `withTastyNext()` can move that stable CSS into one
93
+ content-hashed stylesheet shared by all routes while leaving component and hook
94
+ styles in the normal streaming path.
95
+
96
+ Keep the Tasty config in a side-effect-free module:
97
+
98
+ ```ts
99
+ // app/tasty.config.ts
100
+ import type { TastyConfig } from '@tenphi/tasty';
101
+
102
+ const config: TastyConfig = {
103
+ tokens: { $gap: '8px', '#brand': 'rebeccapurple' },
104
+ globalStyles: { body: { margin: '0', color: '#brand' } },
105
+ fontFaces: {
106
+ Brand: { src: 'url("/fonts/brand.woff2") format("woff2")' },
107
+ },
108
+ };
109
+
110
+ export default config;
111
+ ```
112
+
113
+ Use it from the Next config:
114
+
115
+ ```ts
116
+ // next.config.ts
117
+ import { withTastyNext } from '@tenphi/tasty/ssr/next-config';
118
+ import config from './app/tasty.config';
119
+
120
+ export default withTastyNext({
121
+ config,
122
+ })({
123
+ // your Next.js config
124
+ });
125
+ ```
126
+
127
+ The runtime must receive the same config. Import and configure it before the
128
+ registry renders:
129
+
130
+ ```tsx
131
+ // app/tasty-registry.tsx
132
+ 'use client';
133
+
134
+ import { configure } from '@tenphi/tasty';
135
+ import { TastyRegistry } from '@tenphi/tasty/ssr/next';
136
+ import config from './tasty.config';
137
+
138
+ configure(config);
139
+
140
+ export default function TastyStyleRegistry({ children }) {
141
+ return <TastyRegistry>{children}</TastyRegistry>;
142
+ }
143
+ ```
144
+
145
+ The generated file contains eager configuration artifacts: built-in and custom
146
+ `@property` rules, tokens and presets, `@font-face`, `@counter-style`, native
147
+ CSS `@function` definitions, and `globalStyles`. Route-dependent component
148
+ rules and calls to `useGlobalStyles`, `useRawCSS`, `useKeyframes`,
149
+ `useProperty`, `useFontFace`, `useCounterStyle`, and `useFunction` remain
150
+ route-specific. Configured keyframes also remain lazy and are streamed only
151
+ when a route references them.
152
+
153
+ The default output directory is `public/_tasty`. The wrapper adds the generated
154
+ URL to `TastyRegistry`, respects `basePath`, preserves existing `env` and
155
+ `headers` config, and serves the content-hashed file with an immutable one-year
156
+ cache header on Next server deployments. Static exports keep the content-hashed
157
+ URL and leave cache headers to the hosting provider. Older hashes are not
158
+ deleted automatically, so rolling deployments cannot break pages from the
159
+ previous build; clean the generated directory as part of a clean deployment if
160
+ needed. Page-relative CSS resources such as
161
+ `url(../fonts/brand.woff2)` are rejected because moving them would change their
162
+ meaning; use root-relative, absolute, or data URLs.
163
+
164
+ `withTastyNext()` options:
165
+
166
+ | Option | Type | Default | Description |
167
+ | ------------ | ------------- | ------------------------- | ---------------------------------------------------------------------- |
168
+ | `config` | `TastyConfig` | — | Config object; takes precedence over `configFile` |
169
+ | `configFile` | `string` | — | Project-relative config module path; requires the optional `jiti` peer |
170
+ | `rootDir` | `string` | current directory | Next app root when the build runs from a monorepo root |
171
+ | `outputDir` | `string` | `public/_tasty` | Filesystem output directory |
172
+ | `publicPath` | `string` | inferred from `outputDir` | Root-relative URL; required when output is outside `public` |
173
+ | `enabled` | `boolean` | `true` | Disable generation without changing wrapper composition |
174
+
89
175
  ### How it works
90
176
 
91
177
  - `TastyRegistry` is a `'use client'` component, but Next.js still server-renders it on initial page load. The `'use client'` boundary is required solely to access `useServerInsertedHTML` — **not** because `tasty()` components need the client.
@@ -130,13 +216,13 @@ The nonce is automatically applied to all `<style>` and `<script>` tags injected
130
216
 
131
217
  Tasty offers several levels of Astro integration. Choose the one that matches your needs:
132
218
 
133
- | Setup | Config needed | Deduplication | Hooks work | Client JS |
134
- | --------------------------------------------------------- | ------------- | ----------------------------- | ---------------------- | -------------- |
135
- | Zero setup | None | Per render tree | Yes (within each tree) | None |
136
- | `tastyIntegration({ islands: false })` | One line | Cross-tree | Yes | None |
137
- | `tastyIntegration()` | One line | Cross-tree | Yes | Auto-hydration |
138
- | `tastyIntegration({ css: { mode: 'extract' } })` | One line | Cross-tree and cross-page | Yes | Auto-hydration |
139
- | `tastyIntegration({ islands: false, css: { mode: 'extract' } })` | One line | Cross-tree and cross-page | Yes | None |
219
+ | Setup | Config needed | Deduplication | Hooks work | Client JS |
220
+ | ---------------------------------------------------------------- | ------------- | ------------------------- | ---------------------- | -------------- |
221
+ | Zero setup | None | Per render tree | Yes (within each tree) | None |
222
+ | `tastyIntegration({ islands: false })` | One line | Cross-tree | Yes | None |
223
+ | `tastyIntegration()` | One line | Cross-tree | Yes | Auto-hydration |
224
+ | `tastyIntegration({ css: { mode: 'extract' } })` | One line | Cross-tree and cross-page | Yes | Auto-hydration |
225
+ | `tastyIntegration({ islands: false, css: { mode: 'extract' } })` | One line | Cross-tree and cross-page | Yes | None |
140
226
 
141
227
  ### Zero setup (static pages)
142
228
 
@@ -413,12 +499,13 @@ const stream = await runWithCollector(collector, () =>
413
499
 
414
500
  ### Entry points
415
501
 
416
- | Import path | Description |
417
- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
418
- | `@tenphi/tasty/ssr` | Core SSR API: `ServerStyleCollector`, `createServerStyleCollector`, `runWithCollector`, `hydrateTastyClasses` |
419
- | `@tenphi/tasty/ssr/next` | Next.js App Router: `TastyRegistry` component |
420
- | `@tenphi/tasty/ssr/astro` | Astro: `tastyIntegration`, `tastyMiddleware` |
421
- | `@tenphi/tasty/ssr/astro-client` | Astro: client-side cache hydration (auto-injected by integration, or import manually) |
502
+ | Import path | Description |
503
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
504
+ | `@tenphi/tasty/ssr` | Core SSR API: `ServerStyleCollector`, `createServerStyleCollector`, `runWithCollector`, `hydrateTastyClasses` |
505
+ | `@tenphi/tasty/ssr/next` | Next.js App Router: `TastyRegistry` component |
506
+ | `@tenphi/tasty/ssr/next-config` | Next.js config wrapper: shared, content-hashed global stylesheet |
507
+ | `@tenphi/tasty/ssr/astro` | Astro: `tastyIntegration`, `tastyMiddleware` |
508
+ | `@tenphi/tasty/ssr/astro-client` | Astro: client-side cache hydration (auto-injected by integration, or import manually) |
422
509
  | `@tenphi/tasty/ssr/astro-middleware`<br>`@tenphi/tasty/ssr/astro-middleware-static`<br>`@tenphi/tasty/ssr/astro-middleware-extract`<br>`@tenphi/tasty/ssr/astro-middleware-extract-static` | Astro: the middleware entrypoints `tastyIntegration()` registers via `addMiddleware()`. Exported so Astro can resolve them by specifier; you should not import them. For manual setups use `tastyMiddleware()`. |
423
510
 
424
511
  ### `ServerStyleCollector`
@@ -439,7 +526,7 @@ Constructor: `new ServerStyleCollector(namePrefix?)`, or use the `createServerSt
439
526
  | `allocateCounterStyleName(providedName?)` | Allocate a counter-style name. Returns `providedName` if given, otherwise generates one using `${namePrefix}c${counter}` (e.g. `tc0`, `tc1`, ...). |
440
527
  | `collectGlobalStyles(key, css)` | Record global styles (from `useGlobalStyles`). Deduplicated by key. |
441
528
  | `collectRawCSS(key, css)` | Record raw CSS text (from `useRawCSS`). Deduplicated by key. |
442
- | `collectInternals()` | Collect internal `@property` rules, `:root` token defaults, `@font-face`, and `@counter-style` rules from the global config. Called automatically on first chunk collection; idempotent. |
529
+ | `collectInternals()` | Collect eager configured globals: `@property`, `:root` tokens and presets, `@font-face`, `@counter-style`, `@function`, and `globalStyles`. Called automatically on first chunk collection; idempotent. |
443
530
  | `getCSS()` | Get all collected CSS as a single string. For non-streaming SSR. |
444
531
  | `flushCSS()` | Get only CSS collected since the last flush. For streaming SSR. |
445
532
  | `getRenderedClassNames()` | Get the list of class names rendered so far. Serialized to `window.__TASTY__` for client hydration via `hydrateTastyClasses()`. |
@@ -448,10 +535,18 @@ Constructor: `new ServerStyleCollector(namePrefix?)`, or use the `createServerSt
448
535
 
449
536
  Next.js App Router component. Props:
450
537
 
451
- | Prop | Type | Default | Description |
452
- | --------------- | ----------- | -------- | ------------------------------------------------ |
453
- | `children` | `ReactNode` | required | Application tree |
454
- | `transferCache` | `boolean` | `true` | Embed cache state script for zero-cost hydration |
538
+ | Prop | Type | Default | Description |
539
+ | ------------------ | ----------------- | --------- | ------------------------------------------------------------------------- |
540
+ | `children` | `ReactNode` | required | Application tree |
541
+ | `transferCache` | `boolean` | `true` | Embed cache state script for zero-cost hydration |
542
+ | `sharedStylesheet` | `string \| false` | generated | Override the shared URL, or disable generated shared CSS for the registry |
543
+
544
+ ### `withTastyNext(options)`
545
+
546
+ Next.js configuration wrapper exported from
547
+ `@tenphi/tasty/ssr/next-config`. It generates a shared stylesheet from eager
548
+ Tasty configuration artifacts and wires its URL and immutable cache header
549
+ into the Next config. Route-specific CSS continues through `TastyRegistry`.
455
550
 
456
551
  ### `tastyIntegration(options?)`
457
552
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/tasty",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "A design-system-integrated styling system and DSL for concise, state-aware UI styling",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,6 +52,11 @@
52
52
  "import": "./dist/ssr/next.js",
53
53
  "default": "./dist/ssr/next.js"
54
54
  },
55
+ "./ssr/next-config": {
56
+ "types": "./dist/ssr/next-config.d.ts",
57
+ "import": "./dist/ssr/next-config.js",
58
+ "default": "./dist/ssr/next-config.js"
59
+ },
55
60
  "./ssr/astro": {
56
61
  "types": "./dist/ssr/astro.d.ts",
57
62
  "import": "./dist/ssr/astro.js",
@@ -174,6 +179,7 @@
174
179
  "@types/react-dom": "^19.0.0",
175
180
  "@vitest/browser-playwright": "^4.1.10",
176
181
  "astro": "^5.18.2",
182
+ "esbuild": "^0.27.7",
177
183
  "eslint": "^10.0.0",
178
184
  "eslint-config-prettier": "^10.1.8",
179
185
  "jiti": "^2.6.1",
@@ -249,6 +255,8 @@
249
255
  "bench:browser": "vitest bench --project browser",
250
256
  "bench:overhead": "vitest bench --config vitest.production-browser-bench.config.ts src/tasty-overhead.bench.tsx",
251
257
  "bench:injection": "vitest bench --config vitest.production-browser-bench.config.ts src/tasty-injection.bench.ts",
258
+ "bench:interaction": "vitest bench --config vitest.production-browser-bench.config.ts src/tasty-interaction.bench.tsx",
259
+ "bench:cold-start": "node scripts/cold-start/index.mjs",
252
260
  "changeset": "changeset",
253
261
  "version": "changeset version",
254
262
  "release": "changeset publish",
@@ -1 +0,0 @@
1
- {"version":3,"file":"astro-CzY4LCpr.js","names":[],"sources":["../src/ssr/astro-extraction.ts","../src/ssr/astro.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport type { ServerStyleArtifact } from './collector';\n\nconst METADATA_START = '<template data-tasty-extract>';\nconst METADATA_END = '</template>';\n\ninterface ExtractablePage {\n path: string;\n html: string;\n artifacts: ServerStyleArtifact[];\n styleStart: number;\n replacementEnd: number;\n styleOpen: string;\n}\n\nexport function createExtractionMetadata(\n artifacts: ServerStyleArtifact[],\n): string {\n const encoded = Buffer.from(JSON.stringify(artifacts), 'utf8').toString(\n 'base64',\n );\n return `${METADATA_START}${encoded}${METADATA_END}`;\n}\n\nfunction parseExtractablePage(\n path: string,\n html: string,\n): ExtractablePage | null {\n const metadataStart = html.indexOf(METADATA_START);\n if (metadataStart === -1) return null;\n\n const metadataContentStart = metadataStart + METADATA_START.length;\n const metadataEnd = html.indexOf(METADATA_END, metadataContentStart);\n if (metadataEnd === -1) return null;\n\n const encoded = html.slice(metadataContentStart, metadataEnd);\n let artifacts: ServerStyleArtifact[];\n try {\n artifacts = JSON.parse(\n Buffer.from(encoded, 'base64').toString('utf8'),\n ) as ServerStyleArtifact[];\n } catch {\n return null;\n }\n\n const styleStart = html.lastIndexOf('<style data-tasty-ssr', metadataStart);\n if (styleStart === -1) return null;\n const styleOpenEnd = html.indexOf('>', styleStart);\n const styleEnd = html.indexOf('</style>', styleOpenEnd + 1);\n if (\n styleOpenEnd === -1 ||\n styleEnd === -1 ||\n styleEnd + '</style>'.length !== metadataStart\n ) {\n return null;\n }\n\n return {\n path,\n html,\n artifacts,\n styleStart,\n replacementEnd: metadataEnd + METADATA_END.length,\n styleOpen: html.slice(styleStart, styleOpenEnd + 1),\n };\n}\n\nasync function findHTMLFiles(dir: string): Promise<string[]> {\n const paths: string[] = [];\n const entries = await readdir(dir, { withFileTypes: true });\n entries.sort((a, b) => a.name.localeCompare(b.name));\n for (const entry of entries) {\n const path = join(dir, entry.name);\n if (entry.isDirectory()) {\n paths.push(...(await findHTMLFiles(path)));\n } else if (entry.isFile() && entry.name.endsWith('.html')) {\n paths.push(path);\n }\n }\n return paths;\n}\n\nfunction skipCSSString(css: string, start: number, quote: string): number {\n for (let index = start + 1; index < css.length; index++) {\n if (css[index] === '\\\\') {\n index++;\n } else if (css[index] === quote) {\n return index + 1;\n }\n }\n return css.length;\n}\n\nfunction decodeCSSEscapes(value: string): string {\n return value.replace(\n /\\\\(?:([\\da-f]{1,6})\\s?|\\r\\n|[\\n\\r\\f]|(.))/gi,\n (_match, hex: string | undefined, escaped: string | undefined) => {\n if (hex) {\n const codePoint = Number.parseInt(hex, 16);\n return codePoint === 0 || codePoint > 0x10ffff\n ? '\\ufffd'\n : String.fromCodePoint(codePoint);\n }\n return escaped ?? '';\n },\n );\n}\n\nfunction readCSSIdentifier(\n css: string,\n start: number,\n): { name: string; end: number } | null {\n let name = '';\n let index = start;\n\n while (index < css.length) {\n const char = css[index];\n if (/[-_a-z\\d]/i.test(char) || char.charCodeAt(0) >= 0x80) {\n name += char;\n index++;\n continue;\n }\n if (char !== '\\\\' || index + 1 >= css.length) break;\n\n const hex = css.slice(index + 1).match(/^[\\da-f]{1,6}/i)?.[0];\n if (hex) {\n name += decodeCSSEscapes(`\\\\${hex}`);\n index += hex.length + 1;\n if (/\\s/.test(css[index] ?? '')) index++;\n continue;\n }\n\n if (/\\r|\\n|\\f/.test(css[index + 1])) break;\n name += css[index + 1];\n index += 2;\n }\n\n return index === start ? null : { name, end: index };\n}\n\nfunction skipCSSWhitespaceAndComments(css: string, start: number): number {\n let index = start;\n for (;;) {\n while (/\\s/.test(css[index] ?? '')) index++;\n if (css[index] !== '/' || css[index + 1] !== '*') return index;\n const commentEnd = css.indexOf('*/', index + 2);\n if (commentEnd === -1) return css.length;\n index = commentEnd + 2;\n }\n}\n\ninterface UnsafeCSSResource {\n url: string;\n rootRelative: boolean;\n}\n\nfunction classifyCSSResource(\n rawURL: string,\n rejectRootRelative: boolean,\n): UnsafeCSSResource | null {\n const url = decodeCSSEscapes(rawURL).trim();\n if (!url || url.startsWith('//') || /^[a-z][a-z\\d+.-]*:/i.test(url)) {\n return null;\n }\n if (url.startsWith('/')) {\n return rejectRootRelative ? { url: rawURL, rootRelative: true } : null;\n }\n return { url: rawURL, rootRelative: false };\n}\n\nfunction findUnsafeCSSResource(\n css: string,\n rejectRootRelative: boolean,\n): UnsafeCSSResource | null {\n const functionStack: (string | null)[] = [];\n const stringResourceFunctions = new Set([\n 'image',\n 'image-set',\n '-webkit-image-set',\n 'src',\n ]);\n\n for (let index = 0; index < css.length; index++) {\n if (css[index] === '/' && css[index + 1] === '*') {\n const commentEnd = css.indexOf('*/', index + 2);\n index = commentEnd === -1 ? css.length : commentEnd + 1;\n continue;\n }\n\n const quote = css[index];\n if (quote === '\"' || quote === \"'\") {\n const stringEnd = skipCSSString(css, index, quote);\n if (stringResourceFunctions.has(functionStack.at(-1) ?? '')) {\n const unsafe = classifyCSSResource(\n css.slice(index + 1, stringEnd - 1),\n rejectRootRelative,\n );\n if (unsafe) return unsafe;\n }\n index = stringEnd - 1;\n continue;\n }\n\n if (css[index] === ')') {\n functionStack.pop();\n continue;\n }\n\n if (css[index] === '(') {\n functionStack.push(null);\n continue;\n }\n\n if (css[index] === '@') {\n const atRule = readCSSIdentifier(css, index + 1);\n if (atRule?.name.toLowerCase() === 'import') {\n const valueStart = skipCSSWhitespaceAndComments(css, atRule.end);\n const importQuote = css[valueStart];\n if (importQuote === '\"' || importQuote === \"'\") {\n const valueEnd = skipCSSString(css, valueStart, importQuote);\n const unsafe = classifyCSSResource(\n css.slice(valueStart + 1, valueEnd - 1),\n rejectRootRelative,\n );\n if (unsafe) return unsafe;\n }\n }\n continue;\n }\n\n const identifier = readCSSIdentifier(css, index);\n if (!identifier || css[identifier.end] !== '(') continue;\n\n const functionName = identifier.name.toLowerCase();\n if (functionName !== 'url') {\n functionStack.push(functionName);\n index = identifier.end;\n continue;\n }\n\n const valueStart = skipCSSWhitespaceAndComments(css, identifier.end + 1);\n const urlQuote = css[valueStart];\n const quoted = urlQuote === '\"' || urlQuote === \"'\";\n let valueEnd: number;\n if (quoted) {\n valueEnd = skipCSSString(css, valueStart, urlQuote) - 1;\n index = css.indexOf(')', valueEnd + 1);\n } else {\n valueEnd = valueStart;\n while (valueEnd < css.length && css[valueEnd] !== ')') {\n if (css[valueEnd] === '\\\\') valueEnd++;\n valueEnd++;\n }\n index = valueEnd;\n }\n\n if (index === -1) return null;\n const unsafe = classifyCSSResource(\n css.slice(valueStart + (quoted ? 1 : 0), valueEnd),\n rejectRootRelative,\n );\n if (unsafe) return unsafe;\n }\n\n return null;\n}\n\nfunction crossOriginAssetsPrefix(\n assetsPrefix?: string | Record<string, string>,\n site?: URL,\n): string | null {\n if (!assetsPrefix) return null;\n const prefix =\n typeof assetsPrefix === 'string'\n ? assetsPrefix\n : assetsPrefix.css || assetsPrefix.fallback;\n if (!/^(?:[a-z][a-z\\d+.-]*:|\\/\\/)/i.test(prefix)) return null;\n if (!site) return prefix;\n\n try {\n const prefixURL = prefix.startsWith('//')\n ? new URL(`${site.protocol}${prefix}`)\n : new URL(prefix);\n return prefixURL.origin === site.origin ? null : prefix;\n } catch {\n return prefix;\n }\n}\n\nfunction validateExtractedURLs(\n pages: ExtractablePage[],\n assetsPrefix?: string | Record<string, string>,\n site?: URL,\n): void {\n const externalPrefix = crossOriginAssetsPrefix(assetsPrefix, site);\n for (const page of pages) {\n for (const artifact of page.artifacts) {\n const unsafe = findUnsafeCSSResource(\n artifact.css,\n externalPrefix !== null,\n );\n if (unsafe) {\n const reason = unsafe.rootRelative\n ? `root-relative CSS URL \"${unsafe.url}\" would resolve against the external assetsPrefix \"${externalPrefix}\" instead of the page origin`\n : `page-relative CSS URL \"${unsafe.url}\" cannot preserve its target`;\n throw new Error(\n `[Tasty] Astro CSS extraction cannot preserve ${reason} in ${page.path} (${artifact.kind} artifact ${artifact.id}). Use an absolute URL or a data URL${externalPrefix ? '' : ', or a root-relative URL such as url(/path/to/asset)'}.`,\n );\n }\n }\n }\n}\n\n/** Find artifacts emitted by every styled page, in the first page's order. */\nfunction selectSharedArtifacts(\n pages: ExtractablePage[],\n): ServerStyleArtifact[] {\n if (pages.length < 2) return [];\n\n const source = pages[0].artifacts;\n const otherIds = pages\n .slice(1)\n .map((page) => new Set(page.artifacts.map(({ id }) => id)));\n\n return source.filter(({ id }) => otherIds.every((ids) => ids.has(id)));\n}\n\nfunction stylesheetHref(\n base: string,\n assets: string,\n filename: string,\n assetsPrefix?: string | Record<string, string>,\n): string {\n const assetsPath = assets.replace(/^\\/+|\\/+$/g, '');\n if (assetsPrefix) {\n const prefix =\n typeof assetsPrefix === 'string'\n ? assetsPrefix\n : assetsPrefix.css || assetsPrefix.fallback;\n return `${prefix.replace(/\\/+$/g, '')}/${assetsPath}/${filename}`;\n }\n\n const basePath = base === '/' ? '' : `/${base.replace(/^\\/+|\\/+$/g, '')}`;\n return `${basePath}/${assetsPath}/${filename}`;\n}\n\nfunction stylesheetLink(page: ExtractablePage, href: string): string {\n const nonceAttr = page.styleOpen.match(/\\snonce=\"[^\"]*\"/)?.[0] ?? '';\n return `<link rel=\"stylesheet\" href=\"${href}\" data-tasty-ssr${nonceAttr}>`;\n}\n\nfunction transformPage(page: ExtractablePage, hrefs: string[]): string {\n const replacement = hrefs.map((href) => stylesheetLink(page, href)).join('');\n\n return (\n page.html.slice(0, page.styleStart) +\n replacement +\n page.html.slice(page.replacementEnd)\n );\n}\n\nasync function writeStylesheet(\n assetDir: string,\n scope: 'shared' | 'page',\n artifacts: ServerStyleArtifact[],\n): Promise<string | null> {\n if (artifacts.length === 0) return null;\n\n const css = artifacts.map(({ css }) => css).join('\\n');\n const hash = createHash('sha256').update(css).digest('hex').slice(0, 12);\n const filename = `tasty.${scope}.${hash}.css`;\n await writeFile(join(assetDir, filename), css);\n return filename;\n}\n\nexport async function extractAstroCSS(options: {\n dir: URL;\n base: string;\n assets: string;\n assetsPrefix?: string | Record<string, string>;\n site?: URL;\n}): Promise<void> {\n const outputDir = fileURLToPath(options.dir);\n const paths = await findHTMLFiles(outputDir);\n const pages = (\n await Promise.all(\n paths.map(async (path) =>\n parseExtractablePage(path, await readFile(path, 'utf8')),\n ),\n )\n ).filter((page): page is ExtractablePage => page !== null);\n if (pages.length === 0) return;\n validateExtractedURLs(pages, options.assetsPrefix, options.site);\n\n const shared = selectSharedArtifacts(pages);\n const assetDir = join(outputDir, options.assets);\n await mkdir(assetDir, { recursive: true });\n const sharedFilename = await writeStylesheet(assetDir, 'shared', shared);\n const sharedHref = sharedFilename\n ? stylesheetHref(\n options.base,\n options.assets,\n sharedFilename,\n options.assetsPrefix,\n )\n : null;\n const sharedIds = new Set(shared.map(({ id }) => id));\n\n for (const page of pages) {\n const remainder = page.artifacts.filter(({ id }) => !sharedIds.has(id));\n const pageFilename = await writeStylesheet(assetDir, 'page', remainder);\n const hrefs = sharedHref ? [sharedHref] : [];\n if (pageFilename) {\n hrefs.push(\n stylesheetHref(\n options.base,\n options.assets,\n pageFilename,\n options.assetsPrefix,\n ),\n );\n }\n await writeFile(page.path, transformPage(page, hrefs));\n }\n}\n","/**\n * Astro integration for Tasty SSR.\n *\n * Provides:\n * - tastyIntegration() — Astro Integration API (recommended)\n * - tastyMiddleware() — manual middleware for advanced composition\n *\n * Import from '@tenphi/tasty/ssr/astro'.\n */\n\nimport { getConfig } from '../config';\nimport { getSSRCollector, runWithCollector } from './async-storage';\nimport { createExtractionMetadata, extractAstroCSS } from './astro-extraction';\nimport { ServerStyleCollector } from './collector';\nimport { registerSSRCollectorGetterGlobal } from './ssr-collector-ref';\n\n// Wire up ALS-based collector discovery so computeStyles() can find\n// the collector set by tastyMiddleware's runWithCollector().\n// Uses globalThis so the getter is visible across Astro's separate\n// module graphs (middleware vs page components).\nregisterSSRCollectorGetterGlobal(getSSRCollector);\n\nexport interface TastyMiddlewareOptions {\n /**\n * Whether to embed the class-list script for client hydration.\n * Set to false to skip class transfer (e.g. for CSP restrictions).\n * Without it, client components may re-inject CSS that already exists\n * in server-rendered `<style>` tags. Default: true.\n */\n transferCache?: boolean;\n}\n\ninterface InternalTastyMiddlewareOptions extends TastyMiddlewareOptions {\n extractionMetadata?: boolean;\n}\n\n/**\n * Create an Astro middleware that collects Tasty styles during SSR.\n *\n * All React components rendered during the request will have their\n * computeStyles() calls captured by the collector via AsyncLocalStorage.\n * After rendering, the middleware injects the collected CSS into </head>.\n *\n * @example Manual middleware setup\n * ```ts\n * // src/middleware.ts\n * import { tastyMiddleware } from '@tenphi/tasty/ssr/astro';\n * export const onRequest = tastyMiddleware();\n * ```\n *\n * @example Composing with other middleware\n * ```ts\n * // src/middleware.ts\n * import { sequence } from 'astro:middleware';\n * import { tastyMiddleware } from '@tenphi/tasty/ssr/astro';\n *\n * export const onRequest = sequence(\n * tastyMiddleware(),\n * myOtherMiddleware,\n * );\n * ```\n */\nexport function tastyMiddleware(options?: TastyMiddlewareOptions) {\n const internalOptions = options as InternalTastyMiddlewareOptions | undefined;\n return async (\n context: { isPrerendered?: boolean },\n next: () => Promise<Response>,\n ): Promise<Response> => {\n const transferCache = options?.transferCache ?? true;\n const extractionMetadata =\n internalOptions?.extractionMetadata === true &&\n context.isPrerendered === true;\n const collector = new ServerStyleCollector();\n\n // Run the entire request — including body stream consumption — inside\n // the ALS context so that components rendering lazily during stream\n // reads can still find the collector via getSSRCollector().\n type Rendered =\n | { response: Response }\n | { html: string | null; status: number; headers: Headers };\n\n const rendered = await runWithCollector<Promise<Rendered>>(\n collector,\n async (): Promise<Rendered> => {\n const response = await next();\n const body = response.body;\n\n // Only process HTML responses. Reading a non-HTML body (e.g. an\n // image, font, or JSON endpoint) as UTF-8 text corrupts binary\n // payloads: every byte >= 0x80 is decoded to U+FFFD and re-encoded\n // as EF BF BD. Pass anything that isn't HTML straight through.\n const contentType = response.headers.get('content-type') ?? '';\n if (!body || !contentType.includes('text/html')) {\n return { response };\n }\n\n const reader = body.pipeThrough(new TextDecoderStream()).getReader();\n const parts: string[] = [];\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n parts.push(value);\n }\n return {\n html: parts.join(''),\n status: response.status,\n headers: response.headers,\n };\n },\n );\n\n // Non-HTML responses are returned untouched to avoid corrupting\n // binary payloads.\n if ('response' in rendered) {\n return rendered.response;\n }\n\n if (!rendered.html) {\n return new Response(null, {\n status: rendered.status,\n headers: rendered.headers,\n });\n }\n\n let { html } = rendered;\n\n const css = collector.getCSS();\n if (!css) {\n return new Response(html, {\n status: rendered.status,\n headers: rendered.headers,\n });\n }\n\n const nonce = getConfig().nonce;\n const nonceAttr = nonce ? ` nonce=\"${nonce}\"` : '';\n const styleTag = `<style data-tasty-ssr${nonceAttr}>${css}</style>`;\n const metadataTag = extractionMetadata\n ? createExtractionMetadata(collector.getArtifacts())\n : '';\n\n let cacheTag = '';\n if (transferCache) {\n const classNames = collector.getRenderedClassNames();\n if (classNames.length > 0) {\n const classListJSON = classNames.map((n) => `\"${n}\"`).join(',');\n cacheTag = `<script${nonceAttr}>(window.__TASTY__=window.__TASTY__||[]).push(${classListJSON})</script>`;\n }\n }\n\n const injection = styleTag + metadataTag + cacheTag;\n const idx = html.indexOf('</head>');\n if (idx !== -1) {\n html = html.slice(0, idx) + injection + html.slice(idx);\n } else {\n html = injection + html;\n }\n\n const headers = new Headers(rendered.headers);\n headers.delete('content-length');\n\n return new Response(html, {\n status: rendered.status,\n headers,\n });\n };\n}\n\n// ============================================================================\n// Astro Integration API\n// ============================================================================\n\n/**\n * Package subpaths of the middleware entrypoints registered by\n * `tastyIntegration()`.\n *\n * These must be bare specifiers rather than\n * `new URL('./astro-middleware.js', import.meta.url)`. The bundler is free to\n * hoist `tastyIntegration` into a shared chunk at a different directory depth\n * than `dist/ssr/`, which makes a relative URL resolve to a file that does not\n * exist and breaks the build for every consumer. A package subpath is resolved\n * by the consumer through our `exports` map, so it never depends on the\n * chunk layout.\n *\n * There are separate entrypoints instead of one parameterised entrypoint because\n * `addMiddleware()` cannot pass options: the integration runs when the Astro\n * config is loaded, while the middleware module is evaluated in the server\n * runtime — a different process for built output — so module-level state set\n * by the integration is not visible to the middleware.\n */\nconst MIDDLEWARE_ENTRYPOINT = '@tenphi/tasty/ssr/astro-middleware';\nconst MIDDLEWARE_ENTRYPOINT_STATIC =\n '@tenphi/tasty/ssr/astro-middleware-static';\nconst MIDDLEWARE_ENTRYPOINT_EXTRACT =\n '@tenphi/tasty/ssr/astro-middleware-extract';\nconst MIDDLEWARE_ENTRYPOINT_EXTRACT_STATIC =\n '@tenphi/tasty/ssr/astro-middleware-extract-static';\n\nexport interface TastyIntegrationCSSOptions {\n /**\n * CSS delivery mode. Extraction only applies to prerendered builds.\n * Extracted CSS preserves resource URLs verbatim, so use absolute URLs or\n * data URLs. Root-relative URLs are also supported unless `assetsPrefix`\n * sends CSS to an external origin. The build rejects resource URLs whose\n * targets would change after extraction.\n */\n mode?: 'inline' | 'extract';\n}\n\nexport interface TastyIntegrationOptions {\n /**\n * Enable island hydration support.\n *\n * When `true` (default): injects a client hydration script via\n * `injectScript('before-hydration')` and sets `transferCache: true`\n * on the middleware. Islands skip the style pipeline during hydration.\n *\n * When `false`: no client JS is shipped and `transferCache` is set\n * to `false`. Use this for fully static sites without `client:*`\n * directives.\n */\n islands?: boolean;\n /** Configure inline or build-wide extracted CSS delivery. */\n css?: TastyIntegrationCSSOptions;\n}\n\n/**\n * Astro integration that automatically sets up Tasty SSR.\n *\n * Registers middleware for cross-component CSS deduplication and\n * optionally injects a client hydration script for island support.\n *\n * @example Basic setup (with islands)\n * ```ts\n * // astro.config.mjs\n * import { tastyIntegration } from '@tenphi/tasty/ssr/astro';\n *\n * export default defineConfig({\n * integrations: [tastyIntegration()],\n * });\n * ```\n *\n * @example Static-only (no client JS)\n * ```ts\n * // astro.config.mjs\n * import { tastyIntegration } from '@tenphi/tasty/ssr/astro';\n *\n * export default defineConfig({\n * integrations: [tastyIntegration({ islands: false })],\n * });\n * ```\n */\nexport function tastyIntegration(options?: TastyIntegrationOptions) {\n const { islands = true } = options ?? {};\n const cssMode = options?.css?.mode ?? 'inline';\n let base = '/';\n let assets = '_astro';\n let assetsPrefix: string | Record<string, string> | undefined;\n let site: URL | undefined;\n\n return {\n name: '@tenphi/tasty',\n hooks: {\n 'astro:config:setup': ({\n addMiddleware,\n injectScript,\n }: {\n addMiddleware: (middleware: {\n entrypoint: string | URL;\n order: 'pre' | 'post';\n }) => void;\n injectScript: (\n stage: 'head-inline' | 'before-hydration' | 'page' | 'page-ssr',\n content: string,\n ) => void;\n }) => {\n addMiddleware({\n entrypoint:\n cssMode === 'extract'\n ? islands\n ? MIDDLEWARE_ENTRYPOINT_EXTRACT\n : MIDDLEWARE_ENTRYPOINT_EXTRACT_STATIC\n : islands\n ? MIDDLEWARE_ENTRYPOINT\n : MIDDLEWARE_ENTRYPOINT_STATIC,\n order: 'pre',\n });\n\n if (islands) {\n injectScript(\n 'before-hydration',\n `import \"@tenphi/tasty/ssr/astro-client\";`,\n );\n }\n },\n 'astro:config:done': ({\n config,\n }: {\n config: {\n base?: string;\n site?: URL;\n build?: {\n assets?: string;\n assetsPrefix?: string | Record<string, string>;\n };\n };\n }) => {\n base = config.base ?? '/';\n assets = config.build?.assets ?? '_astro';\n assetsPrefix = config.build?.assetsPrefix;\n site = config.site;\n },\n 'astro:build:done': async ({ dir }: { dir: URL }) => {\n if (cssMode !== 'extract') return;\n await extractAstroCSS({ dir, base, assets, assetsPrefix, site });\n },\n },\n };\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"format-rules-rCZ37rqY.js","names":[],"sources":["../src/ssr/ssr-collector-ref.ts","../src/ssr/format-property.ts","../src/ssr/format-rules.ts"],"sourcesContent":["/**\n * Global reference to the SSR collector getter function.\n *\n * This indirection avoids importing 'node:async_hooks' in the browser bundle.\n * The SSR entry point sets this ref when loaded on the server. The useStyles\n * hook calls it if set; on the client it stays null and is never called.\n *\n * Uses a module-level variable as the primary mechanism. In Next.js App\n * Router the RSC and SSR module graphs load separate copies of this module,\n * so the getter registered by TastyRegistry (SSR layer) is invisible to\n * server components (RSC layer) — which correctly fall through to inline\n * RSC styles.\n *\n * A globalThis fallback (`registerSSRCollectorGetterGlobal`) is provided\n * for frameworks like Astro where middleware and page components live in\n * different module graphs and must share the getter across them.\n */\n\nimport type { ServerStyleCollector } from './collector';\n\ntype SSRCollectorGetter = () => ServerStyleCollector | null;\n\nconst GETTER_KEY = '__tasty_ssr_collector_getter__';\n\nlet _getSSRCollector: SSRCollectorGetter | null = null;\n\n/**\n * Register the collector getter in the current module graph only.\n * Used by Next.js TastyRegistry.\n */\nexport function registerSSRCollectorGetter(fn: SSRCollectorGetter): void {\n _getSSRCollector = fn;\n}\n\n/**\n * Register the collector getter on globalThis so it is visible across\n * separate module graphs (e.g. Astro middleware ↔ page components).\n */\nexport function registerSSRCollectorGetterGlobal(fn: SSRCollectorGetter): void {\n (globalThis as Record<string, unknown>)[GETTER_KEY] = fn;\n}\n\n/**\n * Retrieve the SSR collector: module-level first, globalThis fallback.\n */\nexport function getRegisteredSSRCollector(): ServerStyleCollector | null {\n if (_getSSRCollector) return _getSSRCollector();\n const getter = (globalThis as Record<string, unknown>)[GETTER_KEY] as\n SSRCollectorGetter | undefined;\n return getter ? getter() : null;\n}\n","/**\n * Format @property CSS rules for SSR output.\n *\n * Replicates the CSS construction from StyleInjector.property()\n * but returns a CSS string instead of inserting into the DOM.\n */\n\nimport type { PropertyDefinition } from '../injector/types';\nimport { getEffectiveDefinition } from '../properties';\nimport type { StyleValue } from '../utils/styles';\nimport { parseStyle } from '../utils/styles';\n\n/**\n * Format a single @property rule as a CSS string.\n *\n * Returns the full `@property --name { ... }` text, or empty string\n * if the token is invalid.\n */\nexport function formatPropertyCSS(\n token: string,\n definition: PropertyDefinition,\n): string {\n const result = getEffectiveDefinition(token, definition);\n if (!result.isValid) return '';\n\n return buildPropertyRule(result.cssName, result.definition);\n}\n\nfunction buildPropertyRule(\n cssName: string,\n definition: PropertyDefinition,\n): string {\n const parts: string[] = [];\n\n if (definition.syntax != null) {\n let syntax = String(definition.syntax).trim();\n if (!/^['\"]/u.test(syntax)) syntax = `\"${syntax}\"`;\n parts.push(`syntax: ${syntax};`);\n }\n\n const inherits = definition.inherits ?? true;\n parts.push(`inherits: ${inherits ? 'true' : 'false'};`);\n\n if (definition.initialValue != null) {\n let initialValueStr: string;\n if (typeof definition.initialValue === 'number') {\n initialValueStr = String(definition.initialValue);\n } else {\n initialValueStr = parseStyle(\n definition.initialValue as StyleValue,\n ).output;\n }\n parts.push(`initial-value: ${initialValueStr};`);\n }\n\n const declarations = parts.join(' ').trim();\n return `@property ${cssName} { ${declarations} }`;\n}\n","/**\n * Shared CSS rule formatting utility.\n *\n * Extracted from SheetManager to allow both the DOM-based injector (client)\n * and the ServerStyleCollector (server) to produce identical CSS text\n * from StyleResult arrays.\n */\n\nimport type { StyleResult } from '../pipeline';\n\n/**\n * Resolve selectors for a rule, applying className-based specificity doubling\n * and rootPrefix handling. Mirrors the logic in StyleInjector.inject().\n */\nfunction resolveSelector(rule: StyleResult, className: string): string {\n let selector = rule.selector;\n\n if (rule.needsClassName) {\n const selectorParts = selector ? selector.split('|||') : [''];\n const classPrefix = `.${className}.${className}`;\n\n selector = selectorParts\n .map((part) => {\n const classSelector = part ? `${classPrefix}${part}` : classPrefix;\n\n if (rule.rootPrefix) {\n return `${rule.rootPrefix} ${classSelector}`;\n }\n return classSelector;\n })\n .join(', ');\n }\n\n return selector;\n}\n\ninterface GroupedRule {\n selector: string;\n declarations: string;\n atRules?: string[];\n startingStyle?: boolean;\n}\n\n/**\n * Group rules by selector + at-rules + startingStyle and merge their declarations.\n * Mirrors the grouping logic in SheetManager.insertRule().\n */\nfunction groupRules(rules: GroupedRule[]): GroupedRule[] {\n const groupMap = new Map<string, GroupedRule>();\n const order: string[] = [];\n\n const atKey = (at?: string[]) => (at && at.length ? at.join('|') : '');\n\n for (const r of rules) {\n const key = `${atKey(r.atRules)}||${r.selector}||${r.startingStyle ? '1' : '0'}`;\n const existing = groupMap.get(key);\n if (existing) {\n existing.declarations = existing.declarations\n ? `${existing.declarations} ${r.declarations}`\n : r.declarations;\n } else {\n groupMap.set(key, {\n selector: r.selector,\n atRules: r.atRules,\n startingStyle: r.startingStyle,\n declarations: r.declarations,\n });\n order.push(key);\n }\n }\n\n return order.map((key) => groupMap.get(key)!);\n}\n\n/**\n * Format an array of StyleResult rules into a CSS text string.\n *\n * Applies className-based specificity doubling (.cls.cls),\n * groups rules by selector + at-rules, and wraps with at-rule blocks.\n *\n * Produces the same CSS text as SheetManager.insertRule() would insert\n * into the DOM, but as a plain string suitable for SSR output.\n */\nexport function formatRules(rules: StyleResult[], className: string): string {\n if (rules.length === 0) return '';\n\n const resolvedRules = rules.map((rule) => ({\n selector: resolveSelector(rule, className),\n declarations: rule.declarations,\n atRules: rule.atRules,\n startingStyle: rule.startingStyle,\n }));\n\n const grouped = groupRules(resolvedRules);\n const cssRules: string[] = [];\n\n for (const rule of grouped) {\n const innerContent = rule.startingStyle\n ? `@starting-style { ${rule.declarations} }`\n : rule.declarations;\n const baseRule = `${rule.selector} { ${innerContent} }`;\n\n let fullRule = baseRule;\n if (rule.atRules && rule.atRules.length > 0) {\n fullRule = rule.atRules.reduce(\n (css, atRule) => `${atRule} { ${css} }`,\n baseRule,\n );\n }\n\n cssRules.push(fullRule);\n }\n\n return cssRules.join('\\n');\n}\n"],"mappings":"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"}