@uniweb/press 0.4.15 → 0.4.17

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.17",
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": {
@@ -1,89 +1,425 @@
1
1
  /**
2
2
  * @fileoverview The CSS a Temml-produced MathML document needs to lay out.
3
3
  *
4
- * Math arrives as MathML, which browsers render natively — so it is tempting
5
- * to conclude, as this pipeline's own comments once did, that it carries no
6
- * CSS dependency at all. It does, for two separate reasons:
4
+ * Math arrives as MathML, which browsers render natively — so it is tempting to
5
+ * conclude, as this pipeline's own comments once did, that it carries no CSS
6
+ * dependency at all. It carries a large one, and `<menclose>` is the proof:
7
+ * that element is NOT part of MathML-Core, it is Temml's own polyfill, and the
8
+ * rules that draw `\cancel`, `\overline` and `\sout` over it exist only in
9
+ * Temml's stylesheet. Without them those constructs render as the bare term — a
10
+ * formula that means something different from what the author wrote.
7
11
  *
8
- * 1. **Temml emits class hooks and expects a stylesheet to define them.**
9
- * `tml-left` / `tml-right` carry the `&` column alignment of an `aligned`
10
- * environment, and `tml-jot` carries its extra row spacing. With nothing
11
- * defining them, a derivation's `=` signs do not line up and its rows sit
12
- * flush against each other. The declarations below are Temml's own, from
13
- * `Temml-Local.css`.
12
+ * ── Why this is a vendored string and not an import ──
14
13
  *
15
- * 2. **`mtd` gets no vertical padding from the browser.** Temml's stylesheet
16
- * only *adjusts* row spacing for jot and small, on the stated assumption
17
- * that "default mtd top padding is 0.5ex per MathML-Core and user-agent
18
- * CSS". Measured in Chrome 2026-07, a pristine `mtd` gets 0px — so a
19
- * `pmatrix` or `cases`, which carries no `tml-*` class at all, renders with
20
- * its rows touching. Shipping Temml's stylesheet verbatim fixes the
21
- * derivation and leaves the matrix broken; the `math mtd` rule below is the
22
- * part Temml does not have.
14
+ * The browser lane (`@uniweb/kit/math-tokens.css`) simply does
15
+ * `@import "temml/dist/Temml-Local.css"` and lets the bundler resolve it. press
16
+ * cannot: its adapters EMIT CSS as a string into a document, run in browser
17
+ * hosts as well as Node ones (see the host-supplied asset loaders in
18
+ * `assets/fetch.js`), and press has — by constitution — no `@uniweb/*`
19
+ * dependency at all. So the declarations are genuinely duplicated here.
23
20
  *
24
- * ── Why this is a string and not an imported stylesheet ──
21
+ * That duplicate is pinned mechanically rather than by comment:
22
+ * `framework/_contracts/math-css-parity.test.js` recomposes this value from the
23
+ * real `temml` package plus kit's stylesheet and fails when they disagree.
24
+ * Regenerate rather than hand-edit `TEMML_BASE`.
25
25
  *
26
- * These adapters run in browser hosts as well as Node ones (see the host-
27
- * supplied asset loaders in `assets/fetch.js`), so press cannot read a file at
28
- * runtime, and it does not depend on `@uniweb/kit`. The same declarations
29
- * therefore live in `kit/src/prose-tokens.css` for the browser lane.
26
+ * ── The two deliberate departures from Temml's sheet ──
30
27
  *
31
- * That is a duplicate, and duplicates drift — so it is pinned mechanically
32
- * rather than by comment: `framework/_contracts/math-css-parity.test.js`
33
- * parses both and fails when they disagree. Change one, change the other, or
34
- * the contract test names it.
28
+ * 1. **`@font-face` is stripped.** It points at `Temml.woff2`, which a bundler
29
+ * emits as an asset for the browser lane but which press would have to
30
+ * package into the EPUB itself. Fixing it is an asset question for the EPUB
31
+ * adapter, not a CSS one.
32
+ *
33
+ * Consequence, accepted for now, and it is wider than it first looks: two
34
+ * rules want that face — `math .mathscr` and, more commonly, `mo.tml-prime`,
35
+ * so every `f'` and `f''` in a compiled document loses its prime alignment
36
+ * and falls back to a system glyph. (It is NOT what draws `\mathcal`; that
37
+ * is `*.mathcal { font-feature-settings: 'ss01' }` and survives stripping.)
38
+ *
39
+ * 2. **`.tml-eqn::before` is rescoped to `.tml-eqn:empty::before`.** Temml
40
+ * leaves the tag span empty and asks a CSS counter to draw the number. That
41
+ * works in a browser and not in a document: Paged.js rewrites counters for
42
+ * its own pagination and strips `counter-increment`, so every equation
43
+ * rendered as "(0)" (measured 2026-07-31). `numberEquations` below therefore
44
+ * writes the number in as TEXT — and a span carrying a number is no longer
45
+ * `:empty`, so the counter rule and the text can never both fire.
46
+ */
47
+
48
+ /**
49
+ * Temml's own stylesheet, vendored. Generated from `temml/dist/Temml-Local.css`
50
+ * with the two departures described above. Do not hand-edit.
35
51
  */
