@pyreon/connector-document 0.13.0 → 0.14.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/cssValueParser.ts","../../src/extractDocumentTree.ts","../../src/resolveStyles.ts"],"mappings":";;;;;;AAiBA;;;;;AAyBC;iBAzBe,iBAAA,CACd,KAAA,sCACA,QAAA;AAAA,KAyBG,cAAA;;;AAWL;;;;;;;iBAAgB,aAAA,CACd,KAAA,+BACA,QAAA,YACC,cAAA;;AAyBH;;iBAAgB,eAAA,CACd,KAAA;;;AAaF;iBAAgB,eAAA,CACd,KAAA,+BACA,QAAA;;;;UC/Fe,cAAA;EACf,aAAA,EAAe,QAAA;AAAA;AAAA,UAGA,cAAA;EDUf;ECRA,QAAA;EDkCG;EChCH,aAAA;AAAA;;;AD2CF;;;;;;;;;AA4BA;iBC0KgB,mBAAA,CAAoB,KAAA,WAAgB,OAAA,GAAS,cAAA,GAAsB,OAAA;;;;;AD5OnF;;;;;iBEGgB,aAAA,CAAc,WAAA,EAAa,MAAA,mBAAyB,QAAA,YAAgB,cAAA"}
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/cssValueParser.ts","../../src/extractDocumentTree.ts","../../src/resolveStyles.ts"],"mappings":";;;;;;AAiBA;;;;;AAyBC;iBAzBe,iBAAA,CACd,KAAA,sCACA,QAAA;AAAA,KAyBG,cAAA;;;AAWL;;;;;;;iBAAgB,aAAA,CACd,KAAA,+BACA,QAAA,YACC,cAAA;;AAyBH;;iBAAgB,eAAA,CACd,KAAA;;;AAaF;iBAAgB,eAAA,CACd,KAAA,+BACA,QAAA;;;;UC/Fe,cAAA;EACf,aAAA,EAAe,QAAA;AAAA;AAAA,UAGA,cAAA;EDUf;ECRA,QAAA;EDkCG;EChCH,aAAA;AAAA;;;AD2CF;;;;;;;;;AA4BA;iBCmLgB,mBAAA,CAAoB,KAAA,WAAgB,OAAA,GAAS,cAAA,GAAsB,OAAA;;;;;ADrPnF;;;;;iBEGgB,aAAA,CAAc,WAAA,EAAa,MAAA,mBAAyB,QAAA,YAAgB,cAAA"}
package/lib/index.js CHANGED
@@ -191,13 +191,21 @@ function extractNode(vnode, options) {
191
191
  let extractedFromCall = null;
192
192
  if (props._documentProps && typeof props._documentProps === "object") rawDocProps = props._documentProps;
193
193
  else if (typeof type === "function") {
194
- const mergedProps = { ...props };
195
- if (children && children.length > 0) mergedProps.children = children.length === 1 ? children[0] : children;
196
- const result = type(mergedProps);
197
- if (isVNode(result)) {
198
- extractedFromCall = result;
199
- const innerProps = result.props;
200
- if (innerProps?._documentProps && typeof innerProps._documentProps === "object") rawDocProps = innerProps._documentProps;
194
+ const rsAttrs = type.__rs_attrs;
195
+ if (rsAttrs && rsAttrs.length > 0) {
196
+ const mergedProps = { ...props };
197
+ if (children && children.length > 0) mergedProps.children = children.length === 1 ? children[0] : children;
198
+ const attrsResult = rsAttrs.reduce((acc, fn) => Object.assign(acc, fn(mergedProps)), {});
199
+ if (attrsResult._documentProps && typeof attrsResult._documentProps === "object") rawDocProps = attrsResult._documentProps;
200
+ } else {
201
+ const mergedProps = { ...props };
202
+ if (children && children.length > 0) mergedProps.children = children.length === 1 ? children[0] : children;
203
+ const result = type(mergedProps);
204
+ if (isVNode(result)) {
205
+ extractedFromCall = result;
206
+ const innerProps = result.props;
207
+ if (innerProps?._documentProps && typeof innerProps._documentProps === "object") rawDocProps = innerProps._documentProps;
208
+ }
201
209
  }
202
210
  }
203
211
  const docProps = {};
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/cssValueParser.ts","../src/resolveStyles.ts","../src/extractDocumentTree.ts"],"sourcesContent":["const PX_RE = /^(-?\\d+(?:\\.\\d+)?)px$/\nconst REM_RE = /^(-?\\d+(?:\\.\\d+)?)rem$/\nconst EM_RE = /^(-?\\d+(?:\\.\\d+)?)em$/\nconst PT_RE = /^(-?\\d+(?:\\.\\d+)?)pt$/\nconst NUMBER_RE = /^-?\\d+(?:\\.\\d+)?$/\n\nconst DEFAULT_ROOT_SIZE = 16\n\n/**\n * Parse a CSS dimension value to a number.\n *\n * - `14` → `14`\n * - `'14px'` → `14`\n * - `'1.5rem'` → `24` (with rootSize=16)\n * - `'12pt'` → `16` (pt × 1.333)\n * - `'auto'` → `undefined`\n */\nexport function parseCssDimension(\n value: string | number | null | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (typeof value !== 'string') return undefined\n\n const trimmed = value.trim()\n\n const pxMatch = PX_RE.exec(trimmed)\n if (pxMatch?.[1]) return Number.parseFloat(pxMatch[1])\n\n const remMatch = REM_RE.exec(trimmed)\n if (remMatch?.[1]) return Number.parseFloat(remMatch[1]) * rootSize\n\n const emMatch = EM_RE.exec(trimmed)\n if (emMatch?.[1]) return Number.parseFloat(emMatch[1]) * rootSize\n\n const ptMatch = PT_RE.exec(trimmed)\n if (ptMatch?.[1]) return Number.parseFloat(ptMatch[1]) * (4 / 3)\n\n if (NUMBER_RE.test(trimmed)) return Number.parseFloat(trimmed)\n\n return undefined\n}\n\ntype BoxModelResult = number | [number, number] | [number, number, number, number] | undefined\n\n/**\n * Parse a CSS padding/margin shorthand to document tuple format.\n *\n * - `8` → `8`\n * - `'8px'` → `8`\n * - `'8px 16px'` → `[8, 16]`\n * - `'8px 16px 8px 16px'` → `[8, 16, 8, 16]`\n * - `'8px 16px 12px'` → `[8, 16, 12, 16]` (CSS 3-value shorthand)\n */\nexport function parseBoxModel(\n value: string | number | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): BoxModelResult {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n\n const parts = value\n .trim()\n .split(/\\s+/)\n .map((p) => parseCssDimension(p, rootSize))\n\n const nums = parts.filter((p): p is number => p != null)\n if (nums.length !== parts.length) return undefined\n\n if (nums.length === 1) return nums[0]\n if (nums.length === 2) return [nums[0], nums[1]] as [number, number]\n if (nums.length === 3)\n return [nums[0], nums[1], nums[2], nums[1]] as [number, number, number, number]\n if (nums.length === 4)\n return [nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number]\n\n return undefined\n}\n\n/**\n * Parse a CSS font-weight value.\n */\nexport function parseFontWeight(\n value: string | number | undefined,\n): 'normal' | 'bold' | number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (value === 'normal' || value === 'bold') return value\n const num = Number.parseInt(value, 10)\n if (!Number.isNaN(num)) return num\n return undefined\n}\n\n/**\n * Parse a CSS line-height value to a unitless number.\n */\nexport function parseLineHeight(\n value: string | number | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (value === 'normal') return undefined\n\n const dim = parseCssDimension(value, rootSize)\n if (dim != null) return dim\n\n return undefined\n}\n","import {\n parseBoxModel,\n parseCssDimension,\n parseFontWeight,\n parseLineHeight,\n} from './cssValueParser'\nimport type { ResolvedStyles } from './types'\n\nconst TEXT_ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify'])\nconst FONT_STYLE_VALUES = new Set(['normal', 'italic'])\nconst TEXT_DECORATION_VALUES = new Set(['none', 'underline', 'line-through'])\nconst BORDER_STYLE_VALUES = new Set(['solid', 'dashed', 'dotted'])\n\n/**\n * Convert a rocketstyle `$rocketstyle` theme object into a `ResolvedStyles`\n * object compatible with `@pyreon/document`.\n *\n * Only extracts properties that `ResolvedStyles` supports — everything else\n * (transitions, cursor, display, etc.) is silently ignored.\n */\nexport function resolveStyles(rocketstyle: Record<string, unknown>, rootSize = 16): ResolvedStyles {\n const styles: ResolvedStyles = {}\n\n // Typography\n const fontSize = parseCssDimension(rocketstyle.fontSize as string | number, rootSize)\n if (fontSize != null) styles.fontSize = fontSize\n\n if (typeof rocketstyle.fontFamily === 'string') styles.fontFamily = rocketstyle.fontFamily\n\n const fontWeight = parseFontWeight(rocketstyle.fontWeight as string | number | undefined)\n if (fontWeight != null) styles.fontWeight = fontWeight\n\n if (typeof rocketstyle.fontStyle === 'string' && FONT_STYLE_VALUES.has(rocketstyle.fontStyle))\n styles.fontStyle = rocketstyle.fontStyle as 'normal' | 'italic'\n\n if (\n typeof rocketstyle.textDecoration === 'string' &&\n TEXT_DECORATION_VALUES.has(rocketstyle.textDecoration)\n )\n styles.textDecoration = rocketstyle.textDecoration as 'none' | 'underline' | 'line-through'\n\n if (typeof rocketstyle.color === 'string') styles.color = rocketstyle.color\n\n if (typeof rocketstyle.backgroundColor === 'string')\n styles.backgroundColor = rocketstyle.backgroundColor\n\n if (typeof rocketstyle.textAlign === 'string' && TEXT_ALIGN_VALUES.has(rocketstyle.textAlign))\n styles.textAlign = rocketstyle.textAlign as 'left' | 'center' | 'right' | 'justify'\n\n const lineHeight = parseLineHeight(\n rocketstyle.lineHeight as string | number | undefined,\n rootSize,\n )\n if (lineHeight != null) styles.lineHeight = lineHeight\n\n const letterSpacing = parseCssDimension(rocketstyle.letterSpacing as string | number, rootSize)\n if (letterSpacing != null) styles.letterSpacing = letterSpacing\n\n // Box model\n const padding = parseBoxModel(rocketstyle.padding as string | number | undefined, rootSize)\n if (padding != null) styles.padding = padding\n\n const margin = parseBoxModel(rocketstyle.margin as string | number | undefined, rootSize)\n if (margin != null) styles.margin = margin\n\n // Border\n const borderRadius = parseCssDimension(rocketstyle.borderRadius as string | number, rootSize)\n if (borderRadius != null) styles.borderRadius = borderRadius\n\n const borderWidth = parseCssDimension(rocketstyle.borderWidth as string | number, rootSize)\n if (borderWidth != null) styles.borderWidth = borderWidth\n\n if (typeof rocketstyle.borderColor === 'string') styles.borderColor = rocketstyle.borderColor\n\n if (\n typeof rocketstyle.borderStyle === 'string' &&\n BORDER_STYLE_VALUES.has(rocketstyle.borderStyle)\n )\n styles.borderStyle = rocketstyle.borderStyle as 'solid' | 'dashed' | 'dotted'\n\n // Sizing\n if (rocketstyle.width != null) {\n const w = parseCssDimension(rocketstyle.width as string | number, rootSize)\n styles.width = w ?? (rocketstyle.width as string)\n }\n\n if (rocketstyle.height != null) {\n const h = parseCssDimension(rocketstyle.height as string | number, rootSize)\n styles.height = h ?? (rocketstyle.height as string)\n }\n\n if (rocketstyle.maxWidth != null) {\n const mw = parseCssDimension(rocketstyle.maxWidth as string | number, rootSize)\n styles.maxWidth = mw ?? (rocketstyle.maxWidth as string)\n }\n\n // Opacity\n if (typeof rocketstyle.opacity === 'number') styles.opacity = rocketstyle.opacity\n\n return styles\n}\n","import { resolveStyles } from './resolveStyles'\nimport type { DocChild, DocNode, NodeType } from './types'\n\n/** Marker interface: components with _documentType are extractable. */\nexport interface DocumentMarker {\n _documentType: NodeType\n}\n\nexport interface ExtractOptions {\n /** Root font size for rem→px conversion. Default: 16. */\n rootSize?: number\n /** Include resolved styles from $rocketstyle. Default: true. */\n includeStyles?: boolean\n}\n\ntype VNodeLike = {\n type: string | ((...args: any[]) => any)\n props: Record<string, any>\n children: unknown[]\n}\n\nfunction isVNode(value: unknown): value is VNodeLike {\n return value != null && typeof value === 'object' && 'type' in value && 'props' in value\n}\n\nfunction getDocumentType(fn: unknown): NodeType | undefined {\n if (typeof fn !== 'function') return undefined\n const meta = (fn as any).meta\n if (meta?._documentType) return meta._documentType as NodeType\n // Fallback: check directly on function (non-rocketstyle components)\n if ('_documentType' in fn) return (fn as any)._documentType as NodeType\n return undefined\n}\n\nfunction flattenChildren(children: unknown[]): unknown[] {\n const result: unknown[] = []\n for (const child of children) {\n if (Array.isArray(child)) {\n result.push(...flattenChildren(child))\n } else if (typeof child === 'function') {\n // Reactive getter — call to resolve\n const resolved = child()\n if (Array.isArray(resolved)) {\n result.push(...flattenChildren(resolved))\n } else {\n result.push(resolved)\n }\n } else {\n result.push(child)\n }\n }\n return result\n}\n\nfunction extractChildren(children: unknown[], options: ExtractOptions): DocChild[] {\n const flat = flattenChildren(children)\n const result: DocChild[] = []\n\n for (const child of flat) {\n if (child == null || child === false || child === true) continue\n\n if (typeof child === 'string') {\n result.push(child)\n continue\n }\n\n if (typeof child === 'number') {\n result.push(String(child))\n continue\n }\n\n if (isVNode(child)) {\n const extracted = extractNode(child, options)\n if (Array.isArray(extracted)) {\n result.push(...extracted)\n } else if (extracted != null) {\n result.push(extracted)\n }\n }\n }\n\n return result\n}\n\nfunction extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocChild[] | null {\n const { type, props, children } = vnode\n const includeStyles = options.includeStyles !== false\n const rootSize = options.rootSize ?? 16\n\n // Component function with _documentType marker (via .statics() or direct)\n const docType = getDocumentType(type)\n if (docType) {\n // ── _documentProps resolution ────────────────────────────────────\n //\n // Two paths to find _documentProps on a documentType vnode:\n //\n // (A) **Pre-resolved on the JSX vnode itself** — used by\n // test fixtures that hand-construct vnodes without\n // going through rocketstyle. Less common in real usage.\n //\n // (B) **Post-attrs result of calling the component** — the\n // real-world path. When a real `DocDocument` (or any\n // rocketstyle primitive with `.statics({ _documentType })`)\n // is rendered via JSX, the JSX vnode's `props` are the\n // USER-PROVIDED props (e.g. `{ title, author }`) — NOT\n // `_documentProps`. The rocketstyle attrs HOC adds\n // `_documentProps` to the wrapped component's vnode by\n // running the `.attrs()` callback during invocation. To\n // see the post-attrs result, we must CALL the component\n // function and read from THAT vnode's props.\n //\n // We try path (A) first because mock-vnode tests rely on it\n // and we don't want to invoke component functions when we\n // don't have to. If path (A) yields no _documentProps, we\n // fall back to path (B) and call the component.\n //\n // **Function values in _documentProps are resolved at this\n // point** — primitives like DocDocument can store accessor\n // thunks (`() => string`) for reactive metadata, and the\n // export pipeline reads the LIVE value on each extraction.\n // See PR #197 for the original use case (resume builder).\n //\n // ── Architectural note ──────────────────────────────────────────\n //\n // Path B is a workaround. The architecturally cleaner fix is to\n // have rocketstyle's `.statics()` mechanism hoist `_documentProps`\n // (or its accessor functions) directly onto the component\n // function — so `extractNode` could read it via\n // `(type as { _documentProps?: ... })._documentProps` without\n // ever invoking the component.\n //\n // That would require teaching rocketstyle that `.statics()`\n // values can be derived from `.attrs()` callbacks. It's a\n // bigger change in `@pyreon/rocketstyle/src/utils/statics.ts`\n // and was deemed out of scope for PR #197. The current\n // workaround works because:\n //\n // 1. rocketstyle's attrs HOC is meant to be PURE setup —\n // no observable side effects on the second call.\n // 2. The idempotence test in\n // `document-primitives/src/__tests__/useDocumentExport.test.ts`\n // locks in the purity assumption: extracting twice produces\n // structurally equivalent doc nodes.\n // 3. Path A is tried first, so existing fast-path tests don't\n // pay the component-invocation cost.\n //\n // If a future primitive accidentally introduces a side effect\n // in its setup body, the idempotence test catches it. If\n // performance becomes a concern (extractDocumentTree is called\n // per export, not per render — so this is unlikely), the\n // architectural fix in rocketstyle becomes worth doing.\n\n let rawDocProps: Record<string, unknown> | undefined\n let extractedFromCall: VNodeLike | null = null\n\n // Path A: pre-resolved on the JSX vnode (test fixtures)\n if (props._documentProps && typeof props._documentProps === 'object') {\n rawDocProps = props._documentProps as Record<string, unknown>\n } else if (typeof type === 'function') {\n // Path B: invoke the component to get the post-attrs vnode\n const mergedProps = { ...props }\n if (children && children.length > 0) {\n mergedProps.children = children.length === 1 ? children[0] : children\n }\n const result = (type as (p: Record<string, unknown>) => unknown)(mergedProps)\n if (isVNode(result)) {\n extractedFromCall = result\n const innerProps = (result as { props?: Record<string, unknown> }).props\n if (innerProps?._documentProps && typeof innerProps._documentProps === 'object') {\n rawDocProps = innerProps._documentProps as Record<string, unknown>\n }\n }\n }\n\n // Resolve function values (accessors) at extraction time\n const docProps: Record<string, unknown> = {}\n if (rawDocProps) {\n for (const [key, value] of Object.entries(rawDocProps)) {\n docProps[key] = typeof value === 'function' ? (value as () => unknown)() : value\n }\n }\n\n // Resolve styles from $rocketstyle. Look on the JSX vnode props\n // first; if the call result has its own $rocketstyle (because the\n // post-attrs vnode carries it down), use that as a fallback.\n const stylesSource =\n props.$rocketstyle ??\n (extractedFromCall as { props?: Record<string, unknown> } | null)?.props?.$rocketstyle\n const styles =\n includeStyles && stylesSource\n ? resolveStyles(stylesSource as Record<string, unknown>, rootSize)\n : undefined\n\n // Children: prefer the JSX vnode's children (the user-supplied\n // tree). The post-attrs call might wrap children in additional\n // styled elements that aren't part of the document tree.\n const docChildren = extractChildren(children ?? [], options)\n\n const node: DocNode = {\n type: docType,\n props: docProps,\n children: docChildren,\n }\n\n if (styles && Object.keys(styles).length > 0) {\n node.styles = styles\n }\n\n return node\n }\n\n // Component function WITHOUT _documentType — call it to get its VNode output\n if (typeof type === 'function') {\n const mergedProps = { ...props }\n if (children && children.length > 0) {\n mergedProps.children = children.length === 1 ? children[0] : children\n }\n\n const result = type(mergedProps)\n\n if (isVNode(result)) {\n return extractNode(result, options)\n }\n\n // The component returned a primitive or null\n if (typeof result === 'string') return [result]\n if (typeof result === 'number') return [String(result)]\n return null\n }\n\n // DOM element (string type like 'div', 'span') — transparent, extract children\n if (typeof type === 'string') {\n const docChildren = extractChildren(children ?? [], options)\n // If there's text content in the DOM element, collect it\n if (docChildren.length > 0) return docChildren\n return null\n }\n\n return null\n}\n\n/**\n * Walk a Pyreon VNode tree and extract a `DocNode` tree for `@pyreon/document`.\n *\n * For each VNode whose component has a `_documentType` marker:\n * 1. Read `_documentType` → `DocNode.type`\n * 2. Read `_documentProps` → `DocNode.props`\n * 3. Read `$rocketstyle` → `resolveStyles()` → `DocNode.styles`\n * 4. Recurse into children\n *\n * VNodes without `_documentType` are transparent — their children\n * are flattened into the parent's children list.\n */\nexport function extractDocumentTree(vnode: unknown, options: ExtractOptions = {}): DocNode {\n if (isVNode(vnode)) {\n const result = extractNode(vnode, options)\n if (result && !Array.isArray(result)) return result\n\n // Wrap loose children in a document node\n const children = Array.isArray(result) ? result : []\n return { type: 'document', props: {}, children }\n }\n\n // If passed a component function directly, call it\n if (typeof vnode === 'function') {\n const result = (vnode as () => unknown)()\n return extractDocumentTree(result, options)\n }\n\n return { type: 'document', props: {}, children: [] }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/cssValueParser.ts","../src/resolveStyles.ts","../src/extractDocumentTree.ts"],"sourcesContent":["const PX_RE = /^(-?\\d+(?:\\.\\d+)?)px$/\nconst REM_RE = /^(-?\\d+(?:\\.\\d+)?)rem$/\nconst EM_RE = /^(-?\\d+(?:\\.\\d+)?)em$/\nconst PT_RE = /^(-?\\d+(?:\\.\\d+)?)pt$/\nconst NUMBER_RE = /^-?\\d+(?:\\.\\d+)?$/\n\nconst DEFAULT_ROOT_SIZE = 16\n\n/**\n * Parse a CSS dimension value to a number.\n *\n * - `14` → `14`\n * - `'14px'` → `14`\n * - `'1.5rem'` → `24` (with rootSize=16)\n * - `'12pt'` → `16` (pt × 1.333)\n * - `'auto'` → `undefined`\n */\nexport function parseCssDimension(\n value: string | number | null | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (typeof value !== 'string') return undefined\n\n const trimmed = value.trim()\n\n const pxMatch = PX_RE.exec(trimmed)\n if (pxMatch?.[1]) return Number.parseFloat(pxMatch[1])\n\n const remMatch = REM_RE.exec(trimmed)\n if (remMatch?.[1]) return Number.parseFloat(remMatch[1]) * rootSize\n\n const emMatch = EM_RE.exec(trimmed)\n if (emMatch?.[1]) return Number.parseFloat(emMatch[1]) * rootSize\n\n const ptMatch = PT_RE.exec(trimmed)\n if (ptMatch?.[1]) return Number.parseFloat(ptMatch[1]) * (4 / 3)\n\n if (NUMBER_RE.test(trimmed)) return Number.parseFloat(trimmed)\n\n return undefined\n}\n\ntype BoxModelResult = number | [number, number] | [number, number, number, number] | undefined\n\n/**\n * Parse a CSS padding/margin shorthand to document tuple format.\n *\n * - `8` → `8`\n * - `'8px'` → `8`\n * - `'8px 16px'` → `[8, 16]`\n * - `'8px 16px 8px 16px'` → `[8, 16, 8, 16]`\n * - `'8px 16px 12px'` → `[8, 16, 12, 16]` (CSS 3-value shorthand)\n */\nexport function parseBoxModel(\n value: string | number | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): BoxModelResult {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n\n const parts = value\n .trim()\n .split(/\\s+/)\n .map((p) => parseCssDimension(p, rootSize))\n\n const nums = parts.filter((p): p is number => p != null)\n if (nums.length !== parts.length) return undefined\n\n if (nums.length === 1) return nums[0]\n if (nums.length === 2) return [nums[0], nums[1]] as [number, number]\n if (nums.length === 3)\n return [nums[0], nums[1], nums[2], nums[1]] as [number, number, number, number]\n if (nums.length === 4)\n return [nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number]\n\n return undefined\n}\n\n/**\n * Parse a CSS font-weight value.\n */\nexport function parseFontWeight(\n value: string | number | undefined,\n): 'normal' | 'bold' | number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (value === 'normal' || value === 'bold') return value\n const num = Number.parseInt(value, 10)\n if (!Number.isNaN(num)) return num\n return undefined\n}\n\n/**\n * Parse a CSS line-height value to a unitless number.\n */\nexport function parseLineHeight(\n value: string | number | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (value === 'normal') return undefined\n\n const dim = parseCssDimension(value, rootSize)\n if (dim != null) return dim\n\n return undefined\n}\n","import {\n parseBoxModel,\n parseCssDimension,\n parseFontWeight,\n parseLineHeight,\n} from './cssValueParser'\nimport type { ResolvedStyles } from './types'\n\nconst TEXT_ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify'])\nconst FONT_STYLE_VALUES = new Set(['normal', 'italic'])\nconst TEXT_DECORATION_VALUES = new Set(['none', 'underline', 'line-through'])\nconst BORDER_STYLE_VALUES = new Set(['solid', 'dashed', 'dotted'])\n\n/**\n * Convert a rocketstyle `$rocketstyle` theme object into a `ResolvedStyles`\n * object compatible with `@pyreon/document`.\n *\n * Only extracts properties that `ResolvedStyles` supports — everything else\n * (transitions, cursor, display, etc.) is silently ignored.\n */\nexport function resolveStyles(rocketstyle: Record<string, unknown>, rootSize = 16): ResolvedStyles {\n const styles: ResolvedStyles = {}\n\n // Typography\n const fontSize = parseCssDimension(rocketstyle.fontSize as string | number, rootSize)\n if (fontSize != null) styles.fontSize = fontSize\n\n if (typeof rocketstyle.fontFamily === 'string') styles.fontFamily = rocketstyle.fontFamily\n\n const fontWeight = parseFontWeight(rocketstyle.fontWeight as string | number | undefined)\n if (fontWeight != null) styles.fontWeight = fontWeight\n\n if (typeof rocketstyle.fontStyle === 'string' && FONT_STYLE_VALUES.has(rocketstyle.fontStyle))\n styles.fontStyle = rocketstyle.fontStyle as 'normal' | 'italic'\n\n if (\n typeof rocketstyle.textDecoration === 'string' &&\n TEXT_DECORATION_VALUES.has(rocketstyle.textDecoration)\n )\n styles.textDecoration = rocketstyle.textDecoration as 'none' | 'underline' | 'line-through'\n\n if (typeof rocketstyle.color === 'string') styles.color = rocketstyle.color\n\n if (typeof rocketstyle.backgroundColor === 'string')\n styles.backgroundColor = rocketstyle.backgroundColor\n\n if (typeof rocketstyle.textAlign === 'string' && TEXT_ALIGN_VALUES.has(rocketstyle.textAlign))\n styles.textAlign = rocketstyle.textAlign as 'left' | 'center' | 'right' | 'justify'\n\n const lineHeight = parseLineHeight(\n rocketstyle.lineHeight as string | number | undefined,\n rootSize,\n )\n if (lineHeight != null) styles.lineHeight = lineHeight\n\n const letterSpacing = parseCssDimension(rocketstyle.letterSpacing as string | number, rootSize)\n if (letterSpacing != null) styles.letterSpacing = letterSpacing\n\n // Box model\n const padding = parseBoxModel(rocketstyle.padding as string | number | undefined, rootSize)\n if (padding != null) styles.padding = padding\n\n const margin = parseBoxModel(rocketstyle.margin as string | number | undefined, rootSize)\n if (margin != null) styles.margin = margin\n\n // Border\n const borderRadius = parseCssDimension(rocketstyle.borderRadius as string | number, rootSize)\n if (borderRadius != null) styles.borderRadius = borderRadius\n\n const borderWidth = parseCssDimension(rocketstyle.borderWidth as string | number, rootSize)\n if (borderWidth != null) styles.borderWidth = borderWidth\n\n if (typeof rocketstyle.borderColor === 'string') styles.borderColor = rocketstyle.borderColor\n\n if (\n typeof rocketstyle.borderStyle === 'string' &&\n BORDER_STYLE_VALUES.has(rocketstyle.borderStyle)\n )\n styles.borderStyle = rocketstyle.borderStyle as 'solid' | 'dashed' | 'dotted'\n\n // Sizing\n if (rocketstyle.width != null) {\n const w = parseCssDimension(rocketstyle.width as string | number, rootSize)\n styles.width = w ?? (rocketstyle.width as string)\n }\n\n if (rocketstyle.height != null) {\n const h = parseCssDimension(rocketstyle.height as string | number, rootSize)\n styles.height = h ?? (rocketstyle.height as string)\n }\n\n if (rocketstyle.maxWidth != null) {\n const mw = parseCssDimension(rocketstyle.maxWidth as string | number, rootSize)\n styles.maxWidth = mw ?? (rocketstyle.maxWidth as string)\n }\n\n // Opacity\n if (typeof rocketstyle.opacity === 'number') styles.opacity = rocketstyle.opacity\n\n return styles\n}\n","import { resolveStyles } from './resolveStyles'\nimport type { DocChild, DocNode, NodeType } from './types'\n\n/** Marker interface: components with _documentType are extractable. */\nexport interface DocumentMarker {\n _documentType: NodeType\n}\n\nexport interface ExtractOptions {\n /** Root font size for rem→px conversion. Default: 16. */\n rootSize?: number\n /** Include resolved styles from $rocketstyle. Default: true. */\n includeStyles?: boolean\n}\n\ntype VNodeLike = {\n type: string | ((...args: any[]) => any)\n props: Record<string, any>\n children: unknown[]\n}\n\nfunction isVNode(value: unknown): value is VNodeLike {\n return value != null && typeof value === 'object' && 'type' in value && 'props' in value\n}\n\nfunction getDocumentType(fn: unknown): NodeType | undefined {\n if (typeof fn !== 'function') return undefined\n const meta = (fn as any).meta\n if (meta?._documentType) return meta._documentType as NodeType\n // Fallback: check directly on function (non-rocketstyle components)\n if ('_documentType' in fn) return (fn as any)._documentType as NodeType\n return undefined\n}\n\nfunction flattenChildren(children: unknown[]): unknown[] {\n const result: unknown[] = []\n for (const child of children) {\n if (Array.isArray(child)) {\n result.push(...flattenChildren(child))\n } else if (typeof child === 'function') {\n // Reactive getter — call to resolve\n const resolved = child()\n if (Array.isArray(resolved)) {\n result.push(...flattenChildren(resolved))\n } else {\n result.push(resolved)\n }\n } else {\n result.push(child)\n }\n }\n return result\n}\n\nfunction extractChildren(children: unknown[], options: ExtractOptions): DocChild[] {\n const flat = flattenChildren(children)\n const result: DocChild[] = []\n\n for (const child of flat) {\n if (child == null || child === false || child === true) continue\n\n if (typeof child === 'string') {\n result.push(child)\n continue\n }\n\n if (typeof child === 'number') {\n result.push(String(child))\n continue\n }\n\n if (isVNode(child)) {\n const extracted = extractNode(child, options)\n if (Array.isArray(extracted)) {\n result.push(...extracted)\n } else if (extracted != null) {\n result.push(extracted)\n }\n }\n }\n\n return result\n}\n\nfunction extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocChild[] | null {\n const { type, props, children } = vnode\n const includeStyles = options.includeStyles !== false\n const rootSize = options.rootSize ?? 16\n\n // Component function with _documentType marker (via .statics() or direct)\n const docType = getDocumentType(type)\n if (docType) {\n // ── _documentProps resolution ────────────────────────────────────\n //\n // Three paths to find `_documentProps` on a documentType vnode,\n // tried in order:\n //\n // (A) **Pre-resolved on the JSX vnode itself** — used by test\n // fixtures that hand-construct vnodes with `_documentProps`\n // baked in. Cheapest path; tried first.\n //\n // (C) **Hoisted-attrs fast path (T3.1, PR #321)** — when the\n // component is a real rocketstyle primitive, it exposes\n // `__rs_attrs` (the accumulated `.attrs()` callback chain)\n // as a typed static. We run the chain DIRECTLY with the\n // JSX vnode's props — `chain.reduce(Object.assign, {})` —\n // and read `_documentProps` from the result. No styled\n // wrapper invocation, no JSX tree creation, no dimension\n // resolution. This is the production path for every real\n // Pyreon doc-primitive (DocDocument, DocHeading, etc.).\n //\n // (B) **Full component invocation (legacy fallback)** — only\n // fires when neither A nor C applies. Used by hand-rolled\n // test fixtures that mark a function with `_documentType`\n // but don't go through rocketstyle (so `__rs_attrs` is\n // absent). Calls the component with the JSX props and\n // reads `_documentProps` from the post-call vnode.\n //\n // Why three paths instead of one: (A) is for test fixtures that\n // hardcode `_documentProps` directly on the JSX vnode — a pattern\n // that pre-dates the attrs HOC. (C) is the real-world path. (B)\n // is what (C) replaced — kept so non-rocketstyle fixtures still\n // work. See PR #197 for the original metadata-drop bug and\n // PR #321 (T3.1) for the architectural fast path.\n //\n // **Function values in _documentProps are resolved at this\n // point** — primitives like DocDocument can store accessor\n // thunks (`() => string`) for reactive metadata, and the\n // export pipeline reads the LIVE value on each extraction.\n\n let rawDocProps: Record<string, unknown> | undefined\n let extractedFromCall: VNodeLike | null = null\n\n // Path A: pre-resolved on the JSX vnode (test fixtures)\n if (props._documentProps && typeof props._documentProps === 'object') {\n rawDocProps = props._documentProps as Record<string, unknown>\n } else if (typeof type === 'function') {\n // ── Path C (T3.1 fast path) ─────────────────────────────────────\n //\n // Rocketstyle exposes the accumulated `.attrs()` callback chain\n // as `__rs_attrs` on the component function. Run the chain\n // directly with the JSX vnode's props to get the post-attrs\n // result — no full component invocation, no styling work, no\n // wrapped JSX tree creation. Just the user-supplied attrs\n // callback(s) folded into a single props object.\n //\n // This eliminates the per-export cost of Path B for every real\n // rocketstyle primitive (DocDocument, DocHeading, etc.). The\n // idempotence assumption is now structural rather than implicit:\n // we never call the component, so it cannot have side effects\n // that affect the second extraction.\n const rsAttrs = (type as { __rs_attrs?: Array<(p: Record<string, unknown>) => Record<string, unknown>> }).__rs_attrs\n if (rsAttrs && rsAttrs.length > 0) {\n const mergedProps = { ...props }\n if (children && children.length > 0) {\n mergedProps.children = children.length === 1 ? children[0] : children\n }\n const attrsResult = rsAttrs.reduce<Record<string, unknown>>(\n (acc, fn) => Object.assign(acc, fn(mergedProps)),\n {},\n )\n if (attrsResult._documentProps && typeof attrsResult._documentProps === 'object') {\n rawDocProps = attrsResult._documentProps as Record<string, unknown>\n }\n } else {\n // Path B (fallback for non-rocketstyle docComponents):\n // invoke the component to get the post-attrs vnode. Used by\n // hand-rolled test fixtures that don't go through rocketstyle.\n const mergedProps = { ...props }\n if (children && children.length > 0) {\n mergedProps.children = children.length === 1 ? children[0] : children\n }\n const result = (type as (p: Record<string, unknown>) => unknown)(mergedProps)\n if (isVNode(result)) {\n extractedFromCall = result\n const innerProps = (result as { props?: Record<string, unknown> }).props\n if (innerProps?._documentProps && typeof innerProps._documentProps === 'object') {\n rawDocProps = innerProps._documentProps as Record<string, unknown>\n }\n }\n }\n }\n\n // Resolve function values (accessors) at extraction time\n const docProps: Record<string, unknown> = {}\n if (rawDocProps) {\n for (const [key, value] of Object.entries(rawDocProps)) {\n docProps[key] = typeof value === 'function' ? (value as () => unknown)() : value\n }\n }\n\n // Resolve styles from $rocketstyle. Look on the JSX vnode props\n // first; if the call result has its own $rocketstyle (because the\n // post-attrs vnode carries it down), use that as a fallback.\n const stylesSource =\n props.$rocketstyle ??\n (extractedFromCall as { props?: Record<string, unknown> } | null)?.props?.$rocketstyle\n const styles =\n includeStyles && stylesSource\n ? resolveStyles(stylesSource as Record<string, unknown>, rootSize)\n : undefined\n\n // Children: prefer the JSX vnode's children (the user-supplied\n // tree). The post-attrs call might wrap children in additional\n // styled elements that aren't part of the document tree.\n const docChildren = extractChildren(children ?? [], options)\n\n const node: DocNode = {\n type: docType,\n props: docProps,\n children: docChildren,\n }\n\n if (styles && Object.keys(styles).length > 0) {\n node.styles = styles\n }\n\n return node\n }\n\n // Component function WITHOUT _documentType — call it to get its VNode output\n if (typeof type === 'function') {\n const mergedProps = { ...props }\n if (children && children.length > 0) {\n mergedProps.children = children.length === 1 ? children[0] : children\n }\n\n const result = type(mergedProps)\n\n if (isVNode(result)) {\n return extractNode(result, options)\n }\n\n // The component returned a primitive or null\n if (typeof result === 'string') return [result]\n if (typeof result === 'number') return [String(result)]\n return null\n }\n\n // DOM element (string type like 'div', 'span') — transparent, extract children\n if (typeof type === 'string') {\n const docChildren = extractChildren(children ?? [], options)\n // If there's text content in the DOM element, collect it\n if (docChildren.length > 0) return docChildren\n return null\n }\n\n return null\n}\n\n/**\n * Walk a Pyreon VNode tree and extract a `DocNode` tree for `@pyreon/document`.\n *\n * For each VNode whose component has a `_documentType` marker:\n * 1. Read `_documentType` → `DocNode.type`\n * 2. Read `_documentProps` → `DocNode.props`\n * 3. Read `$rocketstyle` → `resolveStyles()` → `DocNode.styles`\n * 4. Recurse into children\n *\n * VNodes without `_documentType` are transparent — their children\n * are flattened into the parent's children list.\n */\nexport function extractDocumentTree(vnode: unknown, options: ExtractOptions = {}): DocNode {\n if (isVNode(vnode)) {\n const result = extractNode(vnode, options)\n if (result && !Array.isArray(result)) return result\n\n // Wrap loose children in a document node\n const children = Array.isArray(result) ? result : []\n return { type: 'document', props: {}, children }\n }\n\n // If passed a component function directly, call it\n if (typeof vnode === 'function') {\n const result = (vnode as () => unknown)()\n return extractDocumentTree(result, options)\n }\n\n return { type: 'document', props: {}, children: [] }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/connector-document",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Bridge between @pyreon/pyreon styled components and @pyreon/document rendering",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,17 +41,17 @@
41
41
  "typecheck": "tsc --noEmit"
42
42
  },
