@uniweb/press 0.4.15 → 0.4.16

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.15",
3
+ "version": "0.4.16",
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": {
package/src/docx/Link.jsx CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Link component for document output.
3
3
  *
4
- * Auto-detects external (http) vs internal (anchor) hyperlinks.
4
+ * Emits an in-document anchor for a `#fragment` and a destination for
5
+ * everything else. See `classifyHref` for why that is the whole rule.
5
6
  *
6
7
  * @param {Object} props
7
8
  * @param {Object} props.data - Link data: { label, href }
8
9
  */
10
+ import { classifyHref } from './classifyHref.js'
9
11
 
10
12
  export default function Link({ data, ...props }) {
11
13
  if (!data) return null
@@ -14,18 +16,11 @@ export default function Link({ data, ...props }) {
14
16
 
15
17
  if (!href) return null
16
18
 
17
- const isExternal = href.startsWith('http')
19
+ const { anchor } = classifyHref(href)
18
20
 
19
- if (isExternal) {
21
+ if (anchor !== null) {
20
22
  return (
21
- <a
22
- data-type="externalHyperlink"
23
- data-link={href}
24
- href={href}
25
- target="_blank"
26
- rel="noopener noreferrer"
27
- {...props}
28
- >
23
+ <a data-type="internalHyperlink" data-anchor={anchor} href={href} {...props}>
29
24
  <span data-type="text" data-style="Hyperlink">
30
25
  {label || href}
31
26
  </span>
@@ -33,15 +28,20 @@ export default function Link({ data, ...props }) {
33
28
  )
34
29
  }
35
30
 
36
- // The IR's anchor is a BARE bookmark name — `data-bookmark="x"` becomes
37
- // `<w:bookmarkStart w:name="x"/>` — so a documented `href: '#section-3'`
38
- // has to lose its '#' or `w:anchor="#section-3"` never matches
39
- // `w:name="section-3"` and Word resolves nothing. `href` keeps the
40
- // fragment form, which is what the browser preview needs.
41
- const anchor = href.startsWith('#') ? href.slice(1) : href
31
+ // `target`/`rel` only affect the React preview, never the compiled
32
+ // document, so they stay on the narrower http test they have always
33
+ // used — opening a mailto: or a site path in a new tab is not what
34
+ // this component previously did and is not what this change is about.
35
+ const offSite = href.startsWith('http')
42
36
 
43
37
  return (
44
- <a data-type="internalHyperlink" data-anchor={anchor} href={href} {...props}>
38
+ <a
39
+ data-type="externalHyperlink"
40
+ data-link={href}
41
+ href={href}
42
+ {...(offSite ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
43
+ {...props}
44
+ >
45
45
  <span data-type="text" data-style="Hyperlink">
46
46
  {label || href}
47
47
  </span>
@@ -13,6 +13,7 @@
13
13
  * in-document anchor the same way (see below).
14
14
  */
15
15
  import TextRun from './TextRun.jsx'
16
+ import { classifyHref } from './classifyHref.js'
16
17
 
17
18
  /** `parts` is absent only for a hand-built part; fall back to the label. */
18
19
  export function labelRuns(part) {
@@ -22,15 +23,15 @@ export function labelRuns(part) {
22
23
  export default function LinkPart({ part }) {
23
24
  const href = part.href || ''
24
25
 
25
- // An href starting with '#' is an in-document anchor, not a destination.
26
- // Emitting it as an external hyperlink produced a relationship to the
27
- // literal '#id' and no jump at all. The IR's anchor is a BARE bookmark
28
- // name — `data-bookmark="x"` becomes `<w:bookmarkStart w:name="x"/>` — so
29
- // the '#' is dropped here rather than shipped into `w:anchor`.
30
- const anchor = href.startsWith('#') ? href.slice(1) : null
31
- const linkAttrs = anchor
32
- ? { 'data-type': 'internalHyperlink', 'data-anchor': anchor }
33
- : { 'data-type': 'externalHyperlink', 'data-link': href }
26
+ // Same rule as the <Link> builder, from the same module rather than a
27
+ // second copy of the condition — the two used to classify the same href
28
+ // differently, which is what let `mailto:` compile to a dead anchor on
29
+ // one path and a working link on the other.
30
+ const { anchor } = classifyHref(href)
31
+ const linkAttrs =
32
+ anchor !== null
33
+ ? { 'data-type': 'internalHyperlink', 'data-anchor': anchor }
34
+ : { 'data-type': 'externalHyperlink', 'data-link': href }
34
35
 
35
36
  return (
36
37
  <a {...linkAttrs} href={href}>
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Decide what SHAPE a link takes in the compiled document. INTERNAL helper —
3
+ * not in the barrel.
4
+ *
5
+ * The whole rule: **a `#fragment` is an in-document anchor; everything else is
6
+ * a destination, emitted verbatim.**
7
+ *
8
+ * ## Why that is the whole rule
9
+ *
10
+ * A fragment means "within this document" in every hypertext format, so this
11
+ * is the one classification Press can make from the href alone without knowing
12
+ * anything about the caller. Everything else — `https://…`, `mailto:…`,
13
+ * `/about`, `../sibling`, `page:installation` — is a destination Press has no
14
+ * standing to interpret.
15
+ *
16
+ * What this replaced was a guess: `href.startsWith('http')` treated everything
17
+ * else as an in-document anchor, so `mailto:a@b.com` and `/about` compiled to
18
+ * `<w:hyperlink w:anchor="mailto:a@b.com">` — a reference to a bookmark that
19
+ * cannot exist. That does not merely fail to navigate: **an anchor name is not
20
+ * a URL, so the destination is destroyed** and nothing downstream can recover
21
+ * it. Emitting it as a destination keeps it in the file, where a wrong link is
22
+ * at least visible and diagnosable.
23
+ *
24
+ * ## Why a bare name is NOT accepted as an anchor
25
+ *
26
+ * `section-3` and `page.html` are indistinguishable. Any rule that reads a bare
27
+ * token as a bookmark name necessarily turns some page links into dead anchors
28
+ * — which is the bug above. `#` is the disambiguator; that is what fragments
29
+ * are for. An author writes `#section-3`.
30
+ *
31
+ * ## Resolution is NOT Press's job
32
+ *
33
+ * Press does not know what `page:installation` means and must not learn. That
34
+ * is the same boundary the LaTeX inset helpers state for cite keys and xref
35
+ * ids — *"Resolution stays foundation-side … the foundation passes
36
+ * already-resolved keys in; Press's job is the emission shape"* — and the same
37
+ * one `DocumentProvider` states for `basePath` (*"Press itself has no awareness
38
+ * of the host runtime"*). Press declares no `@uniweb/*` dependency, and
39
+ * resolving a framework reference here would mean duplicating a resolver that
40
+ * lives in the runtime, which is how two implementations drift apart.
41
+ *
42
+ * So an unresolved `page:` reference compiles to a link pointing at the literal
43
+ * string. That is intended: it is the caller's unresolved input, preserved
44
+ * rather than swallowed. Callers compiling Uniweb content resolve authored
45
+ * hrefs before handing strings to the builders.
46
+ *
47
+ * @param {string} href
48
+ * @returns {{ anchor: string | null }} `anchor` is the bare bookmark name for
49
+ * an in-document reference, or null when the href is a destination.
50
+ */
51
+ export function classifyHref(href) {
52
+ if (!href || typeof href !== 'string') return { anchor: null }
53
+
54
+ // The bookmark name carries no '#'. A paragraph's `data-bookmark="x"`
55
+ // becomes `<w:bookmarkStart w:name="x"/>`, and Word matches
56
+ // `<w:hyperlink w:anchor="x">` against that name, so the fragment marker
57
+ // has to come off or the two never meet.
58
+ if (href.startsWith('#')) return { anchor: href.slice(1) }
59
+
60
+ return { anchor: null }
61
+ }