@defold-typescript/types 0.38.0 → 0.40.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.
Files changed (109) hide show
  1. package/api-availability.json +117 -0
  2. package/api-signatures.json +586 -480
  3. package/examples/translations.json +356 -180
  4. package/generated/b2d_body.d.ts +7 -4
  5. package/generated/b2d_shape.d.ts +23 -1
  6. package/generated/buffer.d.ts +34 -19
  7. package/generated/camera.d.ts +3 -2
  8. package/generated/collectionfactory.d.ts +17 -12
  9. package/generated/collectionproxy.d.ts +2 -2
  10. package/generated/compute.d.ts +1 -1
  11. package/generated/factory.d.ts +2 -1
  12. package/generated/go.d.ts +150 -91
  13. package/generated/graphics.d.ts +54 -0
  14. package/generated/gui.d.ts +117 -71
  15. package/generated/html5.d.ts +4 -2
  16. package/generated/http.d.ts +8 -2
  17. package/generated/image.d.ts +24 -15
  18. package/generated/kinds/gui-script.d.ts +1 -0
  19. package/generated/kinds/render-script.d.ts +1 -0
  20. package/generated/kinds/script.d.ts +1 -0
  21. package/generated/material.d.ts +1 -1
  22. package/generated/model.d.ts +41 -7
  23. package/generated/particlefx.d.ts +1 -1
  24. package/generated/physics.d.ts +29 -20
  25. package/generated/profiler.d.ts +10 -3
  26. package/generated/render.d.ts +293 -111
  27. package/generated/resource.d.ts +243 -226
  28. package/generated/socket.d.ts +33 -18
  29. package/generated/sound.d.ts +6 -5
  30. package/generated/sprite.d.ts +9 -1
  31. package/generated/sys.d.ts +44 -27
  32. package/generated/tilemap.d.ts +25 -19
  33. package/generated/timer.d.ts +33 -24
  34. package/generated/versions/defold-1.12.4/buffer.d.ts +34 -19
  35. package/generated/versions/defold-1.12.4/camera.d.ts +3 -2
  36. package/generated/versions/defold-1.12.4/collectionfactory.d.ts +17 -12
  37. package/generated/versions/defold-1.12.4/collectionproxy.d.ts +1 -1
  38. package/generated/versions/defold-1.12.4/factory.d.ts +2 -1
  39. package/generated/versions/defold-1.12.4/go.d.ts +150 -91
  40. package/generated/versions/defold-1.12.4/graphics.d.ts +2 -0
  41. package/generated/versions/defold-1.12.4/gui.d.ts +109 -69
  42. package/generated/versions/defold-1.12.4/html5.d.ts +4 -2
  43. package/generated/versions/defold-1.12.4/http.d.ts +8 -2
  44. package/generated/versions/defold-1.12.4/image.d.ts +24 -15
  45. package/generated/versions/defold-1.12.4/index.d.ts +1 -0
  46. package/generated/versions/defold-1.12.4/model.d.ts +41 -7
  47. package/generated/versions/defold-1.12.4/particlefx.d.ts +1 -1
  48. package/generated/versions/defold-1.12.4/physics.d.ts +29 -20
  49. package/generated/versions/defold-1.12.4/profiler.d.ts +10 -3
  50. package/generated/versions/defold-1.12.4/render.d.ts +292 -110
  51. package/generated/versions/defold-1.12.4/resource.d.ts +243 -226
  52. package/generated/versions/defold-1.12.4/socket.d.ts +33 -18
  53. package/generated/versions/defold-1.12.4/sound.d.ts +6 -5
  54. package/generated/versions/defold-1.12.4/sprite.d.ts +9 -1
  55. package/generated/versions/defold-1.12.4/sys.d.ts +44 -27
  56. package/generated/versions/defold-1.12.4/tilemap.d.ts +25 -19
  57. package/generated/versions/defold-1.12.4/timer.d.ts +33 -24
  58. package/generated/versions/defold-1.12.4/vmath.d.ts +18 -10
  59. package/generated/versions/defold-1.12.4/window.d.ts +5 -3
  60. package/generated/versions/defold-1.13.0/b2d_body.d.ts +7 -4
  61. package/generated/versions/defold-1.13.0/b2d_shape.d.ts +23 -1
  62. package/generated/versions/defold-1.13.0/buffer.d.ts +34 -19
  63. package/generated/versions/defold-1.13.0/camera.d.ts +3 -2
  64. package/generated/versions/defold-1.13.0/collectionfactory.d.ts +17 -12
  65. package/generated/versions/defold-1.13.0/collectionproxy.d.ts +1 -1
  66. package/generated/versions/defold-1.13.0/compute.d.ts +1 -1
  67. package/generated/versions/defold-1.13.0/factory.d.ts +2 -1
  68. package/generated/versions/defold-1.13.0/go.d.ts +150 -91
  69. package/generated/versions/defold-1.13.0/graphics.d.ts +50 -0
  70. package/generated/versions/defold-1.13.0/gui.d.ts +109 -69
  71. package/generated/versions/defold-1.13.0/html5.d.ts +4 -2
  72. package/generated/versions/defold-1.13.0/http.d.ts +8 -2
  73. package/generated/versions/defold-1.13.0/image.d.ts +24 -15
  74. package/generated/versions/defold-1.13.0/index.d.ts +1 -0
  75. package/generated/versions/defold-1.13.0/material.d.ts +1 -1
  76. package/generated/versions/defold-1.13.0/model.d.ts +41 -7
  77. package/generated/versions/defold-1.13.0/particlefx.d.ts +1 -1
  78. package/generated/versions/defold-1.13.0/physics.d.ts +29 -20
  79. package/generated/versions/defold-1.13.0/profiler.d.ts +10 -3
  80. package/generated/versions/defold-1.13.0/render.d.ts +293 -111
  81. package/generated/versions/defold-1.13.0/resource.d.ts +243 -226
  82. package/generated/versions/defold-1.13.0/socket.d.ts +33 -18
  83. package/generated/versions/defold-1.13.0/sound.d.ts +6 -5
  84. package/generated/versions/defold-1.13.0/sprite.d.ts +9 -1
  85. package/generated/versions/defold-1.13.0/sys.d.ts +44 -27
  86. package/generated/versions/defold-1.13.0/tilemap.d.ts +25 -19
  87. package/generated/versions/defold-1.13.0/timer.d.ts +33 -24
  88. package/generated/versions/defold-1.13.0/vmath.d.ts +18 -10
  89. package/generated/versions/defold-1.13.0/window.d.ts +5 -3
  90. package/generated/vmath.d.ts +18 -10
  91. package/generated/window.d.ts +5 -3
  92. package/index.d.ts +1 -0
  93. package/package.json +2 -1
  94. package/scripts/generate-api-signatures.ts +2 -1
  95. package/scripts/prose-constants.ts +96 -0
  96. package/scripts/regen.ts +6 -2
  97. package/src/api-doc.ts +8 -0
  98. package/src/component-properties.d.ts +33 -0
  99. package/src/core-types.ts +4 -2
  100. package/src/custom-messages.d.ts +4 -3
  101. package/src/doc-comment.ts +247 -43
  102. package/src/emit-dts.ts +958 -57
  103. package/src/engine-globals.d.ts +2 -2
  104. package/src/example-store.ts +26 -0
  105. package/src/go-overloads.d.ts +11 -10
  106. package/src/index.ts +9 -0
  107. package/src/param-correction-provenance.ts +67 -0
  108. package/src/render-overloads.d.ts +15 -17
  109. package/src/script-api.ts +3 -1
