/** * The visual editor's ONE decision module — every editability verdict, refusal * predicate, escape rule and coordinate convention, in a single browser-safe * file with no imports. * * ☠️ **Why this file exists: the system has TWO parsers by necessity and used to * have two IMPLEMENTATIONS of every decision by accident.** The pod parses with * oxc (native, fast enough for the tagger's per-transform hot path, byte * offsets for MagicString splices); the cockpit parses with Babel (the only * maintained parser that runs in a browser — `@oxc-parser/wasm` died at 0.60.0 * in 2025 — and the only one with a printer for the file plane). Neither side * can adopt the other's parser, so for a year every predicate lived twice, * maintained as a hand "mirror" with docblocks pleading that they stay * byte-for-byte — and they drifted, four separate times, each drift shipping as * an owner-facing bug: a shape one side called editable and the other refused * is either an element the inspector won't select or a green success over a * file nothing wrote. * * The fix is not one parser — it is ONE implementation. Everything here is a * pure function over STRUCTURAL nodes ({@link AnalysisNode}) and accepts BOTH * tree dialects: oxc's ESTree output (one `Literal` kind, * `StaticMemberExpression`) and Babel's (split `StringLiteral` / * `NumericLiteral` / …, `MemberExpression`). The pod's analyzer and transformer * call these over oxc trees; the cockpit's AstEngine calls the SAME functions * over Babel trees. `analysis-core.parity.spec.ts` parses one fixture corpus * with both parsers and asserts identical verdicts — the drift gate that * replaces the hand-mirror discipline. * * ⚠️ **Browser-safe is a hard constraint, not a preference.** The cockpit * imports this file into a Vite SPA bundle (`@proyecta-ai/vite/analysis`), so * nothing here may import `fs`, `path`, `oxc-parser`, `magic-string`, or * anything else — the impure shells stay in `ast-analyzer.ts` / * `ast-transformer.ts`. * * ⚠️ **Version skew is the residual, and it is bounded by design.** A pod's * copy of this module is frozen at seed time; the cockpit's is evergreen. That * skew existed before (as two files drifting apart in the same repo) — what is * new is that at any single version there is exactly one behaviour, so skew is * an honest "old pod, old rules" rather than "two current files disagree". */ /** * A parsed node, structurally: the shape shared by oxc's ESTree output and * Babel's AST for every construct these decisions touch. One declaration, so * the writer, the analyzer's gate and the cockpit's pre-flight cannot come to * hold three different ideas of the same tree. */ export interface AnalysisNode { type: string; start: number; end: number; [key: string]: unknown; } /** * The `data-ve-line` / `data-ve-col` convention: BOTH are 1-based. * * ☠️ The cockpit's Babel `loc.start.column` is 0-BASED, and for a year the * cockpit compared it against these 1-based coordinates raw — every lookup was * off by one and only worked because a ±3 "drift" fallback absorbed it. Any * consumer of a Babel `loc` must add 1 before comparing against a `data-ve-*` * coordinate; {@link offsetToLineCol} is the tagger's own arithmetic and is * 1-based by construction. */ export declare const VE_COLUMN_BASE = 1; /** 1-based line/col of a byte offset — the tagger's own arithmetic. */ export declare function offsetToLineCol(source: string, offset: number): { line: number; col: number; }; /** * ☠️ **The column tolerance is a FALLBACK, not a match — the NEAREST opening tag * on the line wins, and an exact column always beats it.** * * `component-tagger.ts` emits `offsetToLineCol(code, node.start)`, the same * function above, so a legitimate coordinate is EXACT. The tolerance only ever * existed as slack for a coordinate that drifted. This is the ONE home of the * constant: the analyzer, the transformer and the cockpit's own finder used to * carry private copies (5, 5 and 3), and the 3 was quietly absorbing the * 0-vs-1-based column skew documented on {@link VE_COLUMN_BASE}. */ export declare const COLUMN_TOLERANCE = 5; /** * The JSX element whose opening tag sits nearest `targetLine:targetCol` * (1-based, {@link VE_COLUMN_BASE}), with its ancestor chain. * * Returning the FIRST node inside the tolerance instead — pre-order, so the * outermost — made every short wrapper swallow its own child: in * `
  • Link
  • ` the `
  • ` opens at column 7 and the `` at * column 11, so a click on the LINK resolved to the LIST ITEM. Nearest-wins, * with a strict `<` so the first (outermost) of a tie is deterministic. * * ⚠️ This walk used to exist TWICE on the pod (analyzer + transformer), with a * docblock warning they "MUST agree". Now it exists once. */ export declare function findJSXElementAtPosition(node: AnalysisNode, source: string, targetLine: number, targetCol: number): { element: AnalysisNode; parents: AnalysisNode[]; } | null; /** A literal of ANY kind, in either dialect — including ones that render nothing. */ export declare function isLiteralNode(node: AnalysisNode | null | undefined): boolean; /** A STRING literal, in either dialect. */ export declare function isStringLiteralNode(node: AnalysisNode | null | undefined): boolean; /** * The literal's runtime value where one exists (`string | number | boolean`), * else `undefined`. Babel's `NullLiteral` has no `.value` — it answers * `undefined` here, which every consumer treats as "renders nothing", the same * answer oxc's `Literal(null)` gets through its `null` value. */ export declare function literalValue(node: AnalysisNode): unknown; /** * The class-merging helpers whose FIRST string-literal argument the writer may * edit in place. ONE set — the cockpit's `CN_FUNCTION_NAMES` twin is deleted; * the three readers (inspector gate, cockpit engine, pod writer) now share it * by import. */ export declare const CLASSNAME_MERGE_FUNCTIONS: Set; export declare function isClassNameMergeCall(node: AnalysisNode | null | undefined): boolean; /** * The first string-literal argument of a class-merging call — the "base * classes" slot — or `null` when the call carries none (`cn(a, b)`). * * Direct arguments first, then one level of nesting (`cn(clsx("…"))`) — one * declaration of the order and the depth, where there used to be two. */ export declare function firstStringLiteralArg(call: AnalysisNode): AnalysisNode | null; /** * Where — if anywhere — a new className string may be written. * * `static` carries the node to overwrite as a whole JSX attribute value (`null` * for a valueless `className`); `merge-call` carries the ONE string literal * inside a `cn()`-family call; `dynamic` means no write is possible. */ export type ClassNameTarget = { kind: 'static'; node: AnalysisNode | null; } | { kind: 'merge-call'; node: AnalysisNode; } | { kind: 'dynamic'; }; /** * ☠️ **A className attribute is not always a string, and overwriting the whole * VALUE deletes the owner's conditional styling without telling anyone.** * * For the template's ordinary shape — * `className={cn("rounded-lg p-4", isActive && "ring-2 ring-brand")}` — the * value is the whole `{cn(…)}` container, so a blind overwrite rewrites it to a * bare string: the call gone, `isActive` gone, the ring-on-active state gone * from the source forever, and the RPC still answers `{ success: true }`. * * Hence three answers rather than two: * - `static` — a literal, `{"literal"}`, or an expression-free template * literal: overwrite the whole value. * - `merge-call` — a `cn()`-family call with a string-literal argument: rewrite * ONLY that argument, so the call, the conditional arms and the merge * semantics survive. * - `dynamic` — anything else: refuse before touching the file. * * This is the SELECTION GATE and the WRITE deciding with one function — the * inspector computes `editable` from the analyzer's use of it, the pod writer * routes its overwrite by it, and the cockpit's pre-flight (which paints the * sidebar) asks the same question of its Babel tree. */ export declare function resolveClassNameTarget(value: AnalysisNode | null | undefined): ClassNameTarget; /** * The literal text a `static` {@link ClassNameTarget} currently holds. * * Template literals join `raw` — matching what the tagger and writer see on * disk. (The cockpit used to prefer `cooked`; for real class strings the two * never differ, and `raw` is the spelling both writers splice around.) */ export declare function staticClassNameValue(node: AnalysisNode | null): string; /** The analyzer's className verdict — shared by the inspector gate and the RPC. */ export interface ClassNameAnalysis { type: 'static' | 'dynamic'; value?: string; } /** * The className verdict for an opening element, resolved through * {@link resolveClassNameTarget}. No `className` attribute at all — or a * valueless one — is an EMPTY static base, not an unreadable one. */ export declare function analyzeClassNameOnOpening(opening: AnalysisNode): ClassNameAnalysis; /** The analyzer's text verdict, with the LOAD-BEARING reason distinction. */ export interface TextContentAnalysis { type: 'static' | 'dynamic'; value?: string; /** * Why a `dynamic` answer is dynamic — not a debugging aid. `dynamic-content` * means the element renders an expression, so its text is DATA that exists * nowhere in the source (the inspector routes those clicks to the CMS admin); * `has-children` means the text is a literal that merely sits beside a child * tag, so it is CODE the writer can still edit in place. */ reason?: 'has-children' | 'dynamic-content'; } /** * Is this child's content DATA — an expression container holding something * other than a plain literal or a comment? * * `{plan.price}` is shared by every instance a `.map()` renders, so collapsing * it to a literal would fork it from its source. `{" "}` (Prettier's line-wrap * spacer) and `{"Precio"}` are literals — TEXT, not data. `{/* … *​/}` wraps a * `JSXEmptyExpression`, renders nothing and binds nothing — counting it as data * once refused the text panel on every element carrying a comment. */ export declare function isDataBoundChild(child: AnalysisNode): boolean; /** * The element's own text nodes — non-blank `JSXText` children AND the * literal-only expression containers, in BOTH spellings. * * ☠️ **Whitespace-only nodes are excluded in BOTH spellings, and that symmetry * is what keeps the commonest wrapped paragraph editable instead of refused.** * Prettier emits `{" "}` whenever JSX text wraps onto a line ahead of an inline * tag, so `

    ¿Ya tienes cuenta?{" "}Inicia sesión

    ` — the ordinary * shape of every sign-in prompt, price line and footnote in a generated app — * carries a spacer beside its one real sentence. Counting that spacer as a * second own-text node would trip the split-text refusal and turn the element * away. * * Booleans and `null` are literals that render NOTHING, so they are not text * and are left out of the count deliberately. */ export declare function ownTextNodes(children: AnalysisNode[]): AnalysisNode[]; /** * The text ONE child contributes as the element's own, or `null` when it * contributes none — the per-child spelling of {@link ownTextNodes}, with the * same blankness rule and the same "booleans and null render nothing" call. * The cockpit's `buildNodeInfo` filters and concatenates with this; keeping it * beside `ownTextNodes` is what keeps the two spellings one rule. */ export declare function ownTextValue(child: AnalysisNode): string | null; /** * The full text verdict for an element. * * ☠️ **`dynamic-content` and `has-children` are OPPOSITES downstream, so the * expression test must run FIRST** — with the nesting test first the reason * depends on which kind of child the element happens to have as well. `type` is * `dynamic` either way; what the ordering decides is what the `reason` MEANS, * and `shouldOpenCmsAdmin` keys on it: `

    {item.name}

    ` is * CMS-backed text that happens to sit next to a tag and must still say * `dynamic-content`, while `` is code. */ export declare function analyzeTextContent(element: AnalysisNode): TextContentAnalysis; export interface ElementTypeAnalysis { type: 'static' | 'dynamic'; reason?: 'conditional-expression' | 'complex-parent' | 'map-expression'; } /** * Is removing this element's whole span safe, or does the SLOT it fills carry * meaning that outlives it? * * `refuseUnparseableWrite` is NOT a substitute: it only catches a hole that * stops parsing, and the worst shape parses fine — a block-bodied `.map()` * callback left as `return ;` is valid JavaScript, so deleting ONE card * silently deletes the entire list and reports success. * * ☠️ A FRAGMENT BOUNDS THE WALK EXACTLY AS AN ELEMENT DOES. Both mean "an * enclosing JSX node already owns this subtree", so whatever expression sits * above them produces the FRAGMENT, not this element — deleting a child of * `{items.map((i) => (<>
    ))}` removes one tag from inside the * fragment, which is safe. Without the break the walk ran past the fragment to * the `.map`/`&&` above and refused the delete, telling the owner something * untrue about their own source. */ export declare function analyzeDeletionSafety(parents: AnalysisNode[]): ElementTypeAnalysis; /** * The JSX grammar defines `JSXTextCharacter` as SourceCharacter *except* `{`, * `<`, `>` and `}` — all four ordinary characters in a sentence, and this text * is whatever the owner typed on the page (`Precios < $100`, `Horario {lunes a * viernes}`, `2 > 1`). Measured: `<`, `>` and `}` each make the emitted file * unparseable, and `{…}` is worse — it parses, silently becoming an expression * container referencing an identifier that is usually not in scope, so the page * throws at render instead of failing at build. * * Entities rather than an expression container (`{"…"}`), which would also be * valid: the container shape would trip the data-bound refusal on the NEXT * edit. `&` is deliberately NOT escaped. * * ONE implementation. Both writers (the pod's MagicString splice and the * cockpit's Babel re-print) import it, so they compose the same file by * construction rather than by a character-for-character mirror. */ export declare function escapeJsxText(value: string): string; /** * A double-quoted JSX attribute value ENDS at the first `"`, and JSX does not * honour backslash escapes. Only the quote is escaped; `&` is left alone ON * PURPOSE — `[&>svg]:size-4` and friends are everywhere in the template's * classes, and Tailwind's candidate scanner reads the SOURCE text, so rewriting * `&` to `&` would keep the runtime value correct while silently dropping * the generated utility. */ export declare function escapeJsxAttributeValue(value: string): string; /** * The element's rendered name (`Button`, `Dialog.Trigger`). Was duplicated in * the tagger and (dead) in the analyzer; the tagger's hot path pays nothing for * importing it from here. */ export declare function getElementName(nameNode: AnalysisNode): string; /** * Trees the visual editor must never write — or offer. * * ☠️ **`src/components/admin/**` and `src/vendor/**` are SYNCED trees, not the * app's own source.** `scripts/sync-cms-admin-template.mjs` generates them from * `@proyecta/cms-admin-react` at seed time, and the builder already refuses its * own FE engineer every op over them (`appBackendWriteBoundary`) — but the * visual editor's middleware had no such line, so an owner who opened `/admin` * inside the visual-mode preview could click-edit the admin console's own * source. A splice there is silent drift from the generated lib: it survives on * the pod, is committed by `autoVersionHook`, and diverges the app's console * from every fix the lib ships after it. * * THREE consumers, one declaration, same shape as every boundary in this * module: the tagger (stops stamping `data-ve-*`, so nothing in these trees is * hoverable on newly seeded pods), `resolveComponentId` (refuses the RPCs, so * nothing in these trees is analyzable or writable regardless of what a * same-origin script posts), and the cockpit (drops hover/select for these * paths, which is the only line that reaches pods seeded BEFORE this constant * existed — their baked tagger and middleware keep the old behaviour forever). * * Matched on the tagger's own repo-relative, forward-slash path spelling. */ export declare const PROTECTED_SOURCE_TREES: readonly string[]; /** Is this repo-relative path inside a tree the visual editor must not touch? */ export declare function isProtectedSourcePath(relativePath: string | null | undefined): boolean; //# sourceMappingURL=analysis-core.d.ts.map