52
+ const TEMML_BASE = `math {
53
+ font-family: "Cambria Math", 'STIXTwoMath-Regular', 'NotoSansMath-Regular', math;
54
+ font-style: normal;
55
+ font-weight: normal;
56
+ line-height: normal;
57
+ font-size-adjust: none;
58
+ text-indent: 0;
59
+ text-transform: none;
60
+ letter-spacing: normal;
61
+ word-wrap: normal;
62
+ direction: ltr;
63
+ /* Prevent Firefox from omitting the dot on i or j. */
64
+ font-feature-settings: "dtls" off;
65
+ }
66
+
67
+ math * {
68
+ border-color: currentColor;
69
+ }
70
+
71
+ /* display: block is necessary in Firefox and Safari.
72
+ * Not in Chromium, which recognizes display: "block math" written inline. */
73
+ math.tml-display {
74
+ display: block;
75
+ width: 100%;
76
+ }
77
+
78
+ *.mathcal {
79
+ /* NotoSans */
80
+ font-feature-settings: 'ss01';
81
+ }
82
+
83
+ math .mathscr {
84
+ font-family: "Temml";
85
+ }
86
+
87
+ mo.tml-prime {
88
+ font-family: Temml;
89
+ }
90
+
91
+ /* Cramped superscripts in WebKit */
92
+ mfrac > :nth-child(2),
93
+ msqrt,
94
+ mover > :first-child {
95
+ math-shift: compact
96
+ }
97
+
98
+ .menclose {
99
+ display: inline-block;
100
+ position: relative;
101
+ padding: 0.5ex 0ex;
102
+ }
103
+ .tml-cancelto {
104
+ display: inline-block;
105
+ position: absolute;
106
+ top: 0;
107
+ left: 0;
108
+ padding: 0.5ex 0ex;
109
+ background-color: currentColor;
110
+ /* Use the SVG as an alpha mask (painted by background-color) */
111
+ -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><defs><marker id='a' markerHeight='5' markerUnits='strokeWidth' markerWidth='7' orient='auto' refX='7' refY='2.5'><path fill='black' d='m0 0 7 2.5L0 5z'/></marker></defs><line x2='100%25' y1='100%25' stroke='black' stroke-width='.06em' marker-end='url(%23a)' vector-effect='non-scaling-stroke'/></svg>");
112
+ mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><defs><marker id='a' markerHeight='5' markerUnits='strokeWidth' markerWidth='7' orient='auto' refX='7' refY='2.5'><path fill='black' d='m0 0 7 2.5L0 5z'/></marker></defs><line x2='100%25' y1='100%25' stroke='black' stroke-width='.06em' marker-end='url(%23a)' vector-effect='non-scaling-stroke'/></svg>");
113
+ -webkit-mask-repeat: no-repeat;
114
+ mask-repeat: no-repeat;
115
+ -webkit-mask-size: 100% 100%;
116
+ mask-size: 100% 100%;
117
+ -webkit-mask-position: 0 0;
118
+ mask-position: 0 0;
119
+ }
120
+
121
+ @supports (-moz-appearance: none) {
122
+ /* \vec w/o italic correction for Firefox */
123
+ .tml-vec {
124
+ transform: scale(0.75)
125
+ }
126
+ /* Fix \cancelto in Firefox */
127
+ .ff-narrow {
128
+ width: 0em;
129
+ }
130
+ .ff-nudge-left {
131
+ margin-left: -0.2em;
132
+ }
133
+ }
36
134
 
37
- export const MATH_CSS = `/* Temml's own class hooks — without these an aligned environment neither
38
- aligns nor breathes. */
39
- .tml-right { text-align: right; }
40
- .tml-left { text-align: left; }
41
- .tml-sml-pad { padding-left: 0.05em; }
42
- /* Row spacing. The browser gives mtd no vertical padding, so state the
43
- MathML-Core default here; Temml's own CSS only adjusts it for jot. */
44
- math mtd { padding-top: 0.5ex; padding-bottom: 0.5ex; }
45
- math mtable.tml-jot mtd { padding-top: 0.7ex; padding-bottom: 0.7ex; }
46
- /* AMS auto-numbering, for lanes that keep our CSS counter.
47
-
48
- Scoped to :empty because the document lanes cannot rely on it -- Paged.js
49
- rewrites counters for its own pagination and strips counter-increment, so
50
- every equation rendered as "(0)" (measured 2026-07-31; the declaration
51
- survives intact without the polyfill). press therefore writes the numbers
52
- into the spans as text, and a span carrying a number is no longer :empty, so
53
- the two can never both fire.
54
-
55
- AMS auto-numbering. Which equations number is the AUTHOR's choice, made in
56
- LaTeX: align and equation number, aligned and the starred forms do not.
57
- Without these two rules that choice was discarded -- align and align-star
58
- rendered identically, so an author who asked for numbers silently got none.
59
- (No backticks in here: this string is a JS template literal.) */
135
+ @supports (not (-moz-appearance: none)) {
136
+ /* Chromium and WebKit */
137
+ /* prime vertical alignment */
138
+ mo.tml-prime {
139
+ font-family: Temml;
140
+ }
141
+ /* Italic correction on superscripts */
142
+ .tml-sml-pad {
143
+ padding-left: 0.05em;
144
+ }
145
+ .tml-med-pad {
146
+ padding-left: 0.10em;
147
+ }
148
+ .tml-lrg-pad {
149
+ padding-left: 0.15em;
150
+ }
151
+ }
152
+
153
+ @supports (-webkit-backdrop-filter: blur(1px)) {
154
+ /* WebKit vertical & italic correction on accents */
155
+ .wbk-acc {
156
+ /* lower by x-height distance */
157
+ transform: translate(0em, 0.431em);
158
+ }
159
+ .wbk-sml {
160
+ transform: translate(0.07em, 0);
161
+ }
162
+ .wbk-sml-acc {
163
+ transform: translate(0.07em, 0.431em);
164
+ }
165
+ .wbk-sml-vec {
166
+ transform: scale(0.75) translate(0.07em, 0);
167
+ }
168
+ .wbk-med {
169
+ transform: translate(0.14em, 0);
170
+ }
171
+ .wbk-med-acc {
172
+ transform: translate(0.14em, 0.431em);
173
+ }
174
+ .wbk-med-vec {
175
+ transform: scale(0.75) translate(0.14em, 0);
176
+ }
177
+ .wbk-lrg {
178
+ transform: translate(0.21em, 0);
179
+ }
180
+ .wbk-lrg-acc {
181
+ transform: translate(0.21em, 0.431em);
182
+ }
183
+ .wbk-lrg-vec {
184
+ transform: scale(0.75) translate(0.21em, 0);
185
+ }
186
+ }
187
+
188
+ /* \cancel & \phase use background images. Get them to print. */
189
+ menclose {
190
+ -webkit-print-color-adjust: exact; /* Chrome & Edge */
191
+ print-color-adjust: exact;
192
+ }
193
+
194
+ /* Array cell justification in Firefox & WebKit */
195
+ .tml-right {
196
+ text-align: right;
197
+ }
198
+ .tml-left {
199
+ text-align: left;
200
+ }
201
+
202
+ /* For CD labels that grow to the left in Firefox and WebKit */
203
+ .tml-shift-left { margin-left:-200% }
204
+
205
+ /* Styles for Chromium only */
206
+ @supports (not (-webkit-backdrop-filter: blur(1px))) and (not (-moz-appearance: none)) {
207
+ /* Italic correction on accents */
208
+ .chr-sml {
209
+ transform: translate(0.07em, 0)
210
+ }
211
+ .chr-sml-vec {
212
+ transform: scale(0.75) translate(0.07em, 0)
213
+ }
214
+ .chr-med {
215
+ transform: translate(0.14em, 0)
216
+ }
217
+ .chr-med-vec {
218
+ transform: scale(0.75) translate(0.14em, 0)
219
+ }
220
+ .chr-lrg {
221
+ transform: translate(0.21em, 0)
222
+ }
223
+ .chr-lrg-vec {
224
+ transform: scale(0.75) translate(0.21em, 0)
225
+ }
226
+
227
+ /* For CD labels that grow to the left */
228
+ .tml-shift-left { margin-left:-100% }
229
+
230
+ /* MathML Core & Chromium do not support the MathML 3.0 element <menclose> attributes. */
231
+ /* So use styles. */
232
+ menclose {
233
+ position: relative;
234
+ padding: 0.5ex 0ex;
235
+ }
236
+
237
+ .tml-overline {
238
+ padding: 0.1em 0 0 0;
239
+ border-top: 0.065em solid;
240
+ }
241
+
242
+ .tml-underline {
243
+ padding: 0 0 0.1em 0;
244
+ border-bottom: 0.065em solid;
245
+ }
246
+
247
+ .tml-cancel {
248
+ display: inline-block;
249
+ position: absolute;
250
+ left: 0.5px;
251
+ bottom: 0;
252
+ width: 100%;
253
+ height: 100%;
254
+ background-color: currentColor;
255
+ }
256
+ .upstrike {
257
+ clip-path: polygon(0.05em 100%, 0em calc(100% - 0.05em), calc(100% - 0.05em) 0em, 100% 0.05em);
258
+ }
259
+ .downstrike {
260
+ clip-path: polygon(0em 0.05em, 0.05em 0em, 100% calc(100% - 0.05em), calc(100% - 0.05em) 100%);
261
+ }
262
+ .sout {
263
+ clip-path: polygon(0em calc(55% + 0.0333em), 0em calc(55% - 0.0333em), 100% calc(55% - 0.0333em), 100% calc(55% + 0.0333em));
264
+ }
265
+ .tml-xcancel {
266
+ clip-path: polygon(0.05em 0em, 0em 0.05em, calc(50% - 0.05em) 50%, 0em calc(100% - 0.05em), 0.05em 100%, 50% calc(50% + 0.05em), calc(100% - 0.05em) 100%, 100% calc(100% - 0.05em), calc(50% + 0.05em) 50%, 100% 0.05em, calc(100% - 0.05em) 0%, 50% calc(50% - 0.05em));
267
+ }
268
+
269
+ .longdiv-top {
270
+ border-top: 0.067em solid;
271
+ padding: 0.1em 0.2em 0.2em 0.433em;
272
+ }
273
+ .longdiv-arc {
274
+ position: absolute;
275
+ top: 0;
276
+ bottom: 0.1em;
277
+ left: -0.4em;
278
+ width: 0.7em;
279
+ border: 0.067em solid;
280
+ transform: translateY(-0.067em);
281
+ border-radius: 70%;
282
+ clip-path: inset(0 0 0 0.4em);
283
+ box-sizing: border-box;}
284
+ .menclose {display: inline-block;
285
+ text-align: left;
286
+ position: relative;
287
+ }
288
+
289
+ .phasor-bottom {
290
+ border-bottom: 0.067em solid;
291
+ padding: 0.2em 0.2em 0.1em 0.6em;
292
+ }
293
+ .phasor-angle {
294
+ display: inline-block;
295
+ position: absolute;
296
+ left: 0.5px;
297
+ bottom: -0.04em;
298
+ height: 100%;
299
+ aspect-ratio: 0.5;
300
+ background-color: currentColor;
301
+ clip-path: polygon(0.05em 100%, 0em calc(100% - 0.05em), calc(100% - 0.05em) 0em, 100% 0.05em);
302
+ }
303
+
304
+ .tml-fbox {
305
+ padding: 3pt;
306
+ border: 1px solid;
307
+ }
308
+
309
+ .circle-pad {
310
+ padding: 0.267em;
311
+ }
312
+ .textcircle {
313
+ position: absolute;
314
+ top: 0;
315
+ bottom: 0;
316
+ right: 0;
317
+ left: 0;
318
+ border: 0.067em solid;
319
+ border-radius: 50%;
320
+ }
321
+
322
+ .actuarial {
323
+ padding: 0.03889em 0.03889em 0 0.03889em;
324
+ border-width: 0.08em 0.08em 0em 0em;
325
+ border-style: solid;
326
+ margin-right: 0.03889em;
327
+ }
328
+
329
+ /* Stretch \widetilde */
330
+ .tml-crooked-2 {
331
+ transform: scale(2.0, 1.1)
332
+ }
333
+ .tml-crooked-3 {
334
+ transform: scale(3.0, 1.3)
335
+ }
336
+ .tml-crooked-4 {
337
+ transform: scale(4.0, 1.4)
338
+ }
339
+ /* set array cell justification */
340
+ .tml-right {
341
+ text-align: -webkit-right;
342
+ }
343
+ .tml-left {
344
+ text-align: -webkit-left;
345
+ }
346
+ }
347
+
348
+ .special-fraction {
349
+ font-family: 'STIX TWO', 'Times New Roman', Times, Tinos, serif;
350
+ }
351
+
352
+ /* flex-wrap for line-breaking in Chromium */
353
+ math {
354
+ display: inline-flex;
355
+ flex-wrap: wrap;
356
+ align-items: baseline;
357
+ }
358
+ math > mrow {
359
+ padding: 0.5ex 0ex;
360
+ }
361
+
362
+ /* Default mtd top padding is 0.5ex per MathML-Core and user-agent CSS */
363
+ /* We adjust for jot and small */
364
+ mtable.tml-jot mtd {
365
+ padding-top: 0.7ex;
366
+ padding-bottom: 0.7ex;
367
+ }
368
+ mtable.tml-small mtd {
369
+ padding-top: 0.35ex;
370
+ padding-bottom: 0.35ex;
371
+ }
372
+
373
+ /* Firefox */
374
+ @-moz-document url-prefix() {
375
+ /* Avoid flex-wrap */
376
+ math { display: inline; }
377
+ math > mrow { padding: 0 }
378
+ /* Adjust Firefox spacing between array rows */
379
+ mtd, mtable.tml-small mtd { padding-top: 0; padding-bottom: 0; }
380
+ mtable.tml-jot mtd { padding-top: 0.2ex; padding-bottom: 0.ex; }
381
+ }
382
+
383
+ /* AMS environment auto-numbering via CSS counter. */
60
384
  .tml-eqn:empty::before {
61
385
  counter-increment: tmlEqnNo;
62
386
  content: "(" counter(tmlEqnNo) ")";
63
387
  }