@@ -5,6 +5,8 @@ declare global {
5
5
  * and screen dimming.
6
6
  */
7
7
  namespace window {
8
+ type DimModeState = typeof window.DIMMING_UNKNOWN | typeof window.DIMMING_ON | typeof window.DIMMING_OFF;
9
+ type DimModeStateSettable = typeof window.DIMMING_ON | typeof window.DIMMING_OFF;
8
10
  /**
9
11
  * Dimming mode is used to control whether or not a mobile device should dim the screen after a period without user interaction.
10
12
  */
@@ -56,7 +58,7 @@ declare global {
56
58
  *
57
59
  * - `window.DIMMING_OFF`
58
60
  */
59
- function get_dim_mode(): number & { readonly __brand: "window.DIMMING_UNKNOWN" } | number & { readonly __brand: "window.DIMMING_ON" } | number & { readonly __brand: "window.DIMMING_OFF" };
61
+ function get_dim_mode(): window.DimModeState;
60
62
  /**
61
63
  * This returns the content scale of the current display.
62
64
  *
@@ -111,7 +113,7 @@ declare global {
111
113
  *
112
114
  * - `window.DIMMING_OFF`
113
115
  */
114
- function set_dim_mode(mode: number & { readonly __brand: "window.DIMMING_ON" } | number & { readonly __brand: "window.DIMMING_OFF" }): void;
116
+ function set_dim_mode(mode: window.DimModeStateSettable): void;
115
117
  /**
116
118
  * Sets a window event listener. Only one window event listener can be set at a time.
117
119
  *
@@ -140,7 +142,7 @@ declare global {
140
142
  * - number `height`: The height of a resize event. nil otherwise.
141
143
  * @example
142
144
  * ```ts
143
- * function window_callback(self, event, data) {
145
+ * function window_callback(self: unknown, event: unknown, data: Record<string | number, unknown>) {
144
146
  * if (event === window.WINDOW_EVENT_FOCUS_LOST) {
145
147
  * print("window.WINDOW_EVENT_FOCUS_LOST");
146
148
  * } else if (event === window.WINDOW_EVENT_FOCUS_GAINED) {
package/index.d.ts CHANGED
@@ -25,6 +25,7 @@ import "./generated/factory";
25
25
  import "./generated/font";
26
26
  import "./generated/go";
27
27
  import "./src/go-overloads";
28
+ import "./src/component-properties";
28
29
  import "./generated/graphics";
29
30
  import "./generated/gui";
30
31
  import "./generated/html5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/types",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "TypeScript types for the Defold engine's Lua APIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,6 +70,7 @@
70
70
  "!scripts/fidelity-audit.ts",
71
71
  "!scripts/augmentation-namespaces.ts",
72
72
  "!scripts/example-pins.ts",
73
+ "!scripts/example-semantics.ts",
73
74
  "!scripts/example-surfaces.ts",
74
75
  "!scripts/example-typecheck.ts"
75
76
  ],
@@ -14,7 +14,8 @@ const PACKAGE_ROOT = resolve(import.meta.dir, "..");
14
14
  const SIGNATURES_PATH = resolve(PACKAGE_ROOT, "api-signatures.json");
15
15
 
16
16
  export interface SignaturesArtifact {
17
- // version -> symbolIdentityKey -> authoritative TS signature text
17
+ // version -> symbolIdentityKey -> authoritative TS signature text; a symbol
18
+ // with several declared arms carries one arm per line, primary first
18
19
  readonly versions: Record<string, Record<string, string>>;
19
20
  // The same axis and keys, carrying each documented slot's rendered type. Kept
20
21
  // parallel rather than folded into `versions` because three consumers
@@ -0,0 +1,96 @@
1
+ import type { ModuleManifestEntry } from "./regen";
2
+
3
+ // A constant family the engine registers and one function's parameter prose
4
+ // lists, but no ref-doc element declares. Each release's own list is the
5
+ // evidence, so a surface declares exactly the members its release documents.
6
+ interface ProseConstantFamily {
7
+ readonly namespace: string;
8
+ readonly prefix: string;
9
+ readonly describes: string;
10
+ readonly evidence: {
11
+ readonly namespace: string;
12
+ readonly function: string;
13
+ readonly parameter: string;
14
+ };
15
+ }
16
+
17
+ const PROSE_CONSTANT_FAMILIES: readonly ProseConstantFamily[] = [
18
+ {
19
+ namespace: "graphics",
20
+ prefix: "SEMANTIC_TYPE_",
21
+ describes: "Vertex attribute semantic type",
22
+ evidence: {
23
+ namespace: "material",
24
+ function: "material.set_vertex_attributes",
25
+ parameter: "attributes",
26
+ },
27
+ },
28
+ ];
29
+
30
+ interface DocElement {
31
+ readonly type: string;
32
+ readonly name: string;
33
+ readonly parameters?: readonly { readonly name: string; readonly doc?: string }[];
34
+ }
35
+
36
+ interface ApiDoc {
37
+ readonly elements: readonly DocElement[];
38
+ }
39
+
40
+ function escapeRegExp(text: string): string {
41
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ }
43
+
44
+ function evidencedNames(family: ProseConstantFamily, modules: readonly ModuleManifestEntry[]) {
45
+ const source = modules.find((m) => m.namespace === family.evidence.namespace);
46
+ if (!source) return [];
47
+ const fn = (source.doc as ApiDoc).elements.find(
48
+ (e) => e.type === "FUNCTION" && e.name === family.evidence.function,
49
+ );
50
+ const prose = fn?.parameters?.find((p) => p.name === family.evidence.parameter)?.doc ?? "";
51
+ const item = new RegExp(
52
+ `<li><code>(${escapeRegExp(`${family.namespace}.${family.prefix}`)}\\w+)</code></li>`,
53
+ "g",
54
+ );
55
+ return [...new Set(Array.from(prose.matchAll(item), (m) => m[1] as string))];
56
+ }
57
+
58
+ function constantElement(name: string, brief: string) {
59
+ return {
60
+ type: "CONSTANT",
61
+ name,
62
+ brief,
63
+ description: "",
64
+ returnvalues: [],
65
+ parameters: [],
66
+ examples: "",
67
+ replaces: "",
68
+ error: "",
69
+ tparams: [],
70
+ members: [],
71
+ notes: [],
72
+ language: "",
73
+ };
74
+ }
75
+
76
+ export function synthesizeProseConstants(
77
+ modules: readonly ModuleManifestEntry[],
78
+ ): ModuleManifestEntry[] {
79
+ let out = [...modules];
80
+ for (const family of PROSE_CONSTANT_FAMILIES) {
81
+ const names = evidencedNames(family, out);
82
+ if (names.length === 0) continue;
83
+ const index = out.findIndex((m) => m.namespace === family.namespace);
84
+ const target = out[index];
85
+ if (!target) continue;
86
+ const doc = target.doc as ApiDoc;
87
+ const declared = new Set(doc.elements.map((e) => e.name));
88
+ const brief = `${family.describes}, for \`${family.evidence.function}\`.`;
89
+ const added = names.filter((n) => !declared.has(n)).map((n) => constantElement(n, brief));
90
+ if (added.length === 0) continue;
91
+ out = out.map((m, i) =>
92
+ i === index ? { ...m, doc: { ...doc, elements: [...doc.elements, ...added] } } : m,
93
+ );
94
+ }
95
+ return out;
96
+ }
package/scripts/regen.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  resolveRefDoc,
27
27
  } from "./doc-source";
28
28
  import { loadTranslations } from "./example-store-io";
29
+ import { synthesizeProseConstants } from "./prose-constants";
29
30
  import { type readZip, SYNC_MANIFEST, type SyncManifestEntry } from "./sync-api-docs";
30
31
 
31
32
  export interface ApiTargetModule {
@@ -99,7 +100,7 @@ export function loadTargetModules(
99
100
  target: ApiTarget,
100
101
  packageRoot: string = PACKAGE_ROOT,
101
102
  ): ModuleManifestEntry[] {
102
- return target.modules.map((module) => {
103
+ const modules = target.modules.map((module) => {
103
104
  const path = resolve(packageRoot, target.fixturesDir, module.fixture);
104
105
  let raw: string;
105
106
  try {
@@ -117,6 +118,7 @@ export function loadTargetModules(
117
118
  };
118
119
  return module.skipFunctions ? { ...entry, skipFunctions: module.skipFunctions } : entry;
119
120
  });
121
+ return synthesizeProseConstants(modules);
120
122
  }
121
123
 
122
124
  export interface ModuleManifestEntry {
@@ -168,7 +170,7 @@ export async function resolveTargetModules(
168
170
  ...(opts.fetchChannelInfo ? { fetchChannelInfo: opts.fetchChannelInfo } : {}),
169
171
  });
170
172
  const syncManifest = opts.syncManifest ?? SYNC_MANIFEST;
171
- return target.modules.map((module) => {
173
+ const modules = target.modules.map((module) => {
172
174
  const sync = syncManifest.find((s) => s.namespace === module.namespace);
173
175
  if (!sync) {
174
176
  throw new Error(
@@ -184,6 +186,7 @@ export async function resolveTargetModules(
184
186
  };
185
187
  return module.skipFunctions ? { ...entry, skipFunctions: module.skipFunctions } : entry;
186
188
  });
189
+ return synthesizeProseConstants(modules);
187
190
  }
188
191
 
189
192
  const API_TARGETS = loadApiTargets();
@@ -556,6 +559,7 @@ const UNIVERSAL_EXTRA_IMPORTS: readonly UniversalExtraImport[] = [
556
559
  { specifier: "../../src/window-event-guard" },
557
560
  { specifier: "../../src/scene-addresses" },
558
561
  { specifier: "../../src/go-overloads" },
562
+ { specifier: "../../src/component-properties" },
559
563
  { specifier: "../../src/render-overloads", restrictedTo: "render" },
560
564
  { specifier: "../../src/vmath-overloads" },
561
565
  ];
package/src/api-doc.ts CHANGED
@@ -20,6 +20,10 @@ export interface ApiTypedef {
20
20
  name: string;
21
21
  functions?: ApiFunction[];
22
22
  properties?: ApiVariable[];
23
+ /** A union alias's top-level arms; a shape with members carries none. */
24
+ types?: string[];
25
+ /** The type text of each parent an interface-backed typedef extends. */
26
+ extends?: string[];
23
27
  /** See {@link ApiFunction.global}. */
24
28
  global?: true;
25
29
  }
@@ -164,10 +168,14 @@ export function parseDefoldApiDoc(input: unknown): ApiModule {
164
168
  function parseTypedef(element: Record<string, unknown>): ApiTypedef {
165
169
  const functions = parseFunctionList(element.functions);
166
170
  const properties = parseVariableList(element.properties);
171
+ const types = parseStringArray(element.types);
172
+ const parents = parseStringArray(element.extends);
167
173
  return {
168
174
  name: stringOr(element.name, ""),
169
175
  ...(functions.length > 0 ? { functions } : {}),
170
176
  ...(properties.length > 0 ? { properties } : {}),
177
+ ...(types.length > 0 ? { types } : {}),
178
+ ...(parents.length > 0 ? { extends: parents } : {}),
171
179
  ...globalKey(element),
172
180
  };
173
181
  }
@@ -0,0 +1,33 @@
1
+ /** @noSelfInFile */
2
+
3
+ import type { Hash } from "./core-types";
4
+
5
+ // Component property keys upstream examples read through `go.get` while no
6
+ // ref-doc catalog declares them. Only evidenced keys belong here.
7
+ declare global {
8
+ namespace sprite {
9
+ interface properties {
10
+ /**
11
+ * The texture bound to the sprite's first sampler. Not in the sprite
12
+ * ref-doc; `resource.set`'s example reads it with
13
+ * `go.get("#sprite", "texture0")`.
14
+ */
15
+ texture0: Hash;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Mesh component properties. Upstream ships no mesh API module, so this
21
+ * catalog holds only the keys upstream examples read.
22
+ */
23
+ namespace mesh {
24
+ interface properties {
25
+ /**
26
+ * The buffer resource holding the mesh's vertex data.
27
+ * `resource.get_buffer` and `resource.set_buffer` read it with
28
+ * `go.get("#mesh", "vertices")`.
29
+ */
30
+ vertices: Hash;
31
+ }
32
+ }
33
+ }
package/src/core-types.ts CHANGED
@@ -214,9 +214,11 @@ export const DEFOLD_TYPE_MAP: Readonly<Record<string, string>> = {
214
214
  texture: 'Opaque<"texture">',
215
215
  render_target: 'Opaque<"render_target">',
216
216
  constant: 'Opaque<"constant">',
217
- constant_buffer: 'Opaque<"constant_buffer">',
217
+ constant_buffer:
218
+ 'Opaque<"constant_buffer"> & { [name: string]: Vector4 | Matrix4 | Vector4[] | Matrix4[] }',
218
219
  buffer: 'Opaque<"buffer">',
219
- bufferstream: 'Opaque<"bufferstream"> & { [index: number]: number }',
220
+ bufferstream:
221
+ 'Opaque<"bufferstream"> & { [index: number]: number; length: LuaLengthMethod<number> }',
220
222
  userdata: 'Opaque<"userdata">',
221
223
  // Vocabulary, not a shipped brand: no current ref-doc fixture types a slot
222
224
  // `resource`, so nothing hand-written may name `Opaque<"resource">`. Kept so a
@@ -41,14 +41,15 @@ declare global {
41
41
  // diagnostic at the call site rather than the declaration.
42
42
  /**
43
43
  * The payload declared for a message id: its `BuiltinMessages` entry, else
44
- * its `CustomMessages` entry, else the open record an undeclared id keeps.
45
- * An id declared in both resolves to the built-in payload.
44
+ * its `CustomMessages` entry, else the open table an undeclared id keeps: a
45
+ * record or a Lua sequence. An id declared in both resolves to the built-in
46
+ * payload.
46
47
  */
47
48
  type MessagePayload<K> = K extends BuiltinMessageId
48
49
  ? BuiltinMessages[K]
49
50
  : K extends keyof CustomMessages
50
51
  ? CustomMessages[K]
51
- : Record<string | number, unknown>;
52
+ : Record<string | number, unknown> | readonly unknown[];
52
53
  }
53
54
 
54
55
  export {};
@@ -1,19 +1,83 @@
1
1
  const NAMED_ENTITIES: Record<string, string> = {
2
- "&lt;": "<",
3
- "&gt;": ">",
4
- "&quot;": '"',
5
- "&#39;": "'",
6
- "&apos;": "'",
2
+ amp: "&",
3
+ lt: "<",
4
+ gt: ">",
5
+ quot: '"',
6
+ apos: "'",
7
+ // A non-breaking space in JSDoc is invisible noise; a plain space reads the same.
8
+ nbsp: " ",
9
+ times: "×",
10
+ copy: "©",
11
+ mdash: "—",
12
+ ndash: "–",
13
+ hellip: "…",
14
+ middot: "·",
15
+ deg: "°",
16
+ plusmn: "±",
17
+ le: "≤",
18
+ ge: "≥",
19
+ ne: "≠",
20
+ rarr: "→",
21
+ larr: "←",
22
+ lsquo: "‘",
23
+ rsquo: "’",
24
+ ldquo: "“",
25
+ rdquo: "”",
7
26
  };
8
27
 
28
+ // One pass over the text, so a character an entity decodes to (`&amp;` -> `&`)
29
+ // is never read as the start of another entity.
9
30
  function decodeEntities(text: string): string {
10
- let out = text;
11
- for (const [entity, char] of Object.entries(NAMED_ENTITIES)) {
12
- out = out.split(entity).join(char);
31
+ return text.replace(/&(?:#[xX]([0-9a-fA-F]+)|#(\d+)|([a-zA-Z]+));/g, (entity, hex, dec, name) => {
32
+ if (hex !== undefined) return codePointOr(Number.parseInt(hex, 16), entity);
33
+ if (dec !== undefined) return codePointOr(Number(dec), entity);
34
+ return Object.hasOwn(NAMED_ENTITIES, name) ? (NAMED_ENTITIES[name] ?? entity) : entity;
35
+ });
36
+ }
37
+
38
+ function codePointOr(code: number, fallback: string): string {
39
+ return code <= 0x10ffff ? String.fromCodePoint(code) : fallback;
40
+ }
41
+
42
+ /**
43
+ * Join a list marker left alone on its line back onto the item's first content
44
+ * line. Upstream prose puts a newline between `<li>` and the item's first word,
45
+ * which the whitespace pass preserves, dropping the value out of the list. The
46
+ * item's remaining lines stay where they are: Markdown's lazy continuation
47
+ * already keeps an unindented line inside the item's paragraph.
48
+ */
49
+ function weldStrandedListMarkers(text: string): string {
50
+ const lines = text.split("\n");
51
+ const out: string[] = [];
52
+ let inFence = false;
53
+ for (let index = 0; index < lines.length; index += 1) {
54
+ const line = lines[index] ?? "";
55
+ if (line.startsWith("```")) {
56
+ inFence = !inFence;
57
+ out.push(line);
58
+ continue;
59
+ }
60
+ if (inFence || line !== "-") {
61
+ out.push(line);
62
+ continue;
63
+ }
64
+ let ahead = index + 1;
65
+ while (ahead < lines.length && lines[ahead] === "") ahead += 1;
66
+ const content = lines[ahead];
67
+ if (
68
+ content === undefined ||
69
+ content === "-" ||
70
+ content.startsWith("- ") ||
71
+ content.startsWith("```")
72
+ ) {
73
+ // No value to weld onto — drop the marker and the blank run behind it.
74
+ index = ahead - 1;
75
+ continue;
76
+ }
77
+ out.push(`- ${content}`);
78
+ index = ahead;
13
79
  }
14
- out = out.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
15
- // `&amp;` last so an already-decoded `&` is never re-interpreted.
16
- return out.split("&amp;").join("&");
80
+ return out.join("\n").trim();
17
81
  }
18
82
 
19
83
  /**
@@ -45,6 +109,12 @@ export function htmlToDocText(html: string): string {
45
109
  .replace(/\n{3,}/g, "\n\n")
46
110
  .trim();
47
111
 
112
+ text = weldStrandedListMarkers(text);
113
+
114
+ // Upstream prose can open a fence and end mid-body; left open it swallows the
115
+ // rest of the JSDoc block and everything after it on the rendered page.
116
+ if ((text.match(/^```/gm) ?? []).length % 2 === 1) text += "\n```";
117
+
48
118
  // A literal `*/` would close the JSDoc comment early; escape it.
49
119
  return text.split("*/").join("*\\/");
50
120
  }
@@ -70,45 +140,169 @@ export function htmlToCodeText(html: string): string {
70
140
  return collapsed.split("*/").join("*\\/");
71
141
  }
72
142
 
73
- /**
74
- * Convert a ref-doc `examples` HTML fragment — prose interleaved with one or
75
- * more `<div class="codehilite">…</div>` syntax-highlight blocks — into Markdown:
76
- * prose runs become `htmlToDocText`, each highlight block becomes a fence via
77
- * `htmlToCodeText`. The fence language comes from a `class="language-X"` on the
78
- * block's `<code>` (as converted extension `.script_api` examples carry), else
79
- * `lua`, since engine ref-doc blocks carry no language class. A fragment with no
80
- * `codehilite` block is wrapped whole as a single ` ```lua ` fence (back-compat
81
- * for plain-code examples). Returns `""` for empty / whitespace-only input.
82
- */
83
- export function examplesHtmlToMarkdown(html: string): string {
84
- if (html.trim() === "") return "";
143
+ // One run of the `examples` fragment: the inner markup of a `<div
144
+ // class="codehilite">` block, or the markup between two of them. `lang` is
145
+ // meaningful on a code region only.
146
+ interface ExampleRegion {
147
+ kind: "code" | "prose";
148
+ html: string;
149
+ lang: string;
150
+ }
85
151
 
86
- const parts: string[] = [];
152
+ // The one `codehilite` walk. Both the markdown lane (`examplesHtmlToMarkdown`)
153
+ // and the store lane (`splitExampleSources`) read their regions from here, so
154
+ // the two cannot disagree about where an example begins. `matched` is false for
155
+ // a fragment carrying no block at all, which both lanes treat as one whole-blob
156
+ // code run.
157
+ function scanExampleRegions(html: string): { matched: boolean; regions: ExampleRegion[] } {
158
+ const regions: ExampleRegion[] = [];
87
159
  const blocks = /<div class="codehilite">([\s\S]*?)<\/div>/gi;
88
160
  let lastIndex = 0;
89
161
  let matched = false;
90
162
  for (let match = blocks.exec(html); match !== null; match = blocks.exec(html)) {
91
163
  matched = true;
92
- const prose = htmlToDocText(html.slice(lastIndex, match.index));
93
- if (prose !== "") parts.push(prose);
164
+ regions.push({ kind: "prose", html: html.slice(lastIndex, match.index), lang: "lua" });
94
165
  const inner = match[1] ?? "";
95
- const lang = /<code\b[^>]*\bclass="language-([^"\s]+)"/i.exec(inner)?.[1] ?? "lua";
96
- const code = htmlToCodeText(inner);
97
- if (code !== "") parts.push(`\`\`\`${lang}\n${code}\n\`\`\``);
166
+ regions.push({
167
+ kind: "code",
168
+ html: inner,
169
+ lang: /<code\b[^>]*\bclass="language-([^"\s]+)"/i.exec(inner)?.[1] ?? "lua",
170
+ });
98
171
  lastIndex = match.index + match[0].length;
99
172
  }
173
+ if (!matched) return { matched: false, regions: [{ kind: "code", html, lang: "lua" }] };
174
+ regions.push({ kind: "prose", html: html.slice(lastIndex), lang: "lua" });
175
+ return { matched: true, regions };
176
+ }
100
177
 
101
- if (!matched) {
102
- const code = htmlToCodeText(html);
103
- return code === "" ? "" : `\`\`\`lua\n${code}\n\`\`\``;
104
- }
178
+ /**
179
+ * Convert a ref-doc `examples` HTML fragment — prose interleaved with one or
180
+ * more `<div class="codehilite">…</div>` syntax-highlight blocks — into Markdown,
181
+ * one fence per example that `segmentExampleRegions` carves out. Each segment
182
+ * emits its leading prose then a fence in its own language: `class="language-X"`
183
+ * on the block's `<code>` (as converted extension `.script_api` examples carry)
184
+ * or an inner ` ```X ` opener, else `lua`, since engine ref-doc blocks carry no
185
+ * language class. Prose trailing the last example is emitted last, and a
186
+ * fragment with no `codehilite` block is wrapped whole as a single ` ```lua `
187
+ * fence (back-compat for plain-code examples). Returns `""` for empty /
188
+ * whitespace-only input.
189
+ */
190
+ export function examplesHtmlToMarkdown(html: string): string {
191
+ const { segments, trailingProse } = segmentExampleRegions(html);
105
192
 
106
- const trailing = htmlToDocText(html.slice(lastIndex));
107
- if (trailing !== "") parts.push(trailing);
193
+ const parts: string[] = [];
194
+ for (const segment of segments) {
195
+ if (segment.prose !== "") parts.push(segment.prose);
196
+ parts.push(`\`\`\`${segment.lang}\n${segment.code}\n\`\`\``);
197
+ }
198
+ if (trailingProse !== "") parts.push(trailingProse);
108
199
 
109
200
  return parts.join("\n\n");
110
201
  }
111
202
 
203
+ /** One example carved out of an `examples` fragment, with the prose leading it. */
204
+ export interface ExampleSegment {
205
+ prose: string;
206
+ code: string;
207
+ lang: string;
208
+ }
209
+
210
+ // A line that opens a code run: ``` followed by a bare language token and
211
+ // nothing else. Any other ``` line closes the run, its remainder being prose
212
+ // upstream welded onto the fence.
213
+ const FENCE_OPENER = /^```([A-Za-z0-9_+-]+)$/;
214
+
215
+ /** The examples a fragment carries, plus any prose left over after the last one. */
216
+ export interface ExampleSegmentation {
217
+ segments: ExampleSegment[];
218
+ /** Prose following the final code run, which belongs to no segment. */
219
+ trailingProse: string;
220
+ }
221
+
222
+ /**
223
+ * Carve an `examples` HTML fragment into one segment per example it carries.
224
+ *
225
+ * A `<div class="codehilite">` block is a code region and the markup between
226
+ * two of them is a prose region; inside either, a ` ``` ` line switches between
227
+ * code and prose, and an opener reached while already in code ends the running
228
+ * example and starts the next. Upstream writes several examples both ways — as
229
+ * several blocks, and as its own Markdown fences inside one block — so both
230
+ * signals are read.
231
+ *
232
+ * This is the walk alone: no whole-blob fallback, so a caller sees the real
233
+ * segments even where there is only one. `splitExampleSources` adds the
234
+ * stability post-pass that keys stored translations.
235
+ */
236
+ export function segmentExampleRegions(html: string): ExampleSegmentation {
237
+ if (html.trim() === "") return { segments: [], trailingProse: "" };
238
+
239
+ const { regions } = scanExampleRegions(html);
240
+ const segments: ExampleSegment[] = [];
241
+ let codeLines: string[] = [];
242
+ let proseLines: string[] = [];
243
+ let lang = "lua";
244
+
245
+ const flush = () => {
246
+ const code = trimBlankEdges(codeLines).join("\n");
247
+ codeLines = [];
248
+ if (code === "") return;
249
+ segments.push({ prose: trimBlankEdges(proseLines).join("\n"), code, lang });
250
+ proseLines = [];
251
+ };
252
+
253
+ for (const region of regions) {
254
+ const text = region.kind === "code" ? htmlToCodeText(region.html) : htmlToDocText(region.html);
255
+ let inCode = region.kind === "code";
256
+ if (region.kind === "code") lang = region.lang;
257
+ // Prose surviving an empty code run keeps its paragraph break from the next.
258
+ if (region.kind === "prose" && text !== "" && proseLines.length > 0) proseLines.push("");
259
+ for (const line of text === "" ? [] : text.split("\n")) {
260
+ const trimmed = line.trim();
261
+ if (!trimmed.startsWith("```")) {
262
+ (inCode ? codeLines : proseLines).push(line);
263
+ continue;
264
+ }
265
+ const opener = FENCE_OPENER.exec(trimmed);
266
+ if (opener) {
267
+ if (inCode) flush();
268
+ inCode = true;
269
+ lang = opener[1] ?? lang;
270
+ continue;
271
+ }
272
+ flush();
273
+ inCode = false;
274
+ const remainder = trimmed.slice(3).trim();
275
+ if (remainder !== "") proseLines.push(remainder);
276
+ }
277
+ flush();
278
+ }
279
+
280
+ return { segments, trailingProse: trimBlankEdges(proseLines).join("\n") };
281
+ }
282
+
283
+ /**
284
+ * `segmentExampleRegions` plus the stability post-pass: a fragment that yields
285
+ * at most one segment returns the whole-blob `htmlToCodeText` verbatim, prose
286
+ * lines included. That is the string every stored translation is pinned
287
+ * against, so a single-example element keeps its identity and cannot be re-keyed
288
+ * by this walk.
289
+ */
290
+ export function splitExampleSources(html: string): ExampleSegment[] {
291
+ const { segments } = segmentExampleRegions(html);
292
+ if (segments.length > 1) return segments;
293
+ if (html.trim() === "") return [];
294
+ const whole = htmlToCodeText(html);
295
+ if (whole === "") return [];
296
+ return [{ prose: "", code: whole, lang: segments[0]?.lang ?? "lua" }];
297
+ }
298
+
299
+ function trimBlankEdges(lines: readonly string[]): string[] {
300
+ const out = [...lines];
301
+ while (out.length > 0 && out[0]?.trim() === "") out.shift();
302
+ while (out.length > 0 && out[out.length - 1]?.trim() === "") out.pop();
303
+ return out;
304
+ }
305
+
112
306
  export interface DocCommentParts {
113
307
  summary: string;
114
308
  // Present exactly when the source carried a deprecation tag; `""` is the bare
@@ -117,8 +311,12 @@ export interface DocCommentParts {
117
311
  deprecated?: string;
118
312
  params?: { name: string; doc: string }[];
119
313
  returns?: string;
120
- example?: string;
121
- exampleLang?: "lua" | "ts";
314
+ // One entry per example the element documents, each rendered as its own
315
+ // `@example` block. An element whose ref-doc blob holds several examples
316
+ // carries several entries; a blank body is dropped. `prose` is the sentence
317
+ // introducing that example, rendered above its fence; blank or absent leaves
318
+ // the block byte-identical to a prose-less one.
319
+ examples?: { text: string; lang: "lua" | "ts"; prose?: string }[];
122
320
  }
123
321
 
124
322
  /**
@@ -129,14 +327,14 @@ export function renderDocComment(parts: DocCommentParts): string[] {
129
327
  const summaryLines = parts.summary.trim() === "" ? [] : parts.summary.split("\n");
130
328
  const params = (parts.params ?? []).filter((p) => p.doc.trim() !== "");
131
329
  const returns = parts.returns?.trim() ? parts.returns : "";
132
- const example = parts.example?.trim() ? parts.example : "";
330
+ const examples = (parts.examples ?? []).filter((entry) => entry.text.trim() !== "");
133
331
  const deprecated = parts.deprecated;
134
332
 
135
333
  if (
136
334
  summaryLines.length === 0 &&
137
335
  params.length === 0 &&
138
336
  returns === "" &&
139
- example === "" &&
337
+ examples.length === 0 &&
140
338
  deprecated === undefined
141
339
  ) {
142
340
  return [];
@@ -147,7 +345,8 @@ export function renderDocComment(parts: DocCommentParts): string[] {
147
345
  lines.push(line === "" ? " *" : ` * ${line}`);
148
346
  }
149
347
 
150
- const hasTags = deprecated !== undefined || params.length > 0 || returns !== "" || example !== "";
348
+ const hasTags =
349
+ deprecated !== undefined || params.length > 0 || returns !== "" || examples.length > 0;
151
350
  if (summaryLines.length > 0 && hasTags) {
152
351
  lines.push(" *");
153
352
  }
@@ -173,10 +372,15 @@ export function renderDocComment(parts: DocCommentParts): string[] {
173
372
  lines.push(line === "" ? " *" : ` * ${line}`);
174
373
  }
175
374
  }
176
- if (example !== "") {
375
+ for (const entry of examples) {
177
376
  lines.push(" * @example");
178
- lines.push(` * \`\`\`${parts.exampleLang ?? "lua"}`);
179
- for (const line of example.split("\n")) {
377
+ if (entry.prose !== undefined && entry.prose.trim() !== "") {
378
+ for (const line of entry.prose.split("\n")) {
379
+ lines.push(line === "" ? " *" : ` * ${line}`);
380
+ }
381
+ }
382
+ lines.push(` * \`\`\`${entry.lang}`);
383
+ for (const line of entry.text.split("\n")) {
180
384
  lines.push(line === "" ? " *" : ` * ${line}`);
181
385
  }
182
386
  lines.push(" * ```");