partial-tsx 0.0.5 → 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 +3 -0
- package/package.json +1 -1
- package/src/partial.ts +46 -11
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
package/src/partial.ts
CHANGED
|
@@ -2,7 +2,27 @@ 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 = {
|
|
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
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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);
|
|
@@ -1756,17 +1787,21 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1756
1787
|
return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
|
|
1757
1788
|
}
|
|
1758
1789
|
|
|
1759
|
-
export function normalizeGeneratedTsx(code: string, options:
|
|
1760
|
-
|
|
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();
|
|
1761
1798
|
let exportScanCode = maskIgnoredTextForExportScan(next);
|
|
1762
1799
|
// The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
|
|
1763
1800
|
// false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
|
|
1764
1801
|
if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
|
|
1765
1802
|
exportScanCode = maskAmbientBlocks(exportScanCode);
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
if (componentName) next += `\nexport default ${componentName};`;
|
|
1769
|
-
}
|
|
1803
|
+
const inferredComponent = options.synthesizeDefaultExport !== false && !hasDefaultExportDeclaration(exportScanCode) ? findExportableComponent(exportScanCode, options.preferredComponent, () => isInsideStatement(raw)) : undefined;
|
|
1804
|
+
if (inferredComponent) next += `\nexport default ${inferredComponent};`;
|
|
1770
1805
|
next = injectMissingReactHookImports(next, exportScanCode);
|
|
1771
|
-
return next;
|
|
1806
|
+
return { source: next, inferredComponent };
|
|
1772
1807
|
}
|