partial-tsx 0.0.4 → 0.0.6

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
@@ -19,7 +19,10 @@ normalizeGeneratedTsx("function Card() { return <button>OK");
19
19
  - Markdown fences removed.
20
20
  - Safe partial syntax completed.
21
21
  - Missing default export added when a component can be found.
22
+ Pass `{ synthesizeDefaultExport: false }` to skip this and wait for the author's own `export default`.
23
+ Which side to pick is a deliberate trade-off — spelled out on `PartialTsxOptions.synthesizeDefaultExport`.
22
24
  - Missing imports added for known React hooks such as `useState`.
25
+ - `normalizeGeneratedTsxWithMetadata(code)` returns that same TSX as `source`, plus the `inferredComponent` it synthesized the default export from (absent when the code already declares one). Pass `{ preferredComponent }` to keep that component selected while the source still ends inside an unfinished statement, so a host streaming a prefix can hold an entry it has already displayed. At a top-level boundary the last declared component wins again.
23
26
  - `{ mode: "streaming" }` first rolls script back to its last committed syntax boundary. It never closes an unfinished call, initializer, callback, control-flow statement, or JSX `{expression`; unterminated JSX string attributes remain the one value-completion exception.
24
27
  - The default `{ mode: "final" }` preserves the existing completion behavior. `partial-react` uses streaming mode only for incremental frames and sends final input to the compiler unchanged.
25
28
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "partial-tsx",
3
3
  "description": "Utilities for completing prefix-growing TSX.",
4
- "version": "0.0.4",
4
+ "version": "0.0.6",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
package/src/partial.ts CHANGED
@@ -2,9 +2,29 @@ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "i
2
2
  const BLOCK_FOLLOWERS: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
3
3
  const BLOCK_CLOSERS = new Set(Object.values(BLOCK_FOLLOWERS));
4
4
  export type PartialTsxMode = "streaming" | "final";
5
- export type PartialTsxOptions = { mode?: PartialTsxMode };
5
+ export type PartialTsxOptions = {
6
+ mode?: PartialTsxMode;
7
+ // Deliberate trade-off, not a bug fix — please keep both sides in mind before flipping the default.
8
+ //
9
+ // On (the default): every component declared so far gets a turn as the entry point, so a growing buffer shows
10
+ // something as early as possible. The payoff is more streaming and more of the intermediate process on screen,
11
+ // which is what makes a live preview feel alive and is why the debug workbenches want it.
12
+ //
13
+ // The cost is that those intermediate frames are not always what a viewer should be seeing yet. A half-written
14
+ // modal shows up as a stray fragment; an answer renders as a loose piece of its own text. They are closer to
15
+ // demo material and inspiration than to a product surface, and the synthesized default also tracks whichever
16
+ // component is currently last, so a helper that precedes the real `export default` paints, then gives way to a
17
+ // half-written entry point and blanks the pane.
18
+ //
19
+ // Off: wait for the author's own `export default`. Less to look at while the code is still growing, but every
20
+ // frame that does paint is a frame that is meant to be seen. The embed turns it off for that reason; a
21
+ // live-typing preview leaves it on.
22
+ synthesizeDefaultExport?: boolean;
23
+ };
24
+ // `preferredComponent` only steers the implicit-entry choice, which `completePartialTsx` does not make.
25
+ export type NormalizeGeneratedTsxOptions = PartialTsxOptions & { preferredComponent?: string };
6
26
  type BlockKind = "function" | "control" | "arrow" | "class" | "method" | "block" | "other";
7
- type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number; close?: number; jsxExpression?: boolean; statementStart?: number; kind?: BlockKind } | { type: "jsx"; tag: string; open?: number };
27
+ type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number; close?: number; headerEnd?: number; jsxExpression?: boolean; statementStart?: number; kind?: BlockKind } | { type: "jsx"; tag: string; open?: number };
8
28
 
9
29
  // Single source of truth for the JSX-text fast path in the four lexer loops below: only `<` (child tag) and `{` (child
10
30
  // expression) leave text mode, so none of them reads `(`/`[`/quotes/`//` in ordinary text as unfinished JavaScript.
@@ -168,7 +188,8 @@ const isAmbientDeclaration = (code: string, index: number) => /\bdeclare\s+(?:ab
168
188
  const findExpressionBindingName = (code: string, index: number) => /\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$.]*\s*(?:<[^<>()]*>)?\s*\(\s*)?$/.exec(code.slice(0, index))?.[1] ?? null;