43
43
  "devDependencies": {
44
- "@pyreon/core": "^0.13.0",
45
- "@pyreon/document": "^0.13.0",
46
- "@pyreon/reactivity": "^0.13.0",
47
- "@pyreon/test-utils": "^0.13.0",
48
- "@pyreon/typescript": "^0.13.0",
44
+ "@pyreon/core": "^0.14.0",
45
+ "@pyreon/document": "^0.14.0",
46
+ "@pyreon/reactivity": "^0.14.0",
47
+ "@pyreon/test-utils": "^0.13.2",
48
+ "@pyreon/typescript": "^0.14.0",
49
49
  "@vitest/browser-playwright": "^4.1.4",
50
50
  "@vitus-labs/tools-rolldown": "^1.15.4"
51
51
  },
52
52
  "peerDependencies": {
53
- "@pyreon/core": "^0.13.0",
54
- "@pyreon/document": "^0.13.0"
53
+ "@pyreon/core": "^0.14.0",
54
+ "@pyreon/document": "^0.14.0"
55
55
  },
56
56
  "engines": {
57
57
  "node": ">= 22"
@@ -1,17 +1,22 @@
1
+ import { h } from '@pyreon/core'
1
2
  import { describe, expect, it } from 'vitest'
2
3
  import type { DocumentMarker } from '../extractDocumentTree'
3
4
  import { extractDocumentTree } from '../extractDocumentTree'
4
5
 
5
- // Helper: create a mock VNode
6
- const vnode = (
6
+ // Helper: build a real VNode via @pyreon/core's h(). The third arg
7
+ // is an array (kept for parity with the prior mock helper) and
8
+ // spreads into h()'s varargs. All tests below run real-h() VNodes
9
+ // through the extraction pipeline — see PR #197 for why mock-only
10
+ // tests masked a silent metadata drop in the real attrs HOC path.
11
+ const node = (
7
12
  type: string | ((...args: any[]) => any),
8
13
  props: Record<string, any> = {},
9
14
  children: unknown[] = [],
10
- ) => ({ type, props, children })
15
+ ) => h(type as any, props, ...(children as any[])) as any
11
16
 
12
17
  // Helper: create a document-marked component function
13
18
  const docComponent = (docType: string, render?: (...args: any[]) => any) => {
14
- const fn = render ?? ((props: any) => vnode('div', props, props.children ? [props.children] : []))
19
+ const fn = render ?? ((props: any) => node('div', props, props.children ? [props.children] : []))
15
20
  ;(fn as any)._documentType = docType
16
21
  return fn as ((...args: any[]) => any) & DocumentMarker
17
22
  }
@@ -19,7 +24,7 @@ const docComponent = (docType: string, render?: (...args: any[]) => any) => {
19
24
  describe('extractDocumentTree', () => {
20
25
  it('extracts a simple document node', () => {
21
26
  const Heading = docComponent('heading')
22
- const tree = vnode(
27
+ const tree = node(
23
28
  Heading,
24
29
  { $rocketstyle: { fontSize: 24, fontWeight: 'bold' }, _documentProps: { level: 1 } },
25
30
  ['Hello World'],
@@ -37,9 +42,9 @@ describe('extractDocumentTree', () => {
37
42
  const Section = docComponent('section')
38
43
  const Text = docComponent('text')
39
44
 
40
- const tree = vnode(Section, { $rocketstyle: { padding: 16 } }, [
41
- vnode(Text, { $rocketstyle: { fontSize: 14, color: '#333' } }, ['Paragraph one']),
42
- vnode(Text, { $rocketstyle: { fontSize: 14, color: '#333' } }, ['Paragraph two']),
45
+ const tree = node(Section, { $rocketstyle: { padding: 16 } }, [
46
+ node(Text, { $rocketstyle: { fontSize: 14, color: '#333' } }, ['Paragraph one']),
47
+ node(Text, { $rocketstyle: { fontSize: 14, color: '#333' } }, ['Paragraph two']),
43
48
  ])
44
49
 
45
50
  const result = extractDocumentTree(tree)
@@ -56,8 +61,8 @@ describe('extractDocumentTree', () => {
56
61
  const Text = docComponent('text')
57
62
 
58
63
  // A plain div wrapper (no _documentType) should be transparent
59
- const tree = vnode(Section, {}, [
60
- vnode('div', {}, [vnode(Text, { $rocketstyle: { fontSize: 14 } }, ['Hello'])]),
64
+ const tree = node(Section, {}, [
65
+ node('div', {}, [node(Text, { $rocketstyle: { fontSize: 14 } }, ['Hello'])]),
61
66
  ])
62
67
 
63
68
  const result = extractDocumentTree(tree)
@@ -69,7 +74,7 @@ describe('extractDocumentTree', () => {
69
74
 
70
75
  it('handles string children', () => {
71
76
  const Text = docComponent('text')
72
- const tree = vnode(Text, {}, ['Hello', ' ', 'World'])
77
+ const tree = node(Text, {}, ['Hello', ' ', 'World'])
73
78
 
74
79
  const result = extractDocumentTree(tree)
75
80
 
@@ -78,7 +83,7 @@ describe('extractDocumentTree', () => {
78
83
 
79
84
  it('handles number children', () => {
80
85
  const Text = docComponent('text')
81
- const tree = vnode(Text, {}, [42])
86
+ const tree = node(Text, {}, [42])
82
87
 
83
88
  const result = extractDocumentTree(tree)
84
89
 
@@ -87,7 +92,7 @@ describe('extractDocumentTree', () => {
87
92
 
88
93
  it('skips null and boolean children', () => {
89
94
  const Section = docComponent('section')
90
- const tree = vnode(Section, {}, [null, false, true, 'visible'])
95
+ const tree = node(Section, {}, [null, false, true, 'visible'])
91
96
 
92
97
  const result = extractDocumentTree(tree)
93
98
 
@@ -96,7 +101,7 @@ describe('extractDocumentTree', () => {
96
101
 
97
102
  it('resolves reactive getter children', () => {
98
103
  const Text = docComponent('text')
99
- const tree = vnode(Text, {}, [() => 'dynamic text'])
104
+ const tree = node(Text, {}, [() => 'dynamic text'])
100
105
 
101
106
  const result = extractDocumentTree(tree)
102
107
 
@@ -105,7 +110,7 @@ describe('extractDocumentTree', () => {
105
110
 
106
111
  it('omits styles when includeStyles is false', () => {
107
112
  const Heading = docComponent('heading')
108
- const tree = vnode(Heading, { $rocketstyle: { fontSize: 24 } }, ['Hello'])
113
+ const tree = node(Heading, { $rocketstyle: { fontSize: 24 } }, ['Hello'])
109
114
 
110
115
  const result = extractDocumentTree(tree, { includeStyles: false })
111
116
 
@@ -113,7 +118,7 @@ describe('extractDocumentTree', () => {
113
118
  })
114
119
 
115
120
  it('wraps in document node when root has no _documentType', () => {
116
- const tree = vnode('div', {}, ['raw text'])
121
+ const tree = node('div', {}, ['raw text'])
117
122
 
118
123
  const result = extractDocumentTree(tree)
119
124
 
@@ -124,9 +129,9 @@ describe('extractDocumentTree', () => {
124
129
  it('handles component functions without _documentType by calling them', () => {
125
130
  const Text = docComponent('text')
126
131
  const Wrapper = (props: any) =>
127
- vnode(Text, { $rocketstyle: { fontSize: 14 } }, [props.children])
132
+ node(Text, { $rocketstyle: { fontSize: 14 } }, [props.children])
128
133
 
129
- const tree = vnode(Wrapper, {}, ['wrapped text'])
134
+ const tree = node(Wrapper, {}, ['wrapped text'])
130
135
 
131
136
  const result = extractDocumentTree(tree)
132
137
 
@@ -136,7 +141,7 @@ describe('extractDocumentTree', () => {
136
141
 
137
142
  it('handles function passed directly', () => {
138
143
  const Text = docComponent('text')
139
- const template = () => vnode(Text, { $rocketstyle: { fontSize: 14 } }, ['Hello'])
144
+ const template = () => node(Text, { $rocketstyle: { fontSize: 14 } }, ['Hello'])
140
145
 
141
146
  const result = extractDocumentTree(template)
142
147
 
@@ -165,7 +170,7 @@ describe('extractDocumentTree', () => {
165
170
 
166
171
  it('calls function values in _documentProps and stores the result', () => {
167
172
  const Document = docComponent('document')
168
- const tree = vnode(
173
+ const tree = node(
169
174
  Document,
170
175
  {
171
176
  _documentProps: {
@@ -193,7 +198,7 @@ describe('extractDocumentTree', () => {
193
198
  // two extractDocumentTree calls on the same vnode.
194
199
  let counter = 0
195
200
  const Document = docComponent('document')
196
- const tree = vnode(
201
+ const tree = node(
197
202
  Document,
198
203
  {
199
204
  _documentProps: {
@@ -214,7 +219,7 @@ describe('extractDocumentTree', () => {
214
219
 
215
220
  it('mixes function and plain values in the same _documentProps object', () => {
216
221
  const Image = docComponent('image')
217
- const tree = vnode(
222
+ const tree = node(
218
223
  Image,
219
224
  {
220
225
  _documentProps: {
@@ -242,7 +247,7 @@ describe('extractDocumentTree', () => {
242
247
  // change must not break them. This test mirrors the shape
243
248
  // of DocHeading's existing _documentProps.
244
249
  const Heading = docComponent('heading')
245
- const tree = vnode(
250
+ const tree = node(
246
251
  Heading,
247
252
  { _documentProps: { level: 1 } },
248
253
  ['Hello'],
@@ -284,7 +289,7 @@ describe('extractDocumentTree', () => {
284
289
  // takes user props, returns a vnode with _documentProps
285
290
  // populated by the "attrs callback".
286
291
  const DocDocLike = ((userProps: { title?: string; author?: string }) =>
287
- vnode('div', {
292
+ node('div', {
288
293
  _documentProps: {
289
294
  ...(userProps.title ? { title: userProps.title } : {}),
290
295
  ...(userProps.author ? { author: userProps.author } : {}),
@@ -293,7 +298,7 @@ describe('extractDocumentTree', () => {
293
298
  ;(DocDocLike as any)._documentType = 'document'
294
299
 
295
300
  // The JSX vnode has user props but NO _documentProps directly
296
- const jsxVnode = vnode(DocDocLike, { title: 'My Doc', author: 'Alice' }, [])
301
+ const jsxVnode = node(DocDocLike, { title: 'My Doc', author: 'Alice' }, [])
297
302
 
298
303
  const result = extractDocumentTree(jsxVnode)
299
304
 
@@ -308,14 +313,14 @@ describe('extractDocumentTree', () => {
308
313
  // callback) gets the live value resolved at extraction time.
309
314
  let liveTitle = 'First'
310
315
  const DocDocLike = ((userProps: { title?: () => string }) =>
311
- vnode('div', {
316
+ node('div', {
312
317
  _documentProps: {
313
318
  title: userProps.title, // store the accessor as-is
314
319
  },
315
320
  })) as ((...args: any[]) => any) & DocumentMarker
316
321
  ;(DocDocLike as any)._documentType = 'document'
317
322
 
318
- const jsxVnode = vnode(DocDocLike, { title: () => liveTitle }, [])
323
+ const jsxVnode = node(DocDocLike, { title: () => liveTitle }, [])
319
324
 
320
325
  const first = extractDocumentTree(jsxVnode)
321
326
  expect(first.props.title).toBe('First')
@@ -334,11 +339,11 @@ describe('extractDocumentTree', () => {
334
339
  let componentCalled = false
335
340
  const DocDocLike = (() => {
336
341
  componentCalled = true
337
- return vnode('div', { _documentProps: { title: 'from-call' } })
342
+ return node('div', { _documentProps: { title: 'from-call' } })
338
343
  }) as ((...args: any[]) => any) & DocumentMarker
339
344
  ;(DocDocLike as any)._documentType = 'document'
340
345
 
341
- const jsxVnode = vnode(
346
+ const jsxVnode = node(
342
347
  DocDocLike,
343
348
  { _documentProps: { title: 'from-jsx' } },
344
349
  [],
@@ -350,3 +355,194 @@ describe('extractDocumentTree', () => {
350
355
  })
351
356
  })
352
357
  })
358
+
359
+ // ─── Real h() round-trip (parallel to the mock-vnode tests above) ────────
360
+ //
361
+ // This is the exact file PR #197 fixed — `extractDocumentTree` was
362
+ // silently dropping metadata from real rocketstyle primitives because
363
+ // the existing tests ONLY used the local `node(...)` helper that
364
+ // hardcodes `{ type, props, children }` literals. The mock path
365
+ // worked; the real pipeline (where `_documentProps` is only attached
366
+ // AFTER the attrs HOC runs) didn't. The `audit_test_environment` tool
367
+ // from PR #311 flagged this file HIGH (27 mock-helper call-sites, 0
368
+ // real `h()` calls, no `@pyreon/core` import).
369
+ //
370
+ // This block adds a parallel: the same tree shapes built via real
371
+ // `h(...)` from `@pyreon/core`. The mock `node()` helper returns a
372
+ // hand-built object literal; real `h()` returns whatever the current
373
+ // Pyreon VNode shape is — if the two ever drift, only the real-`h()`
374
+ // path catches it. The mock tests above stay as the fast unit-test
375
+ // path; these are the safety net.
376
+
377
+ describe('extractDocumentTree — real h() round-trip', () => {
378
+ it('extracts a simple document node built via real h()', () => {
379
+ const Heading = docComponent('heading')
380
+ const tree = h(
381
+ Heading,
382
+ { $rocketstyle: { fontSize: 24, fontWeight: 'bold' }, _documentProps: { level: 1 } },
383
+ 'Hello World',
384
+ )
385
+
386
+ const result = extractDocumentTree(tree)
387
+ expect(result.type).toBe('heading')
388
+ expect(result.props).toEqual({ level: 1 })
389
+ expect(result.children).toEqual(['Hello World'])
390
+ expect(result.styles).toEqual({ fontSize: 24, fontWeight: 'bold' })
391
+ })
392
+
393
+ it('extracts nested document nodes through real h() trees', () => {
394
+ const Section = docComponent('section')
395
+ const Text = docComponent('text')
396
+
397
+ const tree = h(
398
+ Section,
399
+ { $rocketstyle: { padding: 16 } },
400
+ h(Text, { $rocketstyle: { fontSize: 14 } }, 'Paragraph one'),
401
+ h(Text, { $rocketstyle: { fontSize: 14 } }, 'Paragraph two'),
402
+ )
403
+
404
+ const result = extractDocumentTree(tree)
405
+ expect(result.type).toBe('section')
406
+ expect(result.styles).toEqual({ padding: 16 })
407
+ expect(result.children).toHaveLength(2)
408
+ const child0 = result.children[0] as { type: string; children: unknown[] }
409
+ const child1 = result.children[1] as { type: string; children: unknown[] }
410
+ expect(child0.type).toBe('text')
411
+ expect(child0.children).toEqual(['Paragraph one'])
412
+ expect(child1.type).toBe('text')
413
+ expect(child1.children).toEqual(['Paragraph two'])
414
+ })
415
+
416
+ it('transparent wrappers (no _documentType) are flattened by the extractor', () => {
417
+ const Section = docComponent('section')
418
+ const Text = docComponent('text')
419
+ // A plain `div` wrapper nested inside a section — no document
420
+ // marker on the div — should be invisible to the extractor.
421
+ // Consumers sprinkle layout containers without breaking the
422
+ // extraction pipeline.
423
+ const tree = h(
424
+ Section,
425
+ {},
426
+ h('div', {}, h(Text, { $rocketstyle: { fontSize: 14 } }, 'Hello')),
427
+ )
428
+
429
+ const result = extractDocumentTree(tree)
430
+ expect(result.type).toBe('section')
431
+ expect(result.children).toHaveLength(1)
432
+ const child0 = result.children[0] as { type: string; children: unknown[] }
433
+ expect(child0.type).toBe('text')
434
+ expect(child0.children).toEqual(['Hello'])
435
+ })
436
+
437
+ it('component is INVOKED during extraction (the PR #197 fix)', () => {
438
+ // The fix in PR #197: when a docComponent has attrs-HOC-style
439
+ // post-processing, extractDocumentTree must call the component to
440
+ // see its post-attrs VNode. With real `h()` the contract is the
441
+ // same — the component function on `vnode.type` must be invoked
442
+ // so that attrs-populated `_documentProps` surface correctly.
443
+ let callCount = 0
444
+ const Enriched = docComponent('heading', (props: any) => {
445
+ callCount++
446
+ // Mimic an attrs HOC that stamps post-attrs metadata onto a
447
+ // child VNode instead of the outer wrapper (the exact shape
448
+ // PR #197 discovered).
449
+ return h('div', { ...props, _documentProps: { level: 2 } }, props.children)
450
+ })
451
+
452
+ const tree = h(Enriched, {}, 'After attrs')
453
+ const result = extractDocumentTree(tree)
454
+ expect(callCount).toBeGreaterThan(0)
455
+ expect(result.type).toBe('heading')
456
+ expect(result.props.level).toBe(2)
457
+ })
458
+ })
459
+
460
+ // ─── T3.1 hoisted-attrs fast path (Path C) ────────────────────────────
461
+ //
462
+ // Real rocketstyle primitives now expose `__rs_attrs` — the accumulated
463
+ // `.attrs()` callback chain — on the component function itself.
464
+ // `extractDocumentTree` runs that chain DIRECTLY instead of invoking
465
+ // the full component, so `_documentProps` resolution doesn't pay for
466
+ // the styled wrapper / dimension resolution / JSX tree creation.
467
+ //
468
+ // The test below mimics rocketstyle's exposed surface (no real
469
+ // `@pyreon/rocketstyle` import needed — the contract is just "if
470
+ // `__rs_attrs` is present on the component function, use it"). A
471
+ // counter-spied "render" function asserts the body is NEVER called when
472
+ // the fast path is taken.
473
+
474
+ describe('extractDocumentTree — T3.1 hoisted-attrs fast path', () => {
475
+ it('uses __rs_attrs without invoking the component function (Path C)', () => {
476
+ let callCount = 0
477
+ // Mimic a rocketstyle primitive: function with _documentType static
478
+ // AND __rs_attrs static (the hoisted attrs chain). The body is what
479
+ // would be the styled wrapper; we count its invocations.
480
+ const FakeRocketDoc = ((props: any) => {
481
+ callCount++
482
+ return h('div', props, props.children)
483
+ }) as ((p: any) => any) & DocumentMarker
484
+ ;(FakeRocketDoc as any)._documentType = 'document'
485
+ ;(FakeRocketDoc as any).__rs_attrs = [
486
+ (props: { title?: string; author?: string }) => ({
487
+ _documentProps: {
488
+ ...(props.title ? { title: props.title } : {}),
489
+ ...(props.author ? { author: props.author } : {}),
490
+ },
491
+ }),
492
+ ]
493
+
494
+ const tree = h(FakeRocketDoc, { title: 'My Doc', author: 'Alice' })
495
+ const result = extractDocumentTree(tree)
496
+
497
+ expect(result.type).toBe('document')
498
+ expect(result.props.title).toBe('My Doc')
499
+ expect(result.props.author).toBe('Alice')
500
+ // The architectural assertion — the component body must NOT run.
501
+ expect(callCount).toBe(0)
502
+ })
503
+
504
+ it('also resolves accessor function values from __rs_attrs (composes with D1 fix)', () => {
505
+ let liveTitle = 'First'
506
+ let callCount = 0
507
+ const FakeRocketDoc = ((props: any) => {
508
+ callCount++
509
+ return h('div', props, props.children)
510
+ }) as ((p: any) => any) & DocumentMarker
511
+ ;(FakeRocketDoc as any)._documentType = 'document'
512
+ ;(FakeRocketDoc as any).__rs_attrs = [
513
+ (props: { title?: () => string }) => ({
514
+ _documentProps: { title: props.title },
515
+ }),
516
+ ]
517
+
518
+ const jsx = h(FakeRocketDoc, { title: () => liveTitle })
519
+ const first = extractDocumentTree(jsx)
520
+ expect(first.props.title).toBe('First')
521
+
522
+ liveTitle = 'Second'
523
+ const second = extractDocumentTree(jsx)
524
+ expect(second.props.title).toBe('Second')
525
+
526
+ // Two extractions, zero component invocations.
527
+ expect(callCount).toBe(0)
528
+ })
529
+
530
+ it('falls back to Path B (full component invocation) when __rs_attrs is absent', () => {
531
+ // Non-rocketstyle docComponents (test fixtures, hand-rolled HOCs)
532
+ // don't have __rs_attrs and must still work via the legacy Path B.
533
+ let callCount = 0
534
+ const PlainDoc = ((props: any) => {
535
+ callCount++
536
+ return h('div', { ...props, _documentProps: { level: 3 } }, props.children)
537
+ }) as ((p: any) => any) & DocumentMarker
538
+ ;(PlainDoc as any)._documentType = 'heading'
539
+ // Note: NO __rs_attrs here — Path B should fire
540
+
541
+ const tree = h(PlainDoc, {}, 'Body')
542
+ const result = extractDocumentTree(tree)
543
+
544
+ expect(result.type).toBe('heading')
545
+ expect(result.props.level).toBe(3)
546
+ expect(callCount).toBeGreaterThan(0)
547
+ })
548
+ })
@@ -92,63 +92,41 @@ function extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocCh
92
92
  if (docType) {
93
93
  // ── _documentProps resolution ────────────────────────────────────
94
94
  //
95
- // Two paths to find _documentProps on a documentType vnode:
95
+ // Three paths to find `_documentProps` on a documentType vnode,
96
+ // tried in order:
96
97
  //
97
- // (A) **Pre-resolved on the JSX vnode itself** — used by
98
- // test fixtures that hand-construct vnodes without
99
- // going through rocketstyle. Less common in real usage.
98
+ // (A) **Pre-resolved on the JSX vnode itself** — used by test
99
+ // fixtures that hand-construct vnodes with `_documentProps`
100
+ // baked in. Cheapest path; tried first.
100
101
  //
101
- // (B) **Post-attrs result of calling the component** — the
102
- // real-world path. When a real `DocDocument` (or any
103
- // rocketstyle primitive with `.statics({ _documentType })`)
104
- // is rendered via JSX, the JSX vnode's `props` are the
105
- // USER-PROVIDED props (e.g. `{ title, author }`) NOT
106
- // `_documentProps`. The rocketstyle attrs HOC adds
107
- // `_documentProps` to the wrapped component's vnode by
108
- // running the `.attrs()` callback during invocation. To
109
- // see the post-attrs result, we must CALL the component
110
- // function and read from THAT vnode's props.
102
+ // (C) **Hoisted-attrs fast path (T3.1, PR #321)** — when the
103
+ // component is a real rocketstyle primitive, it exposes
104
+ // `__rs_attrs` (the accumulated `.attrs()` callback chain)
105
+ // as a typed static. We run the chain DIRECTLY with the
106
+ // JSX vnode's props — `chain.reduce(Object.assign, {})` —
107
+ // and read `_documentProps` from the result. No styled
108
+ // wrapper invocation, no JSX tree creation, no dimension
109
+ // resolution. This is the production path for every real
110
+ // Pyreon doc-primitive (DocDocument, DocHeading, etc.).
111
111
  //
112
- // We try path (A) first because mock-vnode tests rely on it
113
- // and we don't want to invoke component functions when we
114
- // don't have to. If path (A) yields no _documentProps, we
115
- // fall back to path (B) and call the component.
112
+ // (B) **Full component invocation (legacy fallback)** only
113
+ // fires when neither A nor C applies. Used by hand-rolled
114
+ // test fixtures that mark a function with `_documentType`
115
+ // but don't go through rocketstyle (so `__rs_attrs` is
116
+ // absent). Calls the component with the JSX props and
117
+ // reads `_documentProps` from the post-call vnode.
118
+ //
119
+ // Why three paths instead of one: (A) is for test fixtures that
120
+ // hardcode `_documentProps` directly on the JSX vnode — a pattern
121
+ // that pre-dates the attrs HOC. (C) is the real-world path. (B)
122
+ // is what (C) replaced — kept so non-rocketstyle fixtures still
123
+ // work. See PR #197 for the original metadata-drop bug and
124
+ // PR #321 (T3.1) for the architectural fast path.
116
125
  //
117
126
  // **Function values in _documentProps are resolved at this
118
127
  // point** — primitives like DocDocument can store accessor
119
128
  // thunks (`() => string`) for reactive metadata, and the
120
129
  // export pipeline reads the LIVE value on each extraction.
121
- // See PR #197 for the original use case (resume builder).
122
- //
123
- // ── Architectural note ──────────────────────────────────────────
124
- //
125
- // Path B is a workaround. The architecturally cleaner fix is to
126
- // have rocketstyle's `.statics()` mechanism hoist `_documentProps`
127
- // (or its accessor functions) directly onto the component
128
- // function — so `extractNode` could read it via
129
- // `(type as { _documentProps?: ... })._documentProps` without
130
- // ever invoking the component.
131
- //
132
- // That would require teaching rocketstyle that `.statics()`
133
- // values can be derived from `.attrs()` callbacks. It's a
134
- // bigger change in `@pyreon/rocketstyle/src/utils/statics.ts`
135
- // and was deemed out of scope for PR #197. The current
136
- // workaround works because:
137
- //
138
- // 1. rocketstyle's attrs HOC is meant to be PURE setup —
139
- // no observable side effects on the second call.
140
- // 2. The idempotence test in
141
- // `document-primitives/src/__tests__/useDocumentExport.test.ts`
142
- // locks in the purity assumption: extracting twice produces
143
- // structurally equivalent doc nodes.
144
- // 3. Path A is tried first, so existing fast-path tests don't
145
- // pay the component-invocation cost.
146
- //
147
- // If a future primitive accidentally introduces a side effect
148
- // in its setup body, the idempotence test catches it. If
149
- // performance becomes a concern (extractDocumentTree is called
150
- // per export, not per render — so this is unlikely), the
151
- // architectural fix in rocketstyle becomes worth doing.
152
130
 
153
131
  let rawDocProps: Record<string, unknown> | undefined
154
132
  let extractedFromCall: VNodeLike | null = null
@@ -157,17 +135,48 @@ function extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocCh
157
135
  if (props._documentProps && typeof props._documentProps === 'object') {
158
136
  rawDocProps = props._documentProps as Record<string, unknown>
159
137
  } else if (typeof type === 'function') {
160
- // Path B: invoke the component to get the post-attrs vnode
161
- const mergedProps = { ...props }
162
- if (children && children.length > 0) {
163
- mergedProps.children = children.length === 1 ? children[0] : children
164
- }
165
- const result = (type as (p: Record<string, unknown>) => unknown)(mergedProps)
166
- if (isVNode(result)) {
167
- extractedFromCall = result
168
- const innerProps = (result as { props?: Record<string, unknown> }).props
169
- if (innerProps?._documentProps && typeof innerProps._documentProps === 'object') {
170
- rawDocProps = innerProps._documentProps as Record<string, unknown>
138
+ // ── Path C (T3.1 fast path) ─────────────────────────────────────
139
+ //
140
+ // Rocketstyle exposes the accumulated `.attrs()` callback chain
141
+ // as `__rs_attrs` on the component function. Run the chain
142
+ // directly with the JSX vnode's props to get the post-attrs
143
+ // result no full component invocation, no styling work, no
144
+ // wrapped JSX tree creation. Just the user-supplied attrs
145
+ // callback(s) folded into a single props object.
146
+ //
147
+ // This eliminates the per-export cost of Path B for every real
148
+ // rocketstyle primitive (DocDocument, DocHeading, etc.). The
149
+ // idempotence assumption is now structural rather than implicit:
150
+ // we never call the component, so it cannot have side effects
151
+ // that affect the second extraction.
152
+ const rsAttrs = (type as { __rs_attrs?: Array<(p: Record<string, unknown>) => Record<string, unknown>> }).__rs_attrs
153
+ if (rsAttrs && rsAttrs.length > 0) {
154
+ const mergedProps = { ...props }
155
+ if (children && children.length > 0) {
156
+ mergedProps.children = children.length === 1 ? children[0] : children
157
+ }
158
+ const attrsResult = rsAttrs.reduce<Record<string, unknown>>(
159
+ (acc, fn) => Object.assign(acc, fn(mergedProps)),
160
+ {},
161
+ )
162
+ if (attrsResult._documentProps && typeof attrsResult._documentProps === 'object') {
163
+ rawDocProps = attrsResult._documentProps as Record<string, unknown>
164
+ }
165
+ } else {
166
+ // Path B (fallback for non-rocketstyle docComponents):
167
+ // invoke the component to get the post-attrs vnode. Used by
168
+ // hand-rolled test fixtures that don't go through rocketstyle.
169
+ const mergedProps = { ...props }
170
+ if (children && children.length > 0) {
171
+ mergedProps.children = children.length === 1 ? children[0] : children
172
+ }
173
+ const result = (type as (p: Record<string, unknown>) => unknown)(mergedProps)
174
+ if (isVNode(result)) {
175
+ extractedFromCall = result
176
+ const innerProps = (result as { props?: Record<string, unknown> }).props
177
+ if (innerProps?._documentProps && typeof innerProps._documentProps === 'object') {
178
+ rawDocProps = innerProps._documentProps as Record<string, unknown>
179
+ }
171
180
  }
172
181
  }
173
182
  }