describe-me 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "describe-me",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Living component documentation generated from your Vitest tests: viewer and CLI",
5
5
  "keywords": [
6
6
  "vitest",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "rrweb-snapshot": "^2.1.6",
45
45
  "vite": "^8.3.0",
46
- "@describe-me/core": "^0.3.0"
46
+ "@describe-me/core": "^0.4.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/node": "^22"
package/src/data.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
2
- import type { Manifest } from '@describe-me/core/types'
2
+ import { ASSET_URL_PREFIX, type Manifest } from '@describe-me/core/types'
3
3
  import { allTests, currentTest, state } from './state.js'
4
4
  import { rerender } from './rerender.js'
5
5
 
@@ -39,16 +39,27 @@ export async function loadManifest(): Promise<void> {
39
39
  rerender()
40
40
  }
41
41
 
42
- /** Fetch one serialized DOM, memoized until the next manifest load. */
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.
47
+ */
43
48
  export function loadSnapshot(path: string): Promise<SerializedNode> {
44
49
  let pending = snapshotCache.get(path)
45
50
  if (!pending) {
46
- pending = fetch(`__data/${path}`, { cache: 'no-store' }).then(
47
- (response) => response.json() as Promise<SerializedNode>,
48
- )
51
+ pending = fetch(`__data/${path}`, { cache: 'no-store' })
52
+ .then((response) => response.text())
53
+ .then((json) => parseSnapshot(json))
49
54
 
50
55
  snapshotCache.set(path, pending)
51
56
  }
52
57
 
53
58
  return pending
54
59
  }
60
+
61
+ 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
65
+ }
@@ -0,0 +1,102 @@
1
+ import { manifestDiagnostics } from '@describe-me/core/diagnostics'
2
+ import type { Manifest } from '@describe-me/core/types'
3
+ import { el } from './el.js'
4
+ import { select } from './state.js'
5
+
6
+ /** Long lists are cut here; the rest is summed up in one line. */
7
+ const LIST_LIMIT = 40
8
+
9
+ function section(title: string, hint: string, items: HTMLElement[]): HTMLElement {
10
+ const shown = items.slice(0, LIST_LIMIT)
11
+ const rest = items.length - shown.length
12
+ const list = el('ul', { class: 'issues-list' }, ...shown)
13
+
14
+ if (rest > 0) {
15
+ list.append(el('li', { class: 'issues-more' }, `+${rest} more`))
16
+ }
17
+
18
+ return el(
19
+ 'section',
20
+ { class: 'issues-section' },
21
+ el('h3', {}, title, el('span', { class: 'issues-count' }, String(items.length))),
22
+ el('p', { class: 'issues-hint' }, hint),
23
+ list,
24
+ )
25
+ }
26
+
27
+ /**
28
+ * A header chip that opens the list of what this manifest could not document:
29
+ * anonymous components, components without props docs and assets that will not
30
+ * load. Absent when there is nothing to report.
31
+ */
32
+ export function renderDiagnosticsMenu(manifest: Manifest): HTMLElement | null {
33
+ const { anonymous, undocumented, assetsMissing } = manifestDiagnostics(manifest)
34
+ const count = anonymous.length + undocumented.length + assetsMissing.length
35
+
36
+ if (count === 0) {
37
+ return null
38
+ }
39
+
40
+ const sections: HTMLElement[] = []
41
+
42
+ if (anonymous.length > 0) {
43
+ const items = anonymous.map((test) =>
44
+ el(
45
+ 'li',
46
+ {},
47
+ el('button', { class: 'issues-link', click: () => select(test.testId) }, test.fullName),
48
+ ),
49
+ )
50
+
51
+ sections.push(
52
+ section(
53
+ 'Anonymous components',
54
+ 'Export the component from a project module (it is named after its export) or give it a displayName.',
55
+ items,
56
+ ),
57
+ )
58
+ }
59
+
60
+ if (undocumented.length > 0) {
61
+ const items = undocumented.map((component) =>
62
+ el(
63
+ 'li',
64
+ {},
65
+ el('span', { class: 'mono' }, component.name),
66
+ el('span', { class: 'issues-meta' }, `${component.tests} tests`),
67
+ ),
68
+ )
69
+
70
+ sections.push(
71
+ section(
72
+ 'No props docs',
73
+ 'Its props could not be read: is it exported from a TypeScript module, and is typescript installed?',
74
+ items,
75
+ ),
76
+ )
77
+ }
78
+
79
+ if (assetsMissing.length > 0) {
80
+ const items = assetsMissing.map((path) => el('li', { class: 'mono' }, path))
81
+
82
+ sections.push(
83
+ section(
84
+ 'Assets not found',
85
+ 'Not found under the project root or public/, so they will not load here.',
86
+ items,
87
+ ),
88
+ )
89
+ }
90
+
91
+ return el(
92
+ 'details',
93
+ { class: 'issues' },
94
+ el(
95
+ 'summary',
96
+ { class: 'stat warn' },
97
+ el('b', {}, String(count)),
98
+ count === 1 ? 'issue' : 'issues',
99
+ ),
100
+ el('div', { class: 'issues-panel' }, ...sections),
101
+ )
102
+ }
package/src/header.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { renderDiagnosticsMenu } from './diagnostics-menu.js'
1
2
  import { el } from './el.js'
2
3
  import { allTests, state } from './state.js'
3
4
 
@@ -5,7 +6,7 @@ function stat(count: number, label: string, tone?: 'pass' | 'fail'): HTMLElement
5
6
  return el('span', { class: tone ? `stat ${tone}` : 'stat' }, el('b', {}, String(count)), label)
6
7
  }
7
8
 