169
189
  const isExpressionPosition = (code: string, index: number) => !/(?:^|[;}]|(?<!\$)\{|\n)\s*(?:(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?)$/.test(code.slice(0, index));
170
190
 
171
- const findLastExportableComponent = (code: string) => {
191
+ // `isTrailingOpen` is lazy: only a live preference that the last-declared rule would override pays for the extra scan.
192
+ const findExportableComponent = (code: string, preferredComponent?: string, isTrailingOpen?: () => boolean) => {
172
193
  const candidates: Array<{ name: string; index: number }> = [];
173
194
  for (const match of code.matchAll(/\bfunction\s+([A-Z][\w$]*)\s*\(/g)) {
174
195
  const index = match.index ?? 0;
@@ -186,7 +207,17 @@ const findLastExportableComponent = (code: string) => {
186
207
  for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\s*/g)) {
187
208
  if (findWrappedComponentCallOpen(code, (match.index ?? 0) + match[0].length) !== -1) candidates.push({ name: match[1], index: match.index ?? 0 });
188
209
  }
189
- return candidates.toSorted((left, right) => left.index - right.index).at(-1)?.name ?? null;
210
+ const last = candidates.toSorted((left, right) => left.index - right.index).at(-1)?.name;
211
+ if (!last || last === preferredComponent || !candidates.some((candidate) => candidate.name === preferredComponent)) return last;
212
+ return isTrailingOpen?.() ? preferredComponent : last;
213
+ };
214
+ // A preference only bridges the frames where the stream stops inside a statement (e.g. a helper's unclosed body completed to an
215
+ // empty `function WhyOnlyA(){}`). Once the source reaches a top-level boundary, last-declared wins again, so a leading helper
216
+ // that rendered first cannot keep the entry from the component declared after it.
217
+ // An EOF comment is reported as an unclosed literal too, but it cannot extend the statement before it.
218
+ const isInsideStatement = (code: string) => {
219
+ const { stack, unclosedLiteralStart, incompleteJsxTagStart } = scanStreamingSource(code);
220
+ return stack.length > 0 || incompleteJsxTagStart !== null || (unclosedLiteralStart !== null && !/^\/[/*]/.test(code.slice(unclosedLiteralStart)));
190
221
  };
191
222
  // `export default` is also valid mid-line after a `;` or block-closing `}` (`;export default …`, `if(a){} export default …`); match those
192
223
  // so the scan does not miss an existing default and append a second one (`Duplicate export of 'default'`).
@@ -212,7 +243,7 @@ const maskAmbientBlocks = (scanned: string) => {
212
243
  // Reuse the normalizer's lexical scan: component declarations inside comments or strings are not preview entry points.
213
244
  export function hasReactComponent(code: string) {
214
245
  const scanned = maskAmbientBlocks(maskIgnoredTextForExportScan(code));
215
- if (findLastExportableComponent(scanned)) return true;
246
+ if (findExportableComponent(scanned)) return true;
216
247
  for (const match of scanned.matchAll(/(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?/g)) {
217
248
  const start = match.index + match[0].length;
218
249
  const expression = scanned.slice(start);
@@ -321,10 +352,10 @@ const isLikelyJsxTagStart = (code: string, index: number) => {
321
352
  return previousChar !== ">";
322
353
  };
323
354
 
324
- const isLikelyStatementBlock = (code: string, index: number, stack: CompletionFrame[]) => {
355
+ const isLikelyStatementBlock = (code: string, index: number, headerEnd: number, stack: CompletionFrame[]) => {
325
356
  // A JSX child expression stays an expression even when the preceding prose looks like a JavaScript block header.
326
357
  if (code[index] !== "{" || isInsideJsxText(stack)) return false;
327
- const before = code.slice(0, index).trimEnd();
358
+ const before = code.slice(0, headerEnd).trimEnd();
328
359
  return /\bfunction(?:\s+[\w$]+)?\s*\([^)]*\)$/.test(before) || /\b(?:if|for|while|switch|with)\s*\([^)]*\)$/.test(before) || /\bcatch(?:\s*\([^)]*\))?$/.test(before) || /(?:^|[^\w$])(?:else|try|finally|do)$/.test(before) || before.endsWith("=>");
329
360
  };
330
361
 
@@ -1240,10 +1271,13 @@ const scanStreamingSource = (code: string): StreamingScan => {
1240
1271
  statementRegexBoundary = false;
1241
1272
  const inheritedStart = currentStatementStart();
1242
1273
  const jsxExpression = char === "{" && insideJsxText;
1243
- const kind = jsxExpression ? undefined : getBlockKind(code, index, inheritedStart);
1274
+ // Both classification and later JSX-shell checks must stop before trailing comments. Reuse lexical provenance
1275
+ // instead of rescanning the prefix or stripping comment-like text inside literals (#1814).
1276
+ const headerEnd = lastSignificantEnd;
1277
+ const kind = jsxExpression ? undefined : getBlockKind(code, headerEnd, inheritedStart);
1244
1278
  const statement = !jsxExpression && kind !== "other";
1245
- const statementStart = statement ? findKeywordStart(code, index, inheritedStart, kind ?? "other") : inheritedStart;
1246
- stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, jsxExpression, statementStart, kind });
1279
+ const statementStart = statement ? findKeywordStart(code, headerEnd, inheritedStart, kind ?? "other") : inheritedStart;
1280
+ stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, headerEnd, jsxExpression, statementStart, kind });
1247
1281
  if (statement && char === "{") scopes.push({ statementStart: index + 1 });
1248
1282
  lastSignificantEnd = index + 1;
1249
1283
  index += 1;
@@ -1286,7 +1320,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1286
1320
  return { stack, closedBlocks, scopes, regexLiteralStarts, lastSignificantEnd, unclosedLiteralStart, unclosedLiteralStatementStart, incompleteJsxTagStart };
1287
1321
  };
1288
1322
 
1289
- const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.open ?? 0).trim();
1323
+ const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.headerEnd ?? frame.open ?? 0).trim();
1290
1324
  const isComponentFunctionFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(FUNCTION_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
1291
1325
  const isFunctionDeclarationFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => FUNCTION_HEADER.test(getFrameHeader(code, frame));
1292
1326
  const isComponentClassFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(CLASS_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
@@ -1642,6 +1676,7 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1642
1676
  };
1643
1677
  const stack: CompletionFrame[] = [];
1644
1678
  let index = 0;
1679
+ let lastSignificantEnd = 0;
1645
1680
  const push = (value: string) => {
1646
1681
  output += value;
1647
1682
  };
@@ -1650,6 +1685,7 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1650
1685
  // Keep completion and committed-prefix scanning in the same lexical mode; never append ')' or ']' to plain text.
1651
1686
  if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
1652
1687
  push(char);
1688
+ if (!/\s/.test(char)) lastSignificantEnd = index + 1;
1653
1689
  index += 1;
1654
1690
  continue;
1655
1691
  }
@@ -1674,16 +1710,22 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1674
1710
  index = closeIndex + 2;
1675
1711
  continue;
1676
1712
  }
1713
+ // Match scanStreamingSource's header boundary without losing comments from the completed output.
1714
+ const headerEnd = lastSignificantEnd;
1715
+ if (!/\s/.test(char)) lastSignificantEnd = index + 1;
1677
1716
  if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
1678
1717
  index = consumeRegexLiteral(code, index, push);
1718
+ lastSignificantEnd = index;
1679
1719
  continue;
1680
1720
  }
1681
1721
  if (char === '"' || char === "'") {
1682
1722
  index = consumeStringLiteral(code, index, push);
1723
+ lastSignificantEnd = index;
1683
1724
  continue;
1684
1725
  }
1685
1726
  if (char === "`") {
1686
1727
  index = consumeTemplateLiteral(code, index, push);
1728
+ lastSignificantEnd = index;
1687
1729
  continue;
1688
1730
  }
1689
1731
  if (char === "<" && isInsideJsxText(stack) && !shouldStartJsxTag(code, index, stack) && index < code.length - 1) {
@@ -1712,11 +1754,12 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1712
1754
  const tagName = match?.[1] ?? (tag === "<>" || tag === "</>" ? "" : null);
1713
1755
  if (!(tagName && VOID_TAGS.has(tagName) && tag.startsWith("</"))) push(tagName && VOID_TAGS.has(tagName) && !tag.endsWith("/>") ? tag.replace(/\s*\/?\s*>$/, " />") : tag);
1714
1756
  index = closeIndex + 1;
1757
+ lastSignificantEnd = index;
1715
1758
  updateJsxStackForTag(stack, tag);
1716
1759
  continue;
1717
1760
  }
1718
1761
  if (char in BLOCK_FOLLOWERS) {
1719
- stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index, stack), open: output.length, jsxExpression: char === "{" && isInsideJsxText(stack) });
1762
+ stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index, headerEnd, stack), open: output.length, jsxExpression: char === "{" && isInsideJsxText(stack) });
1720
1763
  push(char);
1721
1764
  index += 1;
1722
1765
  continue;
@@ -1744,17 +1787,21 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1744
1787
  return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
1745
1788
  }
1746
1789
 
1747
- export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
1748
- let next = completePartialTsx(stripMarkdownFence(code), options).trim();
1790
+ export function normalizeGeneratedTsx(code: string, options: NormalizeGeneratedTsxOptions = {}) {
1791
+ return normalizeGeneratedTsxWithMetadata(code, options).source;
1792
+ }
1793
+
1794
+ /** Hosts may retain an already displayed implicit entry without treating a synthesized export as an explicit one. */
1795
+ export function normalizeGeneratedTsxWithMetadata(code: string, options: NormalizeGeneratedTsxOptions = {}): { source: string; inferredComponent?: string } {
1796
+ const raw = stripMarkdownFence(code);
1797
+ let next = completePartialTsx(raw, options).trim();
1749
1798
  let exportScanCode = maskIgnoredTextForExportScan(next);
1750
1799
  // The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
1751
1800
  // false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
1752
1801
  if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
1753
1802
  exportScanCode = maskAmbientBlocks(exportScanCode);
1754
- if (!hasDefaultExportDeclaration(exportScanCode)) {
1755
- const componentName = findLastExportableComponent(exportScanCode);
1756
- if (componentName) next += `\nexport default ${componentName};`;
1757
- }
1803
+ const inferredComponent = options.synthesizeDefaultExport !== false && !hasDefaultExportDeclaration(exportScanCode) ? findExportableComponent(exportScanCode, options.preferredComponent, () => isInsideStatement(raw)) : undefined;
1804
+ if (inferredComponent) next += `\nexport default ${inferredComponent};`;
1758
1805
  next = injectMissingReactHookImports(next, exportScanCode);
1759
- return next;
1806
+ return { source: next, inferredComponent };
1760
1807
  }