shelving 1.269.0 → 1.271.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.
@@ -29,15 +29,17 @@ export declare class FileExtractor extends Extractor<BunFile, TreeElement> {
29
29
  * Build the file element props from the extracted content.
30
30
  * - `name` is the basename without extension (e.g. `"array"`) — display-ready, used by menus, cards, and URL paths.
31
31
  * - Override to parse `text` into richer elements (content/children/description) and to set
32
- * `title` if a confident title is available.
32
+ * `title` if a confident title is available. Overrides may be async (e.g. `TypescriptExtractor` parses via the native compiler server) — `extract()` awaits the result either way.
33
33
  *
34
34
  * @param name The basename of the file without extension (e.g. `"array"`).
35
35
  * @param content The raw text content of the file.
36
- * @returns The element props, always including a `name`; the base implementation stores `content` verbatim.
36
+ * @returns The element props (or a promise resolving to them), always including a `name`; the base implementation stores `content` verbatim.
37
37
  * @example extractProps("notes", "Some text") // { name: "notes", content: "Some text" }
38
38
  * @see https://shelving.cc/extract/FileExtractor/extractProps
39
39
  */
40
- extractProps(name: string, content: string): Partial<TreeElementProps> & {
40
+ extractProps(name: string, content: string): (Partial<TreeElementProps> & {
41
41
  name: string;
42
- };
42
+ }) | Promise<Partial<TreeElementProps> & {
43
+ name: string;
44
+ }>;
43
45
  }