388
+
64
389
  body {
65
390
  counter-reset: tmlEqnNo;
391
+ }`
392
+
393
+ /**
394
+ * What Temml does not have. Kept byte-identical to the rules in
395
+ * `@uniweb/kit/math-tokens.css`, which the parity test enforces.
396
+ *
397
+ * 1. `mtd` padding: Temml's sheet only ADJUSTS row spacing for jot and small,
398
+ * on the stated assumption that "default mtd top padding is 0.5ex per
399
+ * MathML-Core and user-agent CSS". Measured in Chrome (2026-07), a pristine
400
+ * `mtd` gets 0px, and a `pmatrix` or `cases` carries no `tml-*` class at
401
+ * all — so its rows touch. Guarded to exclude Firefox because Temml
402
+ * deliberately ZEROES mtd padding there, and an unguarded `math mtd`
403
+ * outranks it on specificity (0,0,2 vs 0,0,1) regardless of the at-rule.
404
+ *
405
+ * 2. Display math needs CSS block layout, not MathML layout, for an equation
406
+ * TAG to reach the right margin. Temml already declares
407
+ * `math.tml-display { display: block; width: 100% }` and cannot win: it emits
408
+ * `style="display:block math"` inline on every display formula, and an
409
+ * inline style beats any stylesheet rule. This restores Temml's own intent
410
+ * with the `!important` its rule lacks, scoped with `:has()` so unnumbered
411
+ * formulas keep MathML layout and stay centred.
412
+ */
413
+ const UNIWEB_CORRECTIONS = `@supports (not (-moz-appearance: none)) {
414
+ math mtd { padding-top: 0.5ex; padding-bottom: 0.5ex; }
66
415
  }