8
- /** Wordmark, pass/fail counts and the time of the last run. */
9
+ /** Wordmark, pass/fail counts, what could not be documented and the time of the last run. */
9
10
  export function renderHeader(): HTMLElement {
10
11
  const manifest = state.manifest
11
12
  const tests = manifest ? allTests(manifest) : []
@@ -26,6 +27,7 @@ export function renderHeader(): HTMLElement {
26
27
  { class: 'header' },
27
28
  wordmark,
28
29
  summary,
30
+ manifest && renderDiagnosticsMenu(manifest),
29
31
  el('span', { class: 'right live' }, 'updated ', el('span', { class: 'mono' }, when)),
30
32
  )
31
33
  }
package/src/style.css CHANGED
@@ -208,6 +208,104 @@ a {
208
208
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--pass) 18%, transparent);
209
209
  }
210
210
 
211
+ /* ---------------------------------------------------------- diagnostics -- */
212
+
213
+ .issues {
214
+ position: relative;
215
+ font-size: 12px;
216
+ }
217
+ .issues > summary {
218
+ padding: 2px 9px;
219
+ border-radius: var(--r-pill);
220
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
221
+ color: var(--warn);
222
+ cursor: pointer;
223
+ list-style: none;
224
+ }
225
+ .issues > summary::-webkit-details-marker {
226
+ display: none;
227
+ }
228
+ .issues > summary b {
229
+ color: inherit;
230
+ }
231
+ .issues[open] > summary {
232
+ box-shadow: 0 0 0 2px var(--ring);
233
+ }
234
+ .issues-panel {
235
+ position: absolute;
236
+ top: calc(100% + 8px);
237
+ left: 0;
238
+ z-index: 20;
239
+ width: 440px;
240
+ max-height: 70vh;
241
+ overflow: auto;
242
+ padding: 6px 0;
243
+ border: 1px solid var(--line);
244
+ border-radius: var(--r-lg);
245
+ background: var(--raised);
246
+ box-shadow: var(--shadow-md);
247
+ }
248
+ .issues-section {
249
+ padding: 10px 16px;
250
+ }
251
+ .issues-section + .issues-section {
252
+ border-top: 1px solid var(--line-soft);
253
+ }
254
+ .issues-section h3 {
255
+ display: flex;
256
+ align-items: baseline;
257
+ gap: 8px;
258
+ margin: 0;
259
+ color: var(--ink);
260
+ font-size: 12px;
261
+ font-weight: 600;
262
+ }
263
+ .issues-count {
264
+ color: var(--ink-3);
265
+ font-weight: 400;
266
+ font-variant-numeric: tabular-nums;
267
+ }
268
+ .issues-hint {
269
+ margin: 4px 0 8px;
270
+ color: var(--ink-3);
271
+ line-height: 1.45;
272
+ }
273
+ .issues-list {
274
+ display: grid;
275
+ gap: 2px;
276
+ margin: 0;
277
+ padding: 0;
278
+ list-style: none;
279
+ }
280
+ .issues-list li {
281
+ display: flex;
282
+ justify-content: space-between;
283
+ gap: 12px;
284
+ overflow: hidden;
285
+ color: var(--ink-2);
286
+ text-overflow: ellipsis;
287
+ white-space: nowrap;
288
+ }
289
+ .issues-link {
290
+ overflow: hidden;
291
+ padding: 2px 0;
292
+ border: 0;
293
+ background: none;
294
+ color: var(--accent);
295
+ font: inherit;
296
+ text-align: left;
297
+ text-overflow: ellipsis;
298
+ white-space: nowrap;
299
+ cursor: pointer;
300
+ }
301
+ .issues-link:hover {
302
+ text-decoration: underline;
303
+ }
304
+ .issues-meta,
305
+ .issues-more {
306
+ color: var(--ink-3);
307
+ }
308
+
211
309
  /* -------------------------------------------------------------- sidebar -- */
212
310
 
213
311
  .sidebar {
package/vite.config.ts CHANGED
@@ -1,7 +1,31 @@
1
1
  import { cpSync, existsSync, readFileSync, statSync, watch } from 'node:fs'
2
- import { dirname, join, resolve, sep } from 'node:path'
2
+ import { dirname, extname, join, resolve, sep } from 'node:path'
3
3
  import { defineConfig, type Plugin } from 'vite'
4
4
 
5
+ /** Content types of what the data directory holds: the manifest, snapshots and copied project assets. */
6
+ const CONTENT_TYPES: Record<string, string> = {
7
+ '.json': 'application/json',
8
+ '.css': 'text/css',
9
+ '.svg': 'image/svg+xml',
10
+ '.png': 'image/png',
11
+ '.jpg': 'image/jpeg',
12
+ '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.webp': 'image/webp',
15
+ '.avif': 'image/avif',
16
+ '.ico': 'image/x-icon',
17
+ '.woff': 'font/woff',
18
+ '.woff2': 'font/woff2',
19
+ '.ttf': 'font/ttf',
20
+ '.otf': 'font/otf',
21
+ '.mp4': 'video/mp4',
22
+ '.webm': 'video/webm',
23
+ }
24
+
25
+ function contentType(file: string): string {
26
+ return CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'
27
+ }
28
+
5
29
  /** Where the manifest and snapshots live. The CLI sets this; the default is the example project. */
6
30
  function dataDir(): string {
7
31
  return resolve(process.env.DESCRIBE_ME_DIR ?? '../../examples/react-browser/.describe-me')
@@ -60,7 +84,7 @@ function describeMeData(): Plugin {
60
84
  return next()
61
85
  }
62
86
 
63
- response.setHeader('Content-Type', 'application/json')
87
+ response.setHeader('Content-Type', contentType(absolute))
64
88
  response.setHeader('Cache-Control', 'no-store')
65
89
  response.end(readFileSync(absolute))
66
90
  })