@uniweb/press 0.4.7 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/press",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Press your React components into downloadable files — Word, Excel, PDF. Registration-based pattern with lazy-loaded format adapters.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -46,8 +46,8 @@
46
46
  "parse5": "^7.2.0"
47
47
  },
48
48
  "peerDependencies": {
49
- "react": "^18.0.0 || ^19.0.0",
50
- "react-dom": "^18.0.0 || ^19.0.0"
49
+ "react": "^19.0.0",
50
+ "react-dom": "^19.0.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@testing-library/react": "^16.0.0",
@@ -510,6 +510,9 @@ function irToInlineChildren(node) {
510
510
  // a TextRun so the OOXML parent is <w:r>.
511
511
  return [new TextRun({ children: [new DocxTab()] })]
512
512
  case 'externalHyperlink':
513
+ // A plain <a href> from same-source JSX — same destination, same
514
+ // emitter. See the href note in ir/parser.js.
515
+ case 'a':
513
516
  return [irToExternalHyperlink(node)]
514
517
  case 'internalHyperlink':
515
518
  return [irToInternalHyperlink(node)]
@@ -612,10 +615,13 @@ function irToTextRunPair(node) {
612
615
  }
613
616
 
614
617
  function irToExternalHyperlink(node) {
618
+ // `link` is the data-* spelling (data-link); `href` arrives from a plain
619
+ // <a href>. Same destination either way.
620
+ const destination = node.link || node.href || ''
615
621
  const children = (node.children || []).flatMap(irToInlineChildren)
616
622
  return new ExternalHyperlink({
617
- children: children.length ? children : [new TextRun({ text: node.link || '' })],
618
- link: node.link || '',
623
+ children: children.length ? children : [new TextRun({ text: destination })],
624
+ link: destination,
619
625
  })
620
626
  }
621
627
 
@@ -47,6 +47,7 @@
47
47
  */
48
48
 
49
49
  import JSZip from 'jszip'
50
+ import { bundleInlineImages } from '../assets/inline-images.js'
50
51
  import { RAW_BEGIN, RAW_END, markRawLatex } from '../latex/raw.js'
51
52
 
52
53
  // Re-export so consumers reaching the adapter via the dynamic-import
@@ -84,9 +85,18 @@ export { markRawLatex }
84
85
  * @returns {Promise<Blob>}
85
86
  */
86
87
  export async function compileLatex(input, options = {}) {
87
- const { mode = 'sources', ...rest } = options
88
-
89
- const bundle = buildBundle(input, rest)
88
+ const { mode = 'sources', loadAsset, ...rest } = options
89
+
90
+ // Fetch inline body images and rewrite their IR src to a bundle-local
91
+ // assets/<hash>.<ext> path, then merge the bytes into the bundle so
92
+ // latexmk/pdflatex resolves \includegraphics{...} against the extracted
93
+ // sources. Cover art arrives pre-bundled in rest.assets via the
94
+ // foundation's getOptions; this handles document-body images.
95
+ const inlineImageAssets = await bundleInlineImages(input, loadAsset)
96
+ const bundle = buildBundle(input, {
97
+ ...rest,
98
+ assets: { ...(rest.assets || {}), ...inlineImageAssets },
99
+ })
90
100
 
91
101
  if (mode === 'sources') {
92
102
  return zipBundle(bundle)
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import JSZip from 'jszip'
31
+ import { bundleInlineImages } from '../assets/inline-images.js'
31
32
 
32
33
  // ============================================================================
33
34
  // Public API
@@ -53,9 +54,18 @@ import JSZip from 'jszip'
53
54
  * @returns {Promise<Blob>}
54
55
  */
55
56
  export async function compileTypst(input, options = {}) {
56
- const { mode = 'sources', ...rest } = options
57
-
58
- const bundle = buildBundle(input, rest)
57
+ const { mode = 'sources', loadAsset, ...rest } = options
58
+
59
+ // Fetch inline body images (`![](foo.png)`) and rewrite their IR src to a
60
+ // bundle-local `assets/<hash>.<ext>` path, then merge the bytes into the
61
+ // bundle so `typst compile` resolves them. Cover images arrive pre-bundled
62
+ // in rest.assets via the foundation's getOptions; this handles the images
63
+ // an author drops into the document body.
64
+ const inlineImageAssets = await bundleInlineImages(input, loadAsset)
65
+ const bundle = buildBundle(input, {
66
+ ...rest,
67
+ assets: { ...(rest.assets || {}), ...inlineImageAssets },
68
+ })
59
69
 
60
70
  if (mode === 'sources') {
61
71
  return zipBundle(bundle)
@@ -364,6 +374,7 @@ function blockNodeToTypst(node) {
364
374
  return inlineNodeToTypst(node)
365
375
 
366
376
  case 'link':
377
+ case 'a':
367
378
  return inlineNodeToTypst(node)
368
379
 
369
380
  case 'math':
@@ -516,7 +527,7 @@ function inlineNodeToTypst(node) {
516
527
  return text
517
528
  }
518
529
 
519
- if (node.type === 'link') {
530
+ if (node.type === 'link' || node.type === 'a') {
520
531
  const href = node.href || ''
521
532
  const inner = inlineChildrenToTypst(node.children || [])
522
533
  return `#link(${quoteTypstString(href)})[${inner}]`
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Inline image bundling for source-bundle adapters (typst, latex).
3
+ *
4
+ * A source-bundle adapter emits an image reference (`#image(src)`,
5
+ * `\includegraphics{src}`) into a text file and ships a directory of assets
6
+ * alongside it. The IR carries only the author's `src` string, so the bytes
7
+ * have to be fetched and written into the bundle — and the src rewritten to the
8
+ * bundle-local path — before `typst` / `latex` can resolve them. Cover images
9
+ * arrive pre-bundled via the foundation's getOptions; this handles the images
10
+ * an author drops into the document body.
11
+ *
12
+ * Extracted from the typst adapter when latex became the second bundle adapter
13
+ * needing it — principle 6 in docs/architecture/principles.md, the same reason
14
+ * src/assets/fetch.js exists. EPUB does its own image embedding (it needs OPF
15
+ * manifest entries and a different on-disk layout), so it doesn't share this.
16
+ */
17
+
18
+ import { fetchAssets } from './fetch.js'
19
+
20
+ /**
21
+ * Fetch every inline image referenced in the IR and rewrite its node `src` to a
22
+ * bundle-local `assets/<hash>.<ext>` path, returning the map of bundle files to
23
+ * merge into the source bundle.
24
+ *
25
+ * The format walker emits the (rewritten) src verbatim, so the compiler
26
+ * resolves it against the extracted bundle directory. Byte-loading goes through
27
+ * the shared fetchAssets helper: the host-supplied loadAsset (node:fs in
28
+ * unipress, fetch in the browser) turns a resolved src into bytes. Images that
29
+ * fail to load are left untouched so one missing asset doesn't abort the whole
30
+ * document.
31
+ *
32
+ * @param {Object} input - compileOutputs output ({ sections, header, footer }).
33
+ * @param {(src: string) => Promise<Uint8Array|null>} [loadAsset]
34
+ * @returns {Promise<Record<string, Uint8Array>>} bundlePath → bytes
35
+ */
36
+ export async function bundleInlineImages(input, loadAsset) {
37
+ const imageNodes = collectImageNodes(input)
38
+ if (!imageNodes.length) return {}
39
+
40
+ const srcs = [...new Set(imageNodes.map((n) => n.src).filter(Boolean))]
41
+ if (!srcs.length) return {}
42
+
43
+ const fetched = await fetchAssets(srcs, { loadAsset })
44
+
45
+ const assets = {}
46
+ const rewrite = new Map()
47
+ for (const [src, result] of fetched) {
48
+ if (!result || result.error || !result.bytes) continue
49
+ const bundlePath = `assets/${result.hash}.${result.ext}`
50
+ assets[bundlePath] = result.bytes
51
+ rewrite.set(src, bundlePath)
52
+ }
53
+
54
+ // Rewrite the IR in place so the walker emits the bundle path. The IR is
55
+ // freshly built by compileOutputs for this compile, so mutation is safe and
56
+ // avoids threading a rewrite map through the whole walker.
57
+ for (const node of imageNodes) {
58
+ const bundlePath = rewrite.get(node.src)
59
+ if (bundlePath) node.src = bundlePath
60
+ }
61
+
62
+ return assets
63
+ }
64
+
65
+ /**
66
+ * Collect every image IR node reachable from the compiled input, descending
67
+ * through `children` (paragraphs, lists, tables, figures, …) across sections,
68
+ * header, and footer.
69
+ */
70
+ export function collectImageNodes(input) {
71
+ const found = []
72
+ const visit = (nodes) => {
73
+ if (!Array.isArray(nodes)) return
74
+ for (const node of nodes) {
75
+ if (!node || typeof node !== 'object') continue
76
+ if (node.type === 'image') found.push(node)
77
+ if (Array.isArray(node.children)) visit(node.children)
78
+ }
79
+ }
80
+ for (const section of input?.sections || []) visit(section)
81
+ visit(input?.header || [])
82
+ visit(input?.footer || [])
83
+ return found
84
+ }
@@ -276,6 +276,11 @@ export async function compileDocument(treeOrWebsite, options = {}) {
276
276
  : {}
277
277
 
278
278
  const mergedAdapterOptions = {
279
+ // Thread the host loadAsset through to the adapter so it can embed
280
+ // inline body images (the typst/latex bundlers fetch bytes here, the
281
+ // same capability the docx adapter uses). A foundation's getOptions or
282
+ // the caller can still override it via adapterOptions.
283
+ loadAsset,
279
284
  ...foundationAdapterOptions.adapterOptions,
280
285
  ...overrideAdapterOptions,
281
286
  }
package/src/ir/parser.js CHANGED
@@ -103,6 +103,22 @@ function nodeToIR(node) {
103
103
  const properties = attributesToProperties(node.attrs || [])
104
104
  const children = collectChildren(node)
105
105
 
106
+ // A plain <a href> carries its destination in a normal HTML attribute, not
107
+ // a data-* one, so attributesToProperties drops it. Foundations reach this
108
+ // shape by writing an ordinary anchor in JSX that serves both the browser
109
+ // preview and the compile input — the same-source property principles.md
110
+ // §8 asks us to pursue. Without the href the adapters still emit the link
111
+ // TEXT and silently lose the destination, which is precisely the
112
+ // preview/output drift that principle exists to eliminate.
113
+ //
114
+ // Only `href` is lifted, and only for an anchor: it is the one plain
115
+ // attribute whose document meaning is unambiguous. The IR stays data-*
116
+ // driven everywhere else.
117
+ if (type === 'a' && properties.href === undefined) {
118
+ const href = getAttr(node, 'href')
119
+ if (href) properties.href = href
120
+ }
121
+
106
122
  const obj = { type, ...properties }
107
123
 
108
124
  if (type === 'text') {