67
- /* Display math needs CSS block layout, not MathML layout, for an equation TAG
68
- to reach the right margin: the tag rides in an mtable whose width:100%
69
- Chromium ignores under display: block math, collapsing the spacer cells so
70
- "(1)" sits glued to the equation instead of at the margin.
71
-
72
- !important is not decoration. Temml emits style=display:block math on
73
- every display formula unconditionally — no option turns it off — so an inline
74
- style beats any rule we write, including Temml's own math.tml-display { display: block }, which is exactly what this restores. Fixing it here
75
- rather than in the generator also repairs math that was already built.
76
-
77
- Scoped with :has() to formulas that actually carry a tag. Switching every
78
- display formula to CSS block layout would left-align the lot -- MathML layout
79
- is what centres them -- so an unnumbered derivation or matrix keeps
80
- display: block math and stays centred. Where :has() is unsupported the tag
81
- simply does not reach the margin; nothing else changes. */
82
416
  math.tml-display:has(.tml-eqn) {
83
417
  display: block !important;
84
418
  width: 100%;
85
419
  }`
86
420
 
421
+ export const MATH_CSS = `${TEMML_BASE}\n${UNIWEB_CORRECTIONS}`
422
+
87
423
  /**
88
424
  * Write equation numbers into Temml's tag spans as text.
89
425
  *
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
+ }