@uniweb/press 0.4.3 → 0.4.5

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,13 +1,17 @@
1
1
  /**
2
- * Parses a styled string with inline HTML marks (<strong>, <em>, <u>, <a>)
3
- * into an array of text/link part objects with style flags.
2
+ * Parses a styled string with inline HTML marks (<strong>, <em>, <u>, <a>,
3
+ * <span data-type="math">) into an array of text / link / math part
4
+ * objects with style flags.
4
5
  *
5
6
  * Ported from legacy report-sdk/src/utils.js:116-186, extended to support
6
7
  * <a href="..."> hyperlinks so paragraphs with auto-linked emails and URLs
7
- * produce real hyperlinks in the docx output.
8
+ * produce real hyperlinks in the docx output, and <span data-type="math">
9
+ * so inline math reaches the print adapters as a structured atom rather
10
+ * than being walked as opaque DOM (which turns MathML into raw operator
11
+ * text).
8
12
  *
9
13
  * @param {string} inputString - HTML string with inline marks.
10
- * @returns {Array<{type: string, content: string, bold?: boolean, italics?: boolean, underline?: object, href?: string}>}
14
+ * @returns {Array<{type: string, content?: string, bold?: boolean, italics?: boolean, underline?: object, href?: string, latex?: string, display?: boolean}>}
11
15
  *
12
16
  * @example
13
17
  * parseStyledString('Hello <strong>World</strong>')
@@ -22,7 +26,34 @@
22
26
  * // { type: 'text', content: 'Visit ' },
23
27
  * // { type: 'link', content: 'Example', href: 'https://example.com' }
24
28
  * // ]
29
+ *
30
+ * @example
31
+ * parseStyledString('Let $G$ be a graph') // after semantic-parser wrapping
32
+ * // input: 'Let <span data-type="math" data-latex="G" data-display="false">…mathml…</span> be a graph'
33
+ * // => [
34
+ * // { type: 'text', content: 'Let ' },
35
+ * // { type: 'math', latex: 'G', display: false },
36
+ * // { type: 'text', content: ' be a graph' }
37
+ * // ]
25
38
  */
