describe-me 0.4.0 → 0.5.0-next.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/README.md CHANGED
@@ -14,6 +14,9 @@ writes, and replays the snapshots in a sandboxed iframe.
14
14
  pnpm add -D describe-me @describe-me/vitest @describe-me/react
15
15
  ```
16
16
 
17
+ `vite` is a peer dependency (Vitest requires it anyway): pnpm and npm install
18
+ it automatically; with yarn, add `vite` to your `devDependencies`.
19
+
17
20
  ## Usage
18
21
 
19
22
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "describe-me",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-next.0",
4
4
  "description": "Living component documentation generated from your Vitest tests: viewer and CLI",
5
5
  "keywords": [
6
6
  "vitest",
@@ -21,7 +21,7 @@
21
21
  "bugs": "https://github.com/grzehub/describe-me/issues",
22
22
  "type": "module",
23
23
  "engines": {
24
- "node": "^20.19.0 || >=22.12.0"
24
+ "node": "^20.16.0 || >=22.4.0"
25
25
  },
26
26
  "publishConfig": {
27
27
  "access": "public",
@@ -42,11 +42,14 @@
42
42
  "sideEffects": false,
43
43
  "dependencies": {
44
44
  "rrweb-snapshot": "^2.1.6",
45
- "vite": "^8.3.0",
46
- "@describe-me/core": "^0.4.0"
45
+ "@describe-me/core": "^0.5.0-next.0"
46
+ },
47
+ "peerDependencies": {
48
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
47
49
  },
48
50
  "devDependencies": {
49
- "@types/node": "^22"
51
+ "@types/node": "^22",
52
+ "vite": "^8.3.0"
50
53
  },
51
54
  "scripts": {
52
55
  "build": "rimraf dist-cli && tsc -p tsconfig.cli.json",
package/src/data.ts CHANGED
@@ -1,10 +1,10 @@
1
- import type { rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
2
- import { ASSET_URL_PREFIX, type Manifest } from '@describe-me/core/types'
1
+ import type { Manifest } from '@describe-me/core/types'
3
2
  import { allTests, currentTest, state } from './state.js'
4
3
  import { rerender } from './rerender.js'
5
-
6
- /** rrweb's serialized document node; the package does not re-export the type. */
7
- type SerializedNode = Parameters<typeof rebuildIntoSandboxedIframe>[0]
4
+ import { resolveAssetUrls } from './resolve-asset-urls.js'
5
+ import { restoreStyles } from './restore-styles.js'
6
+ import type { SerializedNode } from './serialized-node.js'
7
+ import { withoutEmptyTests } from './without-empty-tests.js'
8
8
 
9
9
  const snapshotCache = new Map<string, Promise<SerializedNode>>()
10
10
 
@@ -18,7 +18,7 @@ export async function loadManifest(): Promise<void> {
18
18
  throw new Error(`manifest: ${response.status}`)
19
19
  }
20
20
 
21
- const next = (await response.json()) as Manifest
21
+ const next = withoutEmptyTests((await response.json()) as Manifest)
22
22
  if (state.manifest?.generatedAt === next.generatedAt) {
23
23
  return
24
24
  }
@@ -40,10 +40,10 @@ export async function loadManifest(): Promise<void> {
40
40
  }
41
41
 
42
42
  /**
43
- * Fetch one serialized DOM, memoized until the next manifest load. Asset URLs
44
- * are made absolute here, against the page, because the snapshot replays in a
45
- * sandboxed iframe that has no base URL of its own. The stage and the gallery
46
- * thumbnails both load through this.
43
+ * Fetch one serialized DOM, memoized until the next manifest load. The promise
44
+ * resolves with asset URLs made absolute and stored stylesheets put back, so
45
+ * the stage and the gallery thumbnails always rebuild complete CSS and measure
46
+ * styled boxes.
47
47
  */
48
48
  export function loadSnapshot(path: string): Promise<SerializedNode> {
49
49
  let pending = snapshotCache.get(path)
@@ -51,6 +51,11 @@ export function loadSnapshot(path: string): Promise<SerializedNode> {
51
51
  pending = fetch(`__data/${path}`, { cache: 'no-store' })
52
52
  .then((response) => response.text())
53
53
  .then((json) => parseSnapshot(json))
54
+ .then(async (node) => {
55
+ await restoreStyles(node)
56
+
57
+ return node
58
+ })
54
59
 
55
60
  snapshotCache.set(path, pending)
56
61
  }
@@ -59,7 +64,5 @@ export function loadSnapshot(path: string): Promise<SerializedNode> {
59
64
  }
60
65
 
61
66
  function parseSnapshot(json: string): SerializedNode {
62
- const assetsUrl = new URL('__data/assets/', location.href).href
63
-
64
- return JSON.parse(json.replaceAll(ASSET_URL_PREFIX, assetsUrl)) as SerializedNode
67
+ return JSON.parse(resolveAssetUrls(json)) as SerializedNode
65
68
  }
@@ -0,0 +1,66 @@
1
+ import { resolveAssetUrls } from './resolve-asset-urls.js'
2
+
3
+ /**
4
+ * Chunk texts by hash, kept as promises so parallel thumbnails share one
5
+ * request. Never cleared, because a hash always names the same text.
6
+ */
7
+ const chunks = new Map<string, Promise<string | null>>()
8
+
9
+ /** Joined sheets by reference, so every snapshot using a sheet shares one string. */
10
+ const sheets = new Map<string, Promise<string>>()
11
+
12
+ async function fetchChunk(hash: string): Promise<string | null> {
13
+ try {
14
+ // Default caching: the name is a content hash, so a cached copy is always right.
15
+ const response = await fetch(`__data/styles/${hash}.css`)
16
+
17
+ return response.ok ? resolveAssetUrls(await response.text()) : null
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ function loadChunk(hash: string): Promise<string | null> {
24
+ const memo = chunks.get(hash)
25
+ if (memo) {
26
+ return memo
27
+ }
28
+
29
+ const pending = fetchChunk(hash).then((css) => {
30
+ if (css === null) {
31
+ console.warn(`describe-me: style chunk styles/${hash}.css did not load`)
32
+ chunks.delete(hash)
33
+ }
34
+
35
+ return css
36
+ })
37
+
38
+ chunks.set(hash, pending)
39
+
40
+ return pending
41
+ }
42
+
43
+ /**
44
+ * The stylesheet behind a reference, the text after `STYLE_URL_PREFIX`. A chunk
45
+ * that fails to load leaves a gap rather than failing the frame, and is fetched
46
+ * again next time.
47
+ */
48
+ export function loadStyle(reference: string): Promise<string> {
49
+ const memo = sheets.get(reference)
50
+ if (memo) {
51
+ return memo
52
+ }
53
+
54
+ const loads = reference.split('+').map((hash) => loadChunk(hash))
55
+ const pending = Promise.all(loads).then((texts) => {
56
+ if (texts.includes(null)) {
57
+ sheets.delete(reference)
58
+ }
59
+
60
+ return texts.map((text) => text ?? '').join('')
61
+ })
62
+
63
+ sheets.set(reference, pending)
64
+
65
+ return pending
66
+ }
@@ -0,0 +1,4 @@
1
+ import { createCache } from 'rrweb-snapshot'
2
+
3
+ /** rrweb caches its CSS adaptation per CSS text, so the stage and the gallery share one cache. */
4
+ export const replayCache = createCache()
@@ -0,0 +1,9 @@
1
+ import { ASSET_URL_PREFIX } from '@describe-me/core/types'
2
+
3
+ /**
4
+ * Make stored asset URLs absolute against the page, because a snapshot replays
5
+ * in a sandboxed iframe that has no base URL of its own.
6
+ */
7
+ export function resolveAssetUrls(text: string): string {
8
+ return text.replaceAll(ASSET_URL_PREFIX, new URL('__data/assets/', location.href).href)
9
+ }
@@ -0,0 +1,44 @@
1
+ import { STYLE_URL_PREFIX } from '@describe-me/core/types'
2
+ import { loadStyle } from './load-style.js'
3
+ import type { SerializedNode } from './serialized-node.js'
4
+
5
+ /**
6
+ * What the walk reads. `SerializedNode` is `any` here: rrweb-snapshot's types
7
+ * import `@rrweb/types`, which it does not install.
8
+ */
9
+ interface WalkedNode {
10
+ attributes?: Record<string, unknown>
11
+ childNodes?: WalkedNode[]
12
+ }
13
+
14
+ interface StoredSheet {
15
+ attributes: Record<string, unknown>
16
+ reference: string
17
+ }
18
+
19
+ function storedSheets(node: WalkedNode, found: StoredSheet[] = []): StoredSheet[] {
20
+ const cssText = node.attributes?._cssText
21
+ if (node.attributes && typeof cssText === 'string' && cssText.startsWith(STYLE_URL_PREFIX)) {
22
+ found.push({ attributes: node.attributes, reference: cssText.slice(STYLE_URL_PREFIX.length) })
23
+ }
24
+
25
+ for (const child of node.childNodes ?? []) {
26
+ storedSheets(child, found)
27
+ }
28
+
29
+ return found
30
+ }
31
+
32
+ /**
33
+ * Put every stylesheet a snapshot keeps in `styles/` back in place, before
34
+ * rrweb rebuilds it. Snapshots without references pass through unchanged.
35
+ */
36
+ export async function restoreStyles(node: SerializedNode): Promise<void> {
37
+ const stored = storedSheets(node)
38
+
39
+ await Promise.all(
40
+ stored.map(async ({ attributes, reference }) => {
41
+ attributes._cssText = await loadStyle(reference)
42
+ }),
43
+ )
44
+ }
@@ -0,0 +1,4 @@
1
+ import type { rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
2
+
3
+ /** rrweb's serialized document node, which the package does not re-export. */
4
+ export type SerializedNode = Parameters<typeof rebuildIntoSandboxedIframe>[0]
package/src/stage.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { createCache, createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
1
+ import { createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
2
2
  import type { ManifestFrame } from '@describe-me/core/types'
3
3
  import { loadSnapshot } from './data.js'
4
4
  import { fitStage } from './fit-stage.js'
5
+ import { replayCache } from './replay-cache.js'
5
6
  import { state } from './state.js'
6
7
 
7
8
  let paintToken = 0
@@ -18,7 +19,7 @@ export async function paintFrame(stage: HTMLElement, frame: ManifestFrame): Prom
18
19
  const { iframe } = rebuildIntoSandboxedIframe(node, {
19
20
  root: stage,
20
21
  iframeAttributes: { title: 'snapshot' },
21
- cache: createCache(),
22
+ cache: replayCache,
22
23
  mirror: createMirror(),
23
24
  hackCss: true,
24
25
  })
@@ -1,8 +1,9 @@
1
- import { createCache, createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
1
+ import { createMirror, rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
2
2
  import type { ManifestFrame, ManifestTest } from '@describe-me/core/types'
3
3
  import { contentBox } from './content-box.js'
4
4
  import { loadSnapshot } from './data.js'
5
5
  import { el } from './el.js'
6
+ import { replayCache } from './replay-cache.js'
6
7
  import { select } from './state.js'
7
8
 
8
9
  /** Bumped on every gallery render, so snapshots that arrive late are dropped. */
@@ -17,7 +18,7 @@ async function paintThumb(thumb: HTMLElement, frame: ManifestFrame, token: numbe
17
18
  const { iframe } = rebuildIntoSandboxedIframe(node, {
18
19
  root: thumb,
19
20
  iframeAttributes: { title: frame.label },
20
- cache: createCache(),
21
+ cache: replayCache,
21
22
  mirror: createMirror(),
22
23
  hackCss: true,
23
24
  })
@@ -0,0 +1,12 @@
1
+ import type { Manifest } from '@describe-me/core/types'
2
+
3
+ export function withoutEmptyTests(manifest: Manifest): Manifest {
4
+ const modules = manifest.modules
5
+ .map((mod) => ({
6
+ ...mod,
7
+ tests: mod.tests.filter((test) => test.frames.some((frame) => frame.kind !== 'end')),
8
+ }))
9
+ .filter((mod) => mod.tests.length > 0)
10
+
11
+ return { ...manifest, modules }
12
+ }