@@ -32,7 +32,7 @@ export class FileExtractor extends Extractor {
32
32
  const filename = splitPath(source).at(-1) ?? "unnamed";
33
33
  const [base = filename] = splitFileExtension(filename);
34
34
  const text = await file.text();
35
- const props = { ...this.extractProps(base, text), source };
35
+ const props = { ...(await this.extractProps(base, text)), source };
36
36
  return {
37
37
  type: "tree-element",
38
38
  key: filename,
@@ -43,11 +43,11 @@ export class FileExtractor extends Extractor {
43
43
  * Build the file element props from the extracted content.
44
44
  * - `name` is the basename without extension (e.g. `"array"`) — display-ready, used by menus, cards, and URL paths.
45
45
  * - Override to parse `text` into richer elements (content/children/description) and to set
46
- * `title` if a confident title is available.
46
+ * `title` if a confident title is available. Overrides may be async (e.g. `TypescriptExtractor` parses via the native compiler server) — `extract()` awaits the result either way.
47
47
  *
48
48
  * @param name The basename of the file without extension (e.g. `"array"`).
49
49
  * @param content The raw text content of the file.
50
- * @returns The element props, always including a `name`; the base implementation stores `content` verbatim.
50
+ * @returns The element props (or a promise resolving to them), always including a `name`; the base implementation stores `content` verbatim.
51
51
  * @example extractProps("notes", "Some text") // { name: "notes", content: "Some text" }
52
52
  * @see https://shelving.cc/extract/FileExtractor/extractProps
53
53
  */
@@ -2,7 +2,8 @@ import type { TreeElementProps } from "../util/tree.js";
2
2
  import { FileExtractor } from "./FileExtractor.js";
3
3
  /**
4
4
  * File extractor that parses a TypeScript source file into a tree element.
5
- * - Uses the TypeScript compiler API to parse the AST.
5
+ * - Uses the native TypeScript compiler's API (`typescript/unstable/async` — TypeScript 7+) to parse the AST.
6
+ * - Parsing runs on a shared native compiler server, spawned on demand into a virtual filesystem and closed again after a short idle — callers never manage its lifecycle.
6
7
  * - Extracts exported, public, non-`_`-prefixed declarations as `tree-documentation` children.
7
8
  * - Overloaded declarations sharing a name are merged into a single `tree-documentation` with multiple `signatures`. When a function (or constructor) is overloaded, the implementation declaration (the "base definition") is dropped entirely — only the overload signatures are documented.
8
9
  * - A `@param name {Type}` (or `@returns {Type}`) whose `{Type}` is given is canonical: it supersedes the inferred type from the base definition and any overloads. Multiple `@param name` tags for one parameter each emit a row, letting a single parameter be documented as several typed variants.
@@ -18,13 +19,13 @@ import { FileExtractor } from "./FileExtractor.js";
18
19
  * - Keys are the raw declared `name` (case-preserving) so case-distinct exports like `Collection` and `COLLECTION` stay separate.
19
20
  * - The file element itself has no `title` — a TS source file has no confident title source; renderers fall back to `name`.
20
21
  *
21
- * @example const element = new TypescriptExtractor().extractProps("string.ts", sourceText);
22
+ * @example const element = await new TypescriptExtractor().extractProps("string.ts", sourceText);
22
23
  *
23
24
  * @see https://shelving.cc/extract/TypescriptExtractor
24
25
  */
25
26
  export declare class TypescriptExtractor extends FileExtractor {
26
27
  /** Parses the TypeScript source into one `tree-documentation` child per exported public declaration. */
27
- extractProps(name: string, text: string): Partial<TreeElementProps> & {
28
+ extractProps(name: string, text: string): Promise<Partial<TreeElementProps> & {
28
29
  name: string;
29
- };
30
+ }>;
30
31
  }
@@ -1,9 +1,13 @@
1
- import ts from "typescript";
1
+ import { getTextOfJSDocComment, isArrayBindingPattern, isBindingElement, isClassDeclaration, isConstructorDeclaration, isEnumDeclaration, isFunctionDeclaration, isGetAccessorDeclaration, isIdentifier, isInterfaceDeclaration, isJSDoc, isJSDocParameterTag, isJSDocReturnTag, isJSDocSeeTag, isJSDocThrowsTag, isJSDocTypeExpression, isMethodDeclaration, isMethodSignatureDeclaration, isObjectBindingPattern, isPropertyDeclaration, isPropertySignatureDeclaration, isSetAccessorDeclaration, isTypeAliasDeclaration, isTypeLiteralNode, isTypeReferenceNode, isVariableStatement, SyntaxKind, } from "typescript/unstable/ast";
2
+ import { API } from "typescript/unstable/async";
3
+ import { ValueError } from "../error/ValueError.js";
4
+ import { BLACKHOLE } from "../util/function.js";
2
5
  import { FileExtractor } from "./FileExtractor.js";
3
6
  import { extractMarkdownProps } from "./MarkupExtractor.js";
4
7
  /**
5
8
  * File extractor that parses a TypeScript source file into a tree element.
6
- * - Uses the TypeScript compiler API to parse the AST.
9
+ * - Uses the native TypeScript compiler's API (`typescript/unstable/async` — TypeScript 7+) to parse the AST.
10
+ * - Parsing runs on a shared native compiler server, spawned on demand into a virtual filesystem and closed again after a short idle — callers never manage its lifecycle.
7
11
  * - Extracts exported, public, non-`_`-prefixed declarations as `tree-documentation` children.
8
12
  * - Overloaded declarations sharing a name are merged into a single `tree-documentation` with multiple `signatures`. When a function (or constructor) is overloaded, the implementation declaration (the "base definition") is dropped entirely — only the overload signatures are documented.
9
13
  * - A `@param name {Type}` (or `@returns {Type}`) whose `{Type}` is given is canonical: it supersedes the inferred type from the base definition and any overloads. Multiple `@param name` tags for one parameter each emit a row, letting a single parameter be documented as several typed variants.
@@ -19,25 +23,25 @@ import { extractMarkdownProps } from "./MarkupExtractor.js";
19
23
  * - Keys are the raw declared `name` (case-preserving) so case-distinct exports like `Collection` and `COLLECTION` stay separate.
20
24
  * - The file element itself has no `title` — a TS source file has no confident title source; renderers fall back to `name`.
21
25
  *
22
- * @example const element = new TypescriptExtractor().extractProps("string.ts", sourceText);
26
+ * @example const element = await new TypescriptExtractor().extractProps("string.ts", sourceText);
23
27
  *
24
28
  * @see https://shelving.cc/extract/TypescriptExtractor
25
29
  */
26
30
  export class TypescriptExtractor extends FileExtractor {
27
31
  /** Parses the TypeScript source into one `tree-documentation` child per exported public declaration. */
28
- extractProps(name, text) {
29
- const source = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true);
32
+ async extractProps(name, text) {
33
+ const source = await _parseSourceFile(text);
30
34
  // Names with one or more overload signatures (bodyless function declarations). When a function is overloaded, the
31
35
  // implementation declaration (the one with a body — the "base definition") is dropped entirely; only the overloads are documented.
32
36
  const overloaded = new Set();
33
37
  for (const statement of source.statements)
34
- if (ts.isFunctionDeclaration(statement) && !statement.body && statement.name)
38
+ if (isFunctionDeclaration(statement) && !statement.body && statement.name)
35
39
  overloaded.add(statement.name.text);
36
40
  // Collect elements by key, merging overloads (same name) by appending signatures.
37
41
  const byKey = new Map();
38
42
  for (const statement of source.statements) {
39
43
  // Skip the implementation of an overloaded function — overloads supersede the base definition.
40
- if (ts.isFunctionDeclaration(statement) && statement.body && statement.name && overloaded.has(statement.name.text))
44
+ if (isFunctionDeclaration(statement) && statement.body && statement.name && overloaded.has(statement.name.text))
41
45
  continue;
42
46
  const element = _extractStatement(statement, source);
43
47
  if (!element)
@@ -50,6 +54,114 @@ export class TypescriptExtractor extends FileExtractor {
50
54
  return { name, children: Array.from(byKey.values()) };
51
55
  }
52
56
  }
57
+ // Constants.
58
+ const _DIR = "/shelving-typescript-extractor";
59
+ const _TSCONFIG_PATH = `${_DIR}/tsconfig.json`;
60
+ const _TSCONFIG_JSON = JSON.stringify({ compilerOptions: { noEmit: true }, include: ["**/*.ts"] });
61
+ const _IDLE_CLOSE_MS = 500;
62
+ let _server;
63
+ let _queue = Promise.resolve();
64
+ let _timer;
65
+ let _count = 0;
66
+ let _previous;
67
+ /** Is a path inside the virtual directory the compiler server is rooted in? */
68
+ function _isVirtual(path) {
69
+ return path === _DIR || path.startsWith(`${_DIR}/`);
70
+ }
71
+ /** Get the shared compiler server, spawning it on demand with a virtual filesystem rooted at `_DIR`. */
72
+ function _getServer() {
73
+ if (_server)
74
+ return _server;
75
+ const files = new Map([[_TSCONFIG_PATH, _TSCONFIG_JSON]]);
76
+ // Paths outside the virtual directory return `undefined` so the server falls back to the real filesystem (e.g. bundled libs).
77
+ const fs = {
78
+ fileExists: f => (_isVirtual(f) ? files.has(f) : undefined),
79
+ readFile: f => (_isVirtual(f) ? (files.get(f) ?? null) : undefined),
80
+ directoryExists: d => (d === _DIR ? true : _isVirtual(d) ? false : undefined),
81
+ getAccessibleEntries: d => d === _DIR
82
+ ? { files: Array.from(files.keys(), f => f.slice(_DIR.length + 1)), directories: [] }
83
+ : _isVirtual(d)
84
+ ? { files: [], directories: [] }
85
+ : undefined,
86
+ realpath: p => (_isVirtual(p) ? p : undefined),
87
+ };
88
+ _server = { api: new API({ cwd: _DIR, fs }), files };
89
+ return _server;
90
+ }
91
+ /** Close the shared compiler server after a short idle, so the process can exit without callers managing the lifecycle. */
92
+ function _scheduleClose() {
93
+ if (_timer)
94
+ clearTimeout(_timer);
95
+ _timer = setTimeout(() => {
96
+ const server = _server;
97
+ _server = undefined;
98
+ _previous = undefined;
99
+ _timer = undefined;
100
+ try {
101
+ server?.api.close();
102
+ }
103
+ catch {
104
+ // Closing can reject in-flight bookkeeping requests — harmless on shutdown.
105
+ }
106
+ }, _IDLE_CLOSE_MS);
107
+ }
108
+ /**
109
+ * Parse TypeScript source text into a `SourceFile` via the shared native compiler server.
110
+ * - Each call writes the text as a fresh virtual file, snapshots the project, and materialises the file's AST.
111
+ * - Calls are serialised through a queue so concurrent extractions don't interleave snapshot updates.
112
+ *
113
+ * @throws `ValueError` If the server fails to produce a source file for the text.
114
+ */
115
+ function _parseSourceFile(text) {
116
+ const task = async () => {
117
+ if (_timer)
118
+ clearTimeout(_timer);
119
+ const { api, files } = _getServer();
120
+ const path = `${_DIR}/${_count++}.ts`;
121
+ files.set(path, text);
122
+ // Drop the previous parse's file so the virtual project stays a single file (keeps snapshots cheap).
123
+ const deleted = _previous;
124
+ if (deleted)
125
+ files.delete(deleted);
126
+ _previous = path;
127
+ const snapshot = await api.updateSnapshot({
128
+ openProjects: [_TSCONFIG_PATH],
129
+ fileChanges: { created: [path], ...(deleted ? { deleted: [deleted] } : {}) },
130
+ });
131
+ try {
132
+ const source = await snapshot.getProject(_TSCONFIG_PATH)?.program.getSourceFile(path);
133
+ if (!source)
134
+ throw new ValueError("Unable to parse TypeScript source", { received: text });
135
+ return source;
136
+ }
137
+ finally {
138
+ snapshot.dispose();
139
+ _scheduleClose();
140
+ }
141
+ };
142
+ const result = _queue.then(task, task);
143
+ _queue = result.catch(BLACKHOLE);
144
+ return result;
145
+ }
146
+ /** Read a node's modifiers (excluding decorators), or `undefined` when it has none. */
147
+ function _getModifiers(node) {
148
+ const modifiers = node.modifiers;
149
+ const filtered = modifiers?.filter((m) => m.kind !== SyntaxKind.Decorator);
150
+ return filtered?.length ? filtered : undefined;
151
+ }
152
+ /** Get a member's declared identifier name, or `undefined` for computed / private / missing names. */
153
+ function _getMemberName(member) {
154
+ // Class/type member bases don't surface `name` structurally — read it off the node and narrow.
155
+ const { name } = member;
156
+ return name && isIdentifier(name) ? name.text : undefined;
157
+ }
158
+ /** Get the text of a JSDoc tag's `{Type}` expression — unwraps a `JSDocTypeExpression` to its inner type. */
159
+ function _getJSDocTypeText(typeExpression, source) {
160
+ if (!typeExpression)
161
+ return;
162
+ const type = isJSDocTypeExpression(typeExpression) ? typeExpression.type : typeExpression;
163
+ return type.getText(source);
164
+ }
53
165
  /** Merge a newly-extracted overload into the existing documentation element with the same key. */
54
166
  function _mergeOverloads(existing, next) {
55
167
  const a = existing.props;
@@ -146,7 +258,7 @@ function _extractStatement(statement, source) {
146
258
  }
147
259
  /** Extract the `extends` (single base type) and `implements` (interface list) heritage from a class or interface declaration, as full type text including any generic arguments (e.g. `AbstractStore<string>`, `Omit<StringSchemaOptions, "value">`). */
148
260
  function _getHeritage(statement, source) {
149
- if (!ts.isClassDeclaration(statement) && !ts.isInterfaceDeclaration(statement))
261
+ if (!isClassDeclaration(statement) && !isInterfaceDeclaration(statement))
150
262
  return;
151
263
  let extendsName;
152
264
  const implementsNames = [];
@@ -154,9 +266,9 @@ function _getHeritage(statement, source) {
154
266
  // Full text — keep generic arguments (`Foo<T>`) and wrappers (`Omit<…>`) intact; render-time lookup trims them to the bare name to resolve a link.
155
267
  const names = clause.types.map(t => t.getText(source));
156
268
  // `extends` keeps the first base type; an interface extending several still surfaces its primary base.
157
- if (clause.token === ts.SyntaxKind.ExtendsKeyword)
269
+ if (clause.token === SyntaxKind.ExtendsKeyword)
158
270
  extendsName ??= names[0];
159
- else if (clause.token === ts.SyntaxKind.ImplementsKeyword)
271
+ else if (clause.token === SyntaxKind.ImplementsKeyword)
160
272
  implementsNames.push(...names);
161
273
  }
162
274
  if (!extendsName && !implementsNames.length)
@@ -170,13 +282,13 @@ function _getHeritage(statement, source) {
170
282
  * - Order-preserving and de-duplicated. Unresolved names (builtins like `Record`, externals) simply stay as plain text at render time.
171
283
  */
172
284
  function _getReferencedTypes(statement, source) {
173
- if (!ts.isTypeAliasDeclaration(statement))
285
+ if (!isTypeAliasDeclaration(statement))
174
286
  return;
175
287
  const generics = new Set(statement.typeParameters?.map(t => t.name.text) ?? []);
176
288
  const names = [];
177
289
  const seen = new Set();
178
290
  const visit = (node) => {
179
- if (ts.isTypeReferenceNode(node)) {
291
+ if (isTypeReferenceNode(node)) {
180
292
  const name = node.typeName.getText(source);
181
293
  if (!generics.has(name) && !seen.has(name)) {
182
294
  seen.add(name);
@@ -196,44 +308,45 @@ function _getReferencedTypes(statement, source) {
196
308
  * - Getters/setters fold into a single entry per name (the getter's type wins for a get/set pair).
197
309
  */
198
310
  function _getProperties(statement, source) {
199
- const members = ts.isInterfaceDeclaration(statement) || ts.isClassDeclaration(statement)
311
+ const members = isInterfaceDeclaration(statement) || isClassDeclaration(statement)
200
312
  ? statement.members
201
- : ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type)
313
+ : isTypeAliasDeclaration(statement) && isTypeLiteralNode(statement.type)
202
314
  ? statement.type.members
203
315
  : undefined;
204
316
  if (!members)
205
317
  return;
206
318
  const properties = [];
207
319
  for (const member of members) {
208
- const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined;
320
+ const name = _getMemberName(member);
209
321
  if (!name || name.startsWith("_"))
210
322
  continue;
211
- const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined;
323
+ const modifiers = _getModifiers(member);
212
324
  // Skip private/protected (not public API), `override` (documented on the base class), and `declare` (ambient re-declarations).
213
- if (modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword))
325
+ if (modifiers?.some(m => m.kind === SyntaxKind.PrivateKeyword || m.kind === SyntaxKind.ProtectedKeyword))
214
326
  continue;
215
- if (modifiers?.some(m => m.kind === ts.SyntaxKind.OverrideKeyword || m.kind === ts.SyntaxKind.DeclareKeyword))
327
+ if (modifiers?.some(m => m.kind === SyntaxKind.OverrideKeyword || m.kind === SyntaxKind.DeclareKeyword))
216
328
  continue;
217
329
  const jsDoc = _getJSDoc(member, source);
218
- if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) {
330
+ if (isPropertySignatureDeclaration(member) || isPropertyDeclaration(member)) {
219
331
  // A class field's initializer is its default; otherwise fall back to an explicit `@default` tag.
220
- const def = (ts.isPropertyDeclaration(member) ? member.initializer?.getText(source) : undefined) ?? jsDoc?.default;
332
+ const def = (isPropertyDeclaration(member) ? member.initializer?.getText(source) : undefined) ?? jsDoc?.default;
221
333
  properties.push({
222
334
  name,
223
335
  type: member.type?.getText(source),
224
336
  description: jsDoc?.description,
225
- optional: !!member.questionToken,
337
+ // Optionality is a `?` postfix token (the postfix slot also carries `!` definite-assignment, which isn't optionality).
338
+ optional: member.postfixToken?.kind === SyntaxKind.QuestionToken,
226
339
  default: def,
227
- readonly: modifiers?.some(m => m.kind === ts.SyntaxKind.ReadonlyKeyword) || undefined,
340
+ readonly: modifiers?.some(m => m.kind === SyntaxKind.ReadonlyKeyword) || undefined,
228
341
  });
229
342
  }
230
- else if (ts.isGetAccessor(member) || ts.isSetAccessor(member)) {
231
- const type = ts.isGetAccessor(member) ? member.type?.getText(source) : member.parameters[0]?.type?.getText(source);
343
+ else if (isGetAccessorDeclaration(member) || isSetAccessorDeclaration(member)) {
344
+ const type = isGetAccessorDeclaration(member) ? member.type?.getText(source) : member.parameters[0]?.type?.getText(source);
232
345
  const index = properties.findIndex(p => p.name === name);
233
346
  const found = index >= 0 ? properties[index] : undefined;
234
347
  // Fold a get/set pair into one entry: the getter's declared type wins, and the matching setter clears read-only.
235
348
  if (found) {
236
- properties[index] = { ...found, type: ts.isGetAccessor(member) && type ? type : found.type, readonly: undefined };
349
+ properties[index] = { ...found, type: isGetAccessorDeclaration(member) && type ? type : found.type, readonly: undefined };
237
350
  }
238
351
  else {
239
352
  // A lone getter is read-only until a setter is seen; a lone setter is writable.
@@ -243,7 +356,7 @@ function _getProperties(statement, source) {
243
356
  description: jsDoc?.description,
244
357
  optional: false,
245
358
  default: jsDoc?.default,
246
- readonly: ts.isGetAccessor(member) || undefined,
359
+ readonly: isGetAccessorDeclaration(member) || undefined,
247
360
  });
248
361
  }
249
362
  }
@@ -264,59 +377,58 @@ function _buildJSDocContent(description, unhandled) {
264
377
  }
265
378
  /** Check if a statement has an `export` modifier. */
266
379
  function _isExported(statement) {
267
- const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
268
- return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
380
+ return !!_getModifiers(statement)?.some(m => m.kind === SyntaxKind.ExportKeyword);
269
381
  }
270
382
  /** Get the declared name of a statement. */
271
383
  function _getStatementName(statement) {
272
- if (ts.isFunctionDeclaration(statement) ||
273
- ts.isClassDeclaration(statement) ||
274
- ts.isInterfaceDeclaration(statement) ||
275
- ts.isTypeAliasDeclaration(statement) ||
276
- ts.isEnumDeclaration(statement)) {
384
+ if (isFunctionDeclaration(statement) ||
385
+ isClassDeclaration(statement) ||
386
+ isInterfaceDeclaration(statement) ||
387
+ isTypeAliasDeclaration(statement) ||
388
+ isEnumDeclaration(statement)) {
277
389
  return statement.name?.text;
278
390
  }
279
- if (ts.isVariableStatement(statement)) {
391
+ if (isVariableStatement(statement)) {
280
392
  const declaration = statement.declarationList.declarations[0];
281
- if (declaration && ts.isIdentifier(declaration.name))
393
+ if (declaration && isIdentifier(declaration.name))
282
394
  return declaration.name.text;
283
395
  }
284
396
  }
285
397
  /** Map a statement to its documentation kind. */
286
398
  function _getKind(statement) {
287
- if (ts.isFunctionDeclaration(statement))
399
+ if (isFunctionDeclaration(statement))
288
400
  return "function";
289
- if (ts.isClassDeclaration(statement))
401
+ if (isClassDeclaration(statement))
290
402
  return "class";
291
- if (ts.isInterfaceDeclaration(statement))
403
+ if (isInterfaceDeclaration(statement))
292
404
  return "interface";
293
- if (ts.isTypeAliasDeclaration(statement))
405
+ if (isTypeAliasDeclaration(statement))
294
406
  return "type";
295
- if (ts.isVariableStatement(statement))
407
+ if (isVariableStatement(statement))
296
408
  return "constant";
297
409
  }
298
410
  /** Get the text signature(s) of a statement — complete, name-prefixed declarations usable as headings. */
299
411
  function _getSignatures(statement, source, name) {
300
- if (ts.isFunctionDeclaration(statement)) {
412
+ if (isFunctionDeclaration(statement)) {
301
413
  const params = statement.parameters.map(p => p.getText(source)).join(", ");
302
414
  const ret = statement.type ? statement.type.getText(source) : "void";
303
415
  return [`${name}(${params}): ${ret}`];
304
416
  }
305
- if (ts.isClassDeclaration(statement)) {
417
+ if (isClassDeclaration(statement)) {
306
418
  // Synthesise `new ClassName<…>(…)` constructor signatures so a class page reads like a function's.
307
419
  return _getConstructorSignatures(statement, source, name);
308
420
  }
309
- if (ts.isInterfaceDeclaration(statement)) {
421
+ if (isInterfaceDeclaration(statement)) {
310
422
  // Emit a pretty-printed `{ member; member }` block — the same shape a `type` object body produces, distinguished only by the `kind` badge.
311
423
  return [_formatObjectSignature(statement.members, source)];
312
424
  }
313
- if (ts.isTypeAliasDeclaration(statement)) {
425
+ if (isTypeAliasDeclaration(statement)) {
314
426
  // Pretty-print object-literal aliases multi-line like an interface; emit other bodies (`string | null`, mapped types, …) verbatim. The alias name is already the page title.
315
- if (ts.isTypeLiteralNode(statement.type))
427
+ if (isTypeLiteralNode(statement.type))
316
428
  return [_formatObjectSignature(statement.type.members, source)];
317
429
  return [statement.type.getText(source)];
318
430
  }
319
- if (ts.isVariableStatement(statement)) {
431
+ if (isVariableStatement(statement)) {
320
432
  const declaration = statement.declarationList.declarations[0];
321
433
  if (declaration?.type)
322
434
  return [`${name}: ${declaration.type.getText(source)}`];
@@ -338,7 +450,7 @@ function _getTypeParamNames(statement, source) {
338
450
  }
339
451
  /** Get a class's constructor declarations in source order — when overloaded, only the overload signatures (the implementation, i.e. the base definition, is dropped). */
340
452
  function _getConstructors(statement) {
341
- const constructors = statement.members.filter(ts.isConstructorDeclaration);
453
+ const constructors = statement.members.filter(isConstructorDeclaration);
342
454
  // When overload signatures exist, drop the implementation constructor (the one with a body) — overloads supersede the base definition.
343
455
  return constructors.some(c => !c.body) ? constructors.filter(c => !c.body) : constructors;
344
456
  }
@@ -357,9 +469,9 @@ function _getConstructorSignatures(statement, source, name) {
357
469
  }
358
470
  /** Extract parameters from a function or class declaration, enriched with JSDoc `@param` descriptions. */
359
471
  function _getParams(statement, source, jsDocParams) {
360
- if (ts.isClassDeclaration(statement))
472
+ if (isClassDeclaration(statement))
361
473
  return _getConstructorParams(statement, source, jsDocParams);
362
- if (!ts.isFunctionDeclaration(statement))
474
+ if (!isFunctionDeclaration(statement))
363
475
  return;
364
476
  const params = _buildParams(statement.parameters, source, jsDocParams);
365
477
  return params.length ? params : undefined;
@@ -377,7 +489,7 @@ function _buildParams(parameters, source, primaryJsDocParams, fallbackJsDocParam
377
489
  // Identifier (non-destructured) parameter names — used to spot "orphan" `@param` tags that name a destructured bag.
378
490
  const named = new Set();
379
491
  for (const p of parameters)
380
- if (ts.isIdentifier(p.name))
492
+ if (isIdentifier(p.name))
381
493
  named.add(p.name.getText(source));
382
494
  // Top-level `@param` names not matching any identifier parameter, in declared order (constructor's own first, then class-level), de-duplicated — these let an author name a destructured param (`@param options`). Sub-tags (`options.min`) are excluded.
383
495
  const orphans = [];
@@ -386,7 +498,7 @@ function _buildParams(parameters, source, primaryJsDocParams, fallbackJsDocParam
386
498
  orphans.push(d.name);
387
499
  let orphan = 0;
388
500
  for (const p of parameters) {
389
- const name = ts.isIdentifier(p.name) ? p.name.getText(source) : (orphans[orphan++] ?? _getBindingName(p, source));
501
+ const name = isIdentifier(p.name) ? p.name.getText(source) : (orphans[orphan++] ?? _getBindingName(p, source));
390
502
  const type = p.type?.getText(source);
391
503
  const optional = !!p.questionToken || !!p.initializer;
392
504
  const def = p.initializer?.getText(source);
@@ -413,9 +525,9 @@ function _buildParams(parameters, source, primaryJsDocParams, fallbackJsDocParam
413
525
  */
414
526
  function _getBindingName(p, source) {
415
527
  const { name } = p;
416
- if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name))
528
+ if (isObjectBindingPattern(name) || isArrayBindingPattern(name))
417
529
  for (const el of name.elements)
418
- if (ts.isBindingElement(el) && el.dotDotDotToken && ts.isIdentifier(el.name))
530
+ if (isBindingElement(el) && el.dotDotDotToken && el.name && isIdentifier(el.name))
419
531
  return el.name.text;
420
532
  return p.type?.getText(source).replace(/<.*/s, "").endsWith("Props") ? "props" : "options";
421
533
  }
@@ -436,12 +548,12 @@ function _getConstructorParams(statement, source, classJsDocParams) {
436
548
  }
437
549
  /** Extract return entries — combines the signature return type with any `@returns` descriptions. */
438
550
  function _getReturns(statement, source, jsDocReturns, name) {
439
- if (ts.isClassDeclaration(statement)) {
551
+ if (isClassDeclaration(statement)) {
440
552
  // A constructor returns an instance of the class, including its generics (e.g. `ChoiceSchema<T>`).
441
553
  const type = `${name}${_getTypeParamNames(statement, source)}`;
442
554
  return [{ type, description: jsDocReturns?.[0]?.description }];
443
555
  }
444
- if (!ts.isFunctionDeclaration(statement))
556
+ if (!isFunctionDeclaration(statement))
445
557
  return jsDocReturns;
446
558
  const type = statement.type?.getText(source);
447
559
  if (jsDocReturns?.length) {
@@ -463,31 +575,31 @@ function _getReturns(statement, source, jsDocReturns, name) {
463
575
  * - `static` methods are labelled `static method` so the docs site groups them in their own section, separate from instance `method`s, and their rendered signature is prefixed with the `static ` keyword.
464
576
  */
465
577
  function _getClassMembers(statement, source, className) {
466
- if (!ts.isClassDeclaration(statement) && !ts.isInterfaceDeclaration(statement))
578
+ if (!isClassDeclaration(statement) && !isInterfaceDeclaration(statement))
467
579
  return;
468
580
  const members = [];
469
581
  for (const member of statement.members) {
470
582
  // Skip private, protected, and _-prefixed members.
471
- const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined;
583
+ const name = _getMemberName(member);
472
584
  if (!name || name.startsWith("_"))
473
585
  continue;
474
- const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined;
475
- if (modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword))
586
+ const modifiers = _getModifiers(member);
587
+ if (modifiers?.some(m => m.kind === SyntaxKind.PrivateKeyword || m.kind === SyntaxKind.ProtectedKeyword))
476
588
  continue;
477
589
  // Skip `override` members — the base class already documents them, so a subclass page lists only its newly-introduced API.
478
- if (modifiers?.some(m => m.kind === ts.SyntaxKind.OverrideKeyword))
590
+ if (modifiers?.some(m => m.kind === SyntaxKind.OverrideKeyword))
479
591
  continue;
480
592
  // Skip `declare` members — ambient type-only re-declarations (e.g. a subclass narrowing an inherited property's type), not new API.
481
- if (modifiers?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword))
593
+ if (modifiers?.some(m => m.kind === SyntaxKind.DeclareKeyword))
482
594
  continue;
483
595
  // `static` methods are grouped and labelled separately from instance methods (`static method`),
484
596
  // and carry the `static ` keyword in their rendered signature.
485
- const isStatic = modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword);
597
+ const isStatic = modifiers?.some(m => m.kind === SyntaxKind.StaticKeyword);
486
598
  const staticPrefix = isStatic ? "static " : "";
487
599
  const memberJSDoc = _getJSDoc(member, source);
488
600
  const content = _buildJSDocContent(memberJSDoc?.description, memberJSDoc?.unhandled);
489
601
  const description = extractMarkdownProps(memberJSDoc?.description ?? "").description;
490
- if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) {
602
+ if (isMethodDeclaration(member) || isMethodSignatureDeclaration(member)) {
491
603
  const params = member.parameters.map(p => p.getText(source)).join(", ");
492
604
  const ret = member.type ? member.type.getText(source) : "void";
493
605
  const signature = `${staticPrefix}${name}(${params}): ${ret}`;
@@ -524,15 +636,15 @@ function _getClassMembers(statement, source, className) {
524
636
  * Extract JSDoc from a node via the TypeScript compiler's parsed JSDoc AST.
525
637
  * - Reads the compiler's structured tags rather than re-scanning the raw comment text, so tags are only ever recognised at real tag positions — a `@kind`/`@param`/etc. mentioned inline in prose is left in the description, not parsed.
526
638
  * - Multi-line `@param` / `@returns` / `@throws` descriptions and `{Type}` expressions come through whole; `*` margins are already stripped.
527
- * - `@example` bodies are taken verbatim. Note the compiler treats any whitespace-led `@word` line as a new tag even inside a ``` fence, so an example must not contain a bare at-rule / decorator / pragma line on its own.
639
+ * - `@example` bodies are taken verbatim. The parser is markdown-fence-aware: an `@word` line inside a balanced triple-backtick fence stays part of the example rather than starting a new tag. The flip side: an unbalanced run of three-plus backticks anywhere in the comment opens a fence that swallows every following tag never write a bare triple-backtick in docblock prose.
528
640
  * - `@see` is recognised only to discard it: it's a VS Code hover affordance (a link back to the docs site), never rendered into the page body.
529
641
  */
530
642
  function _getJSDoc(node, source) {
531
643
  // The compiler attaches every leading `/** */` block here; the last one is the doc comment.
532
644
  const jsDoc = node.jsDoc?.at(-1);
533
- if (!jsDoc)
645
+ if (!jsDoc || !isJSDoc(jsDoc))
534
646
  return;
535
- const description = ts.getTextOfJSDocComment(jsDoc.comment)?.trim();
647
+ const description = getTextOfJSDocComment(jsDoc.comment)?.trim();
536
648
  let kind;
537
649
  let def;
538
650
  const params = [];
@@ -541,24 +653,24 @@ function _getJSDoc(node, source) {
541
653
  const examples = [];
542
654
  const unhandled = [];
543
655
  for (const tag of jsDoc.tags ?? []) {
544
- const comment = ts.getTextOfJSDocComment(tag.comment)?.trim();
545
- if (ts.isJSDocParameterTag(tag)) {
656
+ const comment = getTextOfJSDocComment(tag.comment)?.trim();
657
+ if (isJSDocParameterTag(tag)) {
546
658
  const name = tag.name.getText(source);
547
- const type = tag.typeExpression?.type.getText(source);
659
+ const type = _getJSDocTypeText(tag.typeExpression, source);
548
660
  if (name)
549
661
  params.push({ name, type: type || undefined, description: comment || undefined });
550
662
  }
551
- else if (ts.isJSDocReturnTag(tag)) {
552
- const type = tag.typeExpression?.type.getText(source);
663
+ else if (isJSDocReturnTag(tag)) {
664
+ const type = _getJSDocTypeText(tag.typeExpression, source);
553
665
  if (type || comment)
554
666
  returns.push({ type: type || undefined, description: comment || undefined });
555
667
  }
556
- else if (ts.isJSDocThrowsTag(tag)) {
557
- const type = tag.typeExpression?.type.getText(source);
668
+ else if (isJSDocThrowsTag(tag)) {
669
+ const type = _getJSDocTypeText(tag.typeExpression, source);
558
670
  if (type || comment)
559
671
  throws.push({ type: type || undefined, description: comment || undefined });
560
672
  }
561
- else if (ts.isJSDocSeeTag(tag)) {
673
+ else if (isJSDocSeeTag(tag)) {
562
674
  // `@see` is a hover affordance only — strip it, never render it.
563
675
  }
564
676
  else {
@@ -3,7 +3,7 @@
3
3
  * - Same as Markdown syntax.
4
4
  * - Start when we reach an opening fence on a new line.
5
5
  * - Stop when we reach a matching closing fence on a new line, or the end of the string.
6
- * - Closing fence must be exactly the same as the opening fence, and can be made of three or more "```" backticks, or three or more `~~~` tildes.
6
+ * - Closing fence must be exactly the same as the opening fence, and can be made of three or more backticks, or three or more `~~~` tildes.
7
7
  * - If there's no closing fence the code block will run to the end of the current string.
8
8
  * - Markdown-style four-space indent syntax is not supported (only fenced code since it's less confusing and more common).
9
9
  *
@@ -7,7 +7,7 @@ const FENCE = "`{3,}|~{3,}";
7
7
  * - Same as Markdown syntax.
8
8
  * - Start when we reach an opening fence on a new line.
9
9
  * - Stop when we reach a matching closing fence on a new line, or the end of the string.
10
- * - Closing fence must be exactly the same as the opening fence, and can be made of three or more "```" backticks, or three or more `~~~` tildes.
10
+ * - Closing fence must be exactly the same as the opening fence, and can be made of three or more backticks, or three or more `~~~` tildes.
11
11
  * - If there's no closing fence the code block will run to the end of the current string.
12
12
  * - Markdown-style four-space indent syntax is not supported (only fenced code since it's less confusing and more common).
13
13
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.269.0",
3
+ "version": "1.271.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,13 +15,12 @@
15
15
  "@types/bun": "^1.3.14",
16
16
  "@types/react": "^19.2.17",
17
17
  "@types/react-dom": "^19.2.3",
18
- "@typescript/native-preview": "^7.0.0-dev.20260706.1",
19
18
  "firebase": "^12.15.0",
20
19
  "react": "^19.3.0-canary-fef12a01-20260413",
21
20
  "react-dom": "^19.3.0-canary-fef12a01-20260413",
22
21
  "stylelint": "^17.14.0",
23
22
  "stylelint-config-standard": "^40.0.0",
24
- "typescript": "^5.9.3"
23
+ "typescript": "^7.0.2"
25
24
  },
26
25
  "peerDependencies": {
27
26
  "@google-cloud/firestore": ">=7.0.0",
@@ -69,7 +68,7 @@
69
68
  "scripts": {
70
69
  "test": "bun run --parallel test:*",
71
70
  "test:lint": "biome check .",
72
- "test:type": "tsgo --noEmit",
71
+ "test:type": "tsc --noEmit",
73
72
  "test:style": "stylelint \"modules/**/*.css\"",
74
73
  "test:unit": "bun test ./modules --concurrent --only-failures",
75
74
  "fix": "bun run --sequential fix:*",
@@ -80,7 +79,7 @@
80
79
  "build": "bun run --sequential build:*",
81
80
  "build:0:setup": "rm -rf ./dist && mkdir -p ./dist",
82
81
  "build:1:copy": "cp package.json dist/package.json && cp LICENSE.md dist/LICENSE.md && cp README.md dist/README.md && cp .npmignore dist/.npmignore && cp -r modules/ui dist/ui",
83
- "build:2:emit": "tsgo -p tsconfig.build.json",
82
+ "build:2:emit": "tsc -p tsconfig.build.json",
84
83
  "build:3:syntax": "bun run ./dist/index.js",
85
84
  "build:4:unit": "bun test ./dist/**/*.test.js --concurrent --only-failures --bail"
86
85
  },
package/ui/block/Row.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactElement } from "react";
2
+ import { type BlockVariants } from "../style/Block.js";
2
3
  import { type FlexVariants } from "../style/Flex.js";
3
4
  import type { OptionalChildProps } from "../util/index.js";
4
5
  import type { BlockElement } from "./Block.js";
@@ -7,7 +8,7 @@ import type { BlockElement } from "./Block.js";
7
8
  *
8
9
  * @see https://shelving.cc/ui/RowProps
9
10
  */
10
- export interface RowProps extends FlexVariants, OptionalChildProps {
11
+ export interface RowProps extends BlockVariants, FlexVariants, OptionalChildProps {
11
12
  /**
12
13
  * Element this `<Row>` renders as, e.g. "header" to output a "<header>"
13
14
  * @default "div"
package/ui/block/Row.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ReactElement } from "react";
2
- import { getBlockClass } from "../style/Block.js";
2
+ import { type BlockVariants, getBlockClass } from "../style/Block.js";
3
3
  import { type FlexVariants, getFlexClass } from "../style/Flex.js";
4
4
  import { getClass } from "../util/css.js";
5
5
  import type { OptionalChildProps } from "../util/index.js";
@@ -10,7 +10,7 @@ import type { BlockElement } from "./Block.js";
10
10
  *
11
11
  * @see https://shelving.cc/ui/RowProps
12
12
  */
13
- export interface RowProps extends FlexVariants, OptionalChildProps {
13
+ export interface RowProps extends BlockVariants, FlexVariants, OptionalChildProps {
14
14
  /**
15
15
  * Element this `<Row>` renders as, e.g. "header" to output a "<header>"
16
16
  * @default "div"
@@ -2,7 +2,6 @@ import { Component, type ReactElement, type ReactNode } from "react";
2
2
  import type { Callback } from "../../util/function.js";
3
3
  import type { ChildProps } from "../util/props.js";
4
4
  /**
5
- import { RetryButton, RetryContext } from "../button/RetryButton.js";
6
5
  * Props for a component that renders a caught error `reason`.
7
6
  *
8
7
  * @see https://shelving.cc/ui/ErrorProps
@@ -9,7 +9,6 @@ import { Page } from "../page/Page.js";
9
9
  import type { ChildProps } from "../util/props.js";
10
10
 
11
11
  /**
12
- import { RetryButton, RetryContext } from "../button/RetryButton.js";
13
12
  * Props for a component that renders a caught error `reason`.
14
13
  *
15
14
  * @see https://shelving.cc/ui/ErrorProps