39
+
40
+ // Decode HTML-attribute entities written by sequence.js's escapeAttr().
41
+ // Inverse of the four replacements there. `&amp;` last so a literal
42
+ // `&amp;quot;` round-trips correctly.
43
+ function decodeAttr(s) {
44
+ return String(s)
45
+ .replace(/&quot;/g, '"')
46
+ .replace(/&lt;/g, '<')
47
+ .replace(/&gt;/g, '>')
48
+ .replace(/&amp;/g, '&')
49
+ }
50
+
51
+ function readAttr(attrs, name) {
52
+ const re = new RegExp(`${name}="([^"]*)"`)
53
+ const m = attrs.match(re)
54
+ return m ? decodeAttr(m[1]) : null
55
+ }
56
+
26
57
  export function parseStyledString(inputString) {
27
58
  const createTextPart = (content, styles) => ({
28
59
  type: 'text',
@@ -48,8 +79,7 @@ export function parseStyledString(inputString) {
48
79
 
49
80
  // Handle <a> tags as links
50
81
  if (tag === 'a' && attrs) {
51
- const hrefMatch = attrs.match(/href="([^"]*)"/)
52
- const href = hrefMatch?.[1]
82
+ const href = readAttr(attrs, 'href')
53
83
  if (href) {
54
84
  result.push({
55
85
  type: 'link',
@@ -61,6 +91,29 @@ export function parseStyledString(inputString) {
61
91
  }
62
92
  }
63
93
 
94
+ // Handle <span data-type="math"> as an inline atom. The inner
95
+ // text is the pre-compiled MathML — discarded here because
96
+ // print adapters consume the LaTeX source instead. Browser-
97
+ // side renderers don't go through parseStyledString; they use
98
+ // dangerouslySetInnerHTML on the original paragraph HTML where
99
+ // the MathML survives intact.
100
+ if (tag === 'span' && attrs) {
101
+ const dataType = readAttr(attrs, 'data-type')
102
+ if (dataType === 'math') {
103
+ const latex = readAttr(attrs, 'data-latex') || ''
104
+ const display = readAttr(attrs, 'data-display') === 'true'
105
+ const id = readAttr(attrs, 'data-id')
106
+ result.push({
107
+ type: 'math',
108
+ latex,
109
+ display,
110
+ ...(id ? { id } : {}),
111
+ })
112
+ lastIndex = offset + match.length
113
+ return
114
+ }
115
+ }
116
+
64
117
  const newStyles = { ...styles }
65
118
  if (tag === 'strong' || tag === 'b') newStyles.bold = true
66
119
  if (tag === 'em' || tag === 'i') newStyles.italics = true
package/src/docx/units.js CHANGED
@@ -73,3 +73,24 @@ export function convertCentimetersToTwip(centimeters) {
73
73
  export function convertPointsToHalfPoints(points) {
74
74
  return Math.round(points * 2)
75
75
  }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Short aliases — for terse JSX. The longer names above remain canonical
79
+ // (and match the docx library's own helpers); these are sugar so foundation
80
+ // code can write `columnWidths={[cm(15), cm(4), cm(1.5), cm(4)]}` instead of
81
+ // `[convertCentimetersToTwip(15), convertCentimetersToTwip(4), …]`.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Centimeters to twips. Alias of `convertCentimetersToTwip`. */
85
+ export const cm = convertCentimetersToTwip
86
+
87
+ /** Millimeters to twips. Alias of `convertMillimetersToTwip`. */
88
+ export const mm = convertMillimetersToTwip
89
+
90
+ /** Inches to twips. Alias of `convertInchesToTwip`. */
91
+ export const inch = convertInchesToTwip
92
+
93
+ /** Points to twips. There are 20 twips in a point. */
94
+ export function pt(points) {
95
+ return Math.round(points * 20)
96
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @uniweb/press/format — locale-aware text formatters.
3
+ *
4
+ * Stage 6.1 of kb/framework/plans/press-professional-docx.md.
5
+ *
6
+ * The same JSX feeds the React preview and the docx fragment, so
7
+ * these are React components that emit <span data-type="text"> and
8
+ * route through the standard IR walker. Each takes a `value` prop
9
+ * (or `from`/`to` for ranges) plus optional locale + format props,
10
+ * and produces a single text node with the formatted string. They
11
+ * are theme-aware: an active <DocumentProvider> supplies the
12
+ * default locale and (for currency) the default ISO code, so
13
+ * foundations don't have to repeat them at every call site.
14
+ *
15
+ * The motivation: foundations had been writing `String(invoice.due)`
16
+ * which yields `"Sat Feb 28 2026 19:00:00 GMT-0500 …"` whenever
17
+ * YAML eagerly turns an ISO string into a JS Date. The IR layer
18
+ * also has a defensive Date->ISO coercion (src/adapters/docx.js),
19
+ * but the right way to render dates is to choose a format
20
+ * explicitly via these builders.
21
+ */
22
+ import { useDocumentTheme } from '../ThemeContext.js'
23
+
24
+ /**
25
+ * Coerce a value to a JS Date for formatting. Accepts:
26
+ * - Date instance (used as-is)
27
+ * - ISO string ('2026-03-31')
28
+ * - epoch number
29
+ * Returns null on invalid input so the builder renders nothing.
30
+ */
31
+ function toDate(value) {
32
+ if (value == null || value === '') return null
33
+ if (value instanceof Date) {
34
+ return Number.isNaN(value.getTime()) ? null : value
35
+ }
36
+ const d = new Date(value)
37
+ return Number.isNaN(d.getTime()) ? null : d
38
+ }
39
+
40
+ /**
41
+ * Map our small set of format names onto Intl.DateTimeFormat option
42
+ * bags. 'iso' is special-cased — it returns the YYYY-MM-DD form
43
+ * exactly, using UTC components (not local) so a date like
44
+ * `2026-03-31` doesn't shift across timezones.
45
+ */
46
+ const DATE_FORMATS = {
47
+ iso: null, // sentinel — handled before Intl
48
+ short: { year: 'numeric', month: 'numeric', day: 'numeric' },
49
+ medium: { year: 'numeric', month: 'short', day: 'numeric' },
50
+ long: { year: 'numeric', month: 'long', day: 'numeric' },
51
+ full: {
52
+ weekday: 'long',
53
+ year: 'numeric',
54
+ month: 'long',
55
+ day: 'numeric',
56
+ },
57
+ }
58
+
59
+ function formatIso(d) {
60
+ const y = d.getUTCFullYear()
61
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0')
62
+ const day = String(d.getUTCDate()).padStart(2, '0')
63
+ return `${y}-${m}-${day}`
64
+ }
65
+
66
+ function formatDate(value, { locale = 'en-CA', format = 'medium' } = {}) {
67
+ const d = toDate(value)
68
+ if (!d) return ''
69
+ if (format === 'iso') return formatIso(d)
70
+ const opts = DATE_FORMATS[format] || DATE_FORMATS.medium
71
+ try {
72
+ // Force UTC so date-only ISO inputs ('2026-03-31') don't drift
73
+ // by the local timezone — without this, en-CA in EST renders
74
+ // 'Mar 30, 2026' instead of 'Mar 31, 2026'.
75
+ return new Intl.DateTimeFormat(locale, { ...opts, timeZone: 'UTC' }).format(d)
76
+ } catch {
77
+ // Bad locale — fall back to ISO.
78
+ return formatIso(d)
79
+ }
80
+ }
81
+
82
+ function formatDateRange(from, to, opts) {
83
+ const f = formatDate(from, opts)
84
+ const t = formatDate(to, opts)
85
+ if (f && t) return `${f} – ${t}`
86
+ return f || t || ''
87
+ }
88
+
89
+ function formatCurrency(value, { locale = 'en-CA', code = 'CAD' } = {}) {
90
+ if (value == null || value === '') return ''
91
+ const n = typeof value === 'number' ? value : Number(value)
92
+ if (!Number.isFinite(n)) return ''
93
+ try {
94
+ return new Intl.NumberFormat(locale, {
95
+ style: 'currency',
96
+ currency: code,
97
+ maximumFractionDigits: 2,
98
+ }).format(n)
99
+ } catch {
100
+ // Unknown currency code — fall back to plain number with the code.
101
+ return `${n.toFixed(2)} ${code}`
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Inline date — emits a single text node with a formatted date.
107
+ *
108
+ * @param {Object} props
109
+ * @param {Date|string|number} props.value
110
+ * @param {'iso'|'short'|'medium'|'long'|'full'} [props.format='medium']
111
+ * @param {string} [props.locale] - BCP-47. Defaults to theme.locale.
112
+ */
113
+ export function DateText({ value, format = 'medium', locale }) {
114
+ const theme = useDocumentTheme()
115
+ const text = formatDate(value, {
116
+ locale: locale ?? theme.locale,
117
+ format,
118
+ })
119
+ return <span data-type="text">{text}</span>
120
+ }
121
+
122
+ /**
123
+ * Inline date range — emits a single text node with two formatted
124
+ * dates joined by an en dash. When one bound is missing, only the
125
+ * other one renders.
126
+ */
127
+ export function DateRangeText({
128
+ from,
129
+ to,
130
+ period,
131
+ format = 'medium',
132
+ locale,
133
+ }) {
134
+ const theme = useDocumentTheme()
135
+ const f = period?.from ?? from
136
+ const t = period?.to ?? to
137
+ const text = formatDateRange(f, t, {
138
+ locale: locale ?? theme.locale,
139
+ format,
140
+ })
141
+ return <span data-type="text">{text}</span>
142
+ }
143
+
144
+ /**
145
+ * Inline currency — emits a single text node with a locale- and
146
+ * code-aware money amount.
147
+ *
148
+ * @param {Object} props
149
+ * @param {number|string} props.value
150
+ * @param {string} [props.code] - ISO 4217. Defaults to theme.currency.
151
+ * @param {string} [props.locale]
152
+ */
153
+ export function Currency({ value, code, locale }) {
154
+ const theme = useDocumentTheme()
155
+ const text = formatCurrency(value, {
156
+ locale: locale ?? theme.locale,
157
+ code: code ?? theme.currency,
158
+ })
159
+ return <span data-type="text">{text}</span>
160
+ }
161
+
162
+ /**
163
+ * Imperative versions for callers that need a string outside JSX
164
+ * (cell content building, header data, etc.). These don't touch the
165
+ * theme — pass locale/code explicitly.
166
+ */
167
+ export const formatters = {
168
+ date: formatDate,
169
+ dateRange: formatDateRange,
170
+ currency: formatCurrency,
171
+ }
package/src/index.js CHANGED
@@ -17,3 +17,9 @@ export {
17
17
  compileSubtree,
18
18
  compileDocument,
19
19
  } from './compileRegistrations.js'
20
+ export {
21
+ useDocumentTheme,
22
+ resolveThemeColor,
23
+ resolveThemeFont,
24
+ DEFAULT_THEME,
25
+ } from './ThemeContext.js'
@@ -46,6 +46,23 @@ const toInt = (v) => {
46
46
  return Number.isFinite(n) ? n : v
47
47
  }
48
48
 
49
+ /**
50
+ * Parse a comma-separated list of integers (whitespace tolerant).
51
+ * Used by `data-table-column-widths`, where the attribute encodes an
52
+ * array of twip values like "8504,2268,851,2268". Empty / non-numeric
53
+ * tokens are dropped — downstream callers treat an empty array the
54
+ * same as "no column widths declared".
55
+ */
56
+ const parseIntList = (v) => {
57
+ if (typeof v !== 'string') return v
58
+ return v
59
+ .split(',')
60
+ .map((s) => s.trim())
61
+ .filter(Boolean)
62
+ .map((s) => parseInt(s, 10))
63
+ .filter((n) => Number.isFinite(n))
64
+ }
65
+
49
66
  /**
50
67
  * Explicit attribute → IR path mapping. Entries are listed in the same
51
68
  * order as the legacy switch for ease of cross-reference.
@@ -94,6 +111,124 @@ export const attributeMap = {
94
111
  'data-borders-right-size': { path: ['borders', 'right', 'size'] },
95
112
  'data-borders-right-color': { path: ['borders', 'right', 'color'] },
96
113
 
114
+ // ------------------------------------------------------------------
115
+ // Table-cell additions (Stage 1 of the press-professional-docx plan)
116
+ // ------------------------------------------------------------------
117
+
118
+ // Cell shading. `data-shading-fill` is the load-bearing attribute
119
+ // (the solid background color). `data-shading-type` defaults to
120
+ // 'clear' in the adapter so a plain fill JSX prop produces a solid
121
+ // background without callers having to remember the OOXML idiom.
122
+ 'data-shading-fill': { path: ['shading', 'fill'] },
123
+ 'data-shading-color': { path: ['shading', 'color'] },
124
+ 'data-shading-type': { path: ['shading', 'type'] },
125
+
126
+ // Vertical alignment of the cell's content. `top` | `center` | `bottom`.
127
+ // Maps to docx's VerticalAlignTable enum at the adapter layer.
128
+ 'data-valign': { path: ['verticalAlign'] },
129
+
130
+ // Cell column merge: how many adjacent columns this cell spans.
131
+ 'data-grid-span': { path: ['columnSpan'], transform: toInt },
132
+
133
+ // Cell row merge: how many rows this cell spans (vertical merge).
134
+ // The adapter uses docx's `rowSpan` shorthand, which expects only
135
+ // the *starting* cell to declare a count; library handles the
136
+ // merge-continue rows internally.
137
+ 'data-row-span': { path: ['rowSpan'], transform: toInt },
138
+
139
+ // ------------------------------------------------------------------
140
+ // Stage 3 — paragraph polish
141
+ // ------------------------------------------------------------------
142
+
143
+ // Paragraph indentation. All values are twips. `firstLine` and
144
+ // `hanging` are positive; `left` and `right` may be negative.
145
+ 'data-indent-left': { path: ['indent', 'left'], transform: toInt },
146
+ 'data-indent-right': { path: ['indent', 'right'], transform: toInt },
147
+ 'data-indent-firstline': { path: ['indent', 'firstLine'], transform: toInt },
148
+ 'data-indent-hanging': { path: ['indent', 'hanging'], transform: toInt },
149
+
150
+ // Paragraph tab stops. JSON-encoded array of TabStopDefinition objects:
151
+ // [{ position: 6804, type: 'right', leader: 'dot' }, …]
152
+ // Position is in twips; type is left/right/center/decimal/etc;
153
+ // leader is none/dot/hyphen/underscore/middleDot. Foundations
154
+ // typically construct these via the <Paragraph tabStops=…> prop
155
+ // and the unit helpers (cm, mm, inch, pt).
156
+ // Paragraph-level named style ('Title', 'Heading1', 'Body', …).
157
+ // Maps to `<w:pStyle w:val="…"/>` in the docx adapter. Distinct
158
+ // from the run-level `data-style` (default fallthrough), which
159
+ // emits `<w:rStyle>`.
160
+ 'data-paragraph-style': { path: ['paragraphStyle'] },
161
+
162
+ 'data-tab-stops': {
163
+ path: ['tabStops'],
164
+ transform: (v) => {
165
+ if (typeof v !== 'string') return v
166
+ try {
167
+ const parsed = JSON.parse(v)
168
+ return Array.isArray(parsed) ? parsed : v
169
+ } catch {
170
+ return v
171
+ }
172
+ },
173
+ },
174
+
175
+ // TextRun toggles — explicit map entries route the lower-case
176
+ // HTML attribute names to camelCase IR fields the adapter reads.
177
+ 'data-smallcaps': { path: ['smallCaps'], transform: asTrue },
178
+ 'data-allcaps': { path: ['allCaps'], transform: asTrue },
179
+ 'data-strike': { path: ['strike'], transform: asTrue },
180
+
181
+ // Row-level: whether the row repeats as a header on each new page
182
+ // when the table breaks. Presence-only — any truthy value counts.
183
+ 'data-row-header': { path: ['tableHeader'], transform: asTrue },
184
+
185
+ // ------------------------------------------------------------------
186
+ // Table-level options (Stage 1)
187
+ //
188
+ // Stored under the prefixed `tableXxx` namespace so they don't
189
+ // collide with the cell-level `width` / `borders` properties that
190
+ // existed before. `irToTable` reads these on table-typed nodes.
191
+ // ------------------------------------------------------------------
192
+
193
+ // Column widths in twips. Emitted as a comma-separated string on
194
+ // the `<table>` element by Table.jsx; parsed back to an int array
195
+ // here. Used with `tableLayout: 'fixed'` to lock columns; without
196
+ // a fixed layout, Word redistributes columns to fit content.
197
+ 'data-table-column-widths': {
198
+ path: ['tableColumnWidths'],
199
+ transform: parseIntList,
200
+ },
201
+
202
+ // 'fixed' | 'autofit'. Default in the adapter is 'fixed' when
203
+ // tableColumnWidths is set, undefined otherwise (Word's default).
204
+ 'data-table-layout': { path: ['tableLayout'] },
205
+
206
+ // Whole-table width. Distinct from per-cell width. Same { size, type }
207
+ // shape (pct/dxa/auto) as cell widths.
208
+ 'data-table-width-size': { path: ['tableWidth', 'size'] },
209
+ 'data-table-width-type': { path: ['tableWidth', 'type'] },
210
+
211
+ // Table-level default borders. Per-cell borders still win (and
212
+ // already exist via `data-borders-*`); these set the table grid.
213
+ 'data-table-borders-top-style': { path: ['tableBorders', 'top', 'style'] },
214
+ 'data-table-borders-top-size': { path: ['tableBorders', 'top', 'size'] },
215
+ 'data-table-borders-top-color': { path: ['tableBorders', 'top', 'color'] },
216
+ 'data-table-borders-bottom-style': { path: ['tableBorders', 'bottom', 'style'] },
217
+ 'data-table-borders-bottom-size': { path: ['tableBorders', 'bottom', 'size'] },
218
+ 'data-table-borders-bottom-color': { path: ['tableBorders', 'bottom', 'color'] },
219
+ 'data-table-borders-left-style': { path: ['tableBorders', 'left', 'style'] },
220
+ 'data-table-borders-left-size': { path: ['tableBorders', 'left', 'size'] },
221
+ 'data-table-borders-left-color': { path: ['tableBorders', 'left', 'color'] },
222
+ 'data-table-borders-right-style': { path: ['tableBorders', 'right', 'style'] },
223
+ 'data-table-borders-right-size': { path: ['tableBorders', 'right', 'size'] },
224
+ 'data-table-borders-right-color': { path: ['tableBorders', 'right', 'color'] },
225
+ 'data-table-borders-insideh-style': { path: ['tableBorders', 'insideHorizontal', 'style'] },
226
+ 'data-table-borders-insideh-size': { path: ['tableBorders', 'insideHorizontal', 'size'] },
227
+ 'data-table-borders-insideh-color': { path: ['tableBorders', 'insideHorizontal', 'color'] },
228
+ 'data-table-borders-insidev-style': { path: ['tableBorders', 'insideVertical', 'style'] },
229
+ 'data-table-borders-insidev-size': { path: ['tableBorders', 'insideVertical', 'size'] },
230
+ 'data-table-borders-insidev-color': { path: ['tableBorders', 'insideVertical', 'color'] },
231
+
97
232
  'data-image-type': { path: ['imageType'] },
98
233
 
99
234
  'data-floating-horizontalposition-relative': {
@@ -52,3 +52,8 @@ export {
52
52
  // LaTeX strings that the adapter should pass through its body-text
53
53
  // escape pass unchanged. See src/latex/raw.js for rationale.
54
54
  export { markRawLatex, RAW_BEGIN, RAW_END } from './raw.js'
55
+
56
+ // LaTeX inset formatters — generic primitives that emit \cite{...} and
57
+ // \autoref{...} commands. Foundations supply the resolved keys; press
58
+ // supplies the emission shape + sentinel wrapping.
59
+ export { formatCite, formatAutoref } from './insets.js'
@@ -0,0 +1,83 @@
1
+ /**
2
+ * LaTeX inset formatters.
3
+ *
4
+ * Generic helpers that emit `\cite{...}` and `\autoref{...}` commands
5
+ * wrapped in raw-passthrough sentinels (see `./raw.js`). Foundations
6
+ * substitute the result into paragraph text via the inline-inset
7
+ * resolution pass; the LaTeX adapter's body-text escape pass leaves
8
+ * sentinel-bracketed segments unescaped.
9
+ *
10
+ * **Resolution stays foundation-side.** These helpers do not know how
11
+ * to look up bibliography records or xref entries — that's a foundation
12
+ * concern (book uses a per-website cite-registry; @uniweb/kit/xref's
13
+ * registry handles xref ids). The foundation passes already-resolved
14
+ * key arrays in. Press's job is the LaTeX emission shape:
15
+ *
16
+ * formatCite(['darwin1859', 'mendel1866'])
17
+ * -> '\cite{darwin1859,mendel1866}' (sentinel-wrapped)
18
+ *
19
+ * formatCite(['darwin1859'], { locator: '42' })
20
+ * -> '\cite[42]{darwin1859}' (sentinel-wrapped)
21
+ *
22
+ * formatCite(['darwin1859'], { suppressAuthor: true })
23
+ * -> '\citeyear{darwin1859}' (sentinel-wrapped)
24
+ *
25
+ * formatAutoref(['fig-doors', 'sec-method'])
26
+ * -> '\autoref{fig-doors}, \autoref{sec-method}' (sentinel-wrapped)
27
+ *
28
+ * Why these live in press: foundations doing document output already
29
+ * import `@uniweb/press`. The shape of these helpers — emit-LaTeX with
30
+ * sentinel wrapping — is exactly press's domain. Adding them here lets
31
+ * any future academic foundation (`@uniweb/scholar`, a thesis-only
32
+ * variant, an arXiv-flavored foundation) reuse the LaTeX emission
33
+ * vocabulary without re-deriving it.
34
+ */
35
+
36
+ import { markRawLatex } from './raw.js'
37
+
38
+ /**
39
+ * Emit a biblatex `\cite` (or `\citeyear`) command for one or more
40
+ * pre-resolved bibliography keys. Returns an empty string when the
41
+ * caller has filtered out every key (no resolved cites → no command).
42
+ *
43
+ * @param {string[]} keys - Resolved bibliography keys. Foundation has
44
+ * already filtered out any keys that don't appear in the website's
45
+ * bibliography (biblatex would fail on unknown keys at TeX-compile
46
+ * time; we drop them at substitution time so authors see clean
47
+ * output even with typos).
48
+ * @param {Object} [options]
49
+ * @param {string} [options.locator] - Page or location reference,
50
+ * becomes the optional `[N]` argument: `\cite[42]{key}`.
51
+ * @param {boolean} [options.suppressAuthor] - When true, emit
52
+ * `\citeyear` instead of `\cite` (author-date styles render only the
53
+ * year — useful for "(Darwin 1859)" → "1859" inline).
54
+ * @returns {string} Sentinel-wrapped LaTeX command, or '' when keys
55
+ * is empty.
56
+ */
57
+ export function formatCite(keys, options = {}) {
58
+ if (!Array.isArray(keys) || keys.length === 0) return ''
59
+ const cmd = options.suppressAuthor ? '\\citeyear' : '\\cite'
60
+ const list = keys.join(',')
61
+ const raw = options.locator
62
+ ? `${cmd}[${String(options.locator)}]{${list}}`
63
+ : `${cmd}{${list}}`
64
+ return markRawLatex(raw)
65
+ }
66
+
67
+ /**
68
+ * Emit one or more `\autoref{}` commands for pre-resolved xref ids.
69
+ * Multi-id clusters render as comma-separated autorefs because biblatex
70
+ * has no native multi-cite-style \autoref grouping; cleveref does (via
71
+ * \cref{a,b}) but adds a package dependency the v1 LaTeX template
72
+ * avoids.
73
+ *
74
+ * @param {string[]} ids - Resolved xref ids (foundation filtered out
75
+ * anything not present in the xref-registry).
76
+ * @returns {string} Sentinel-wrapped LaTeX command(s), or '' when ids
77
+ * is empty.
78
+ */
79
+ export function formatAutoref(ids) {
80
+ if (!Array.isArray(ids) || ids.length === 0) return ''
81
+ const refs = ids.map((id) => `\\autoref{${id}}`).join(', ')
82
+ return markRawLatex(refs)
83
+ }