@uniweb/press 0.4.4 → 0.4.6

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.4",
3
+ "version": "0.4.6",
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": {
@@ -9,6 +9,7 @@
9
9
  "./typst": "./src/typst/index.js",
10
10
  "./latex": "./src/latex/index.js",
11
11
  "./sections": "./src/sections/index.js",
12
+ "./format": "./src/format/index.jsx",
12
13
  "./ir": "./src/ir/index.js",
13
14
  "./vite-plugin-typst": "./src/vite-plugin-typst.js"
14
15
  },
@@ -55,6 +56,7 @@
55
56
  },
56
57
  "scripts": {
57
58
  "test": "vitest run",
58
- "test:watch": "vitest"
59
+ "test:watch": "vitest",
60
+ "test:invoice": "vitest run tests/integration/invoice-fixtures/build-fixtures.test.jsx --reporter verbose"
59
61
  }
60
62
  }
@@ -36,6 +36,7 @@
36
36
  import { createElement, useMemo } from 'react'
37
37
  import { DocumentContext } from './DocumentContext.js'
38
38
  import { BasePathContext } from './BasePathContext.js'
39
+ import { ThemeContext, DEFAULT_THEME } from './ThemeContext.js'
39
40
 
40
41
  /**
41
42
  * @typedef {Object} OutputEntry
@@ -76,6 +77,18 @@ export function createStore() {
76
77
  // refs, but the walker filters them out.
77
78
  const blockOrder = []
78
79
 
80
+ /**
81
+ * Inner Map keys by `${format}@${role}` so a single block can
82
+ * register a body fragment + a header fragment + a footer
83
+ * fragment for the same format without colliding. Without this
84
+ * splitting, register(block, 'docx', body) followed by
85
+ * register(block, 'docx', header, { role: 'header' }) would
86
+ * overwrite the body — the second call's role tag is invisible
87
+ * to a plain `format` key.
88
+ */
89
+ const keyFor = (format, options) =>
90
+ `${format}@${(options && options.role) || 'body'}`
91
+
79
92
  return {
80
93
  register(block, format, fragment, options = {}) {
81
94
  let formatMap = outputs.get(block)
@@ -84,17 +97,19 @@ export function createStore() {
84
97
  outputs.set(block, formatMap)
85
98
  blockOrder.push(block)
86
99
  }
87
- formatMap.set(format, { fragment, options })
100
+ formatMap.set(keyFor(format, options), { fragment, options })
88
101
  },
89
102
 
90
103
  getOutputs(format) {
91
104
  const result = []
105
+ const prefix = `${format}@`
92
106
  for (const block of blockOrder) {
93
107
  const formatMap = outputs.get(block)
94
108
  if (!formatMap) continue
95
- const entry = formatMap.get(format)
96
- if (entry) {
97
- result.push({ block, ...entry })
109
+ for (const [key, entry] of formatMap) {
110
+ if (key.startsWith(prefix)) {
111
+ result.push({ block, ...entry })
112
+ }
98
113
  }
99
114
  }
100
115
  return result
@@ -111,7 +126,29 @@ export function createStore() {
111
126
  }
112
127
  }
113
128
 
114
- export default function DocumentProvider({ children, basePath = '', store: externalStore }) {
129
+ /**
130
+ * Merge a partial theme into the defaults. Foundations only need to
131
+ * override the keys they care about; the rest fall back to DEFAULT_THEME.
132
+ */
133
+ function mergeTheme(theme) {
134
+ if (!theme) return DEFAULT_THEME
135
+ return {
136
+ colors: { ...DEFAULT_THEME.colors, ...(theme.colors || {}) },
137
+ fonts: { ...DEFAULT_THEME.fonts, ...(theme.fonts || {}) },
138
+ ...Object.fromEntries(
139
+ Object.entries(theme).filter(
140
+ ([k]) => k !== 'colors' && k !== 'fonts',
141
+ ),
142
+ ),
143
+ }
144
+ }
145
+
146
+ export default function DocumentProvider({
147
+ children,
148
+ basePath = '',
149
+ theme,
150
+ store: externalStore,
151
+ }) {
115
152
  // Prefer an externally-provided store when the caller wants to own
116
153
  // the aggregation (for off-screen / whole-site compiles). Otherwise
117
154
  // build one internally — the common case for live page rendering.
@@ -120,33 +157,34 @@ export default function DocumentProvider({ children, basePath = '', store: exter
120
157
  [externalStore],
121
158
  )
122
159
 
160
+ const resolvedBasePath = basePath || ''
161
+ const resolvedTheme = useMemo(() => mergeTheme(theme), [theme])
162
+
123
163
  // Expose the provider's full context stack as a closure so the
124
164
  // compile pipeline can re-wrap registered fragments with the same
125
165
  // contexts they rendered under — necessary because registrations
126
166
  // capture JSX element trees, not rendered output, and a later
127
- // renderToStaticMarkup(fragment) starts outside any React tree
128
- // (builders would otherwise fall back to context defaults and
129
- // emit, for example, un-prefixed URLs).
167
+ // renderToStaticMarkup(fragment) starts outside any React tree.
130
168
  //
131
- // Reassigned on every render so prop changes (basePath and any
132
- // future context values) flow through even though the store's
133
- // object identity is memoised.
134
- //
135
- // When adding a new cross-cutting context that builders consume
136
- // (theme, locale, author, …), wrap it here — the compile pipeline
137
- // does not need to change.
138
- const resolvedBasePath = basePath || ''
169
+ // When adding a new cross-cutting context that builders consume,
170
+ // wrap it here — the compile pipeline does not need to change.
139
171
  store.wrapWithProviders = (children) =>
140
172
  createElement(
141
173
  BasePathContext.Provider,
142
174
  { value: resolvedBasePath },
143
- children,
175
+ createElement(
176
+ ThemeContext.Provider,
177
+ { value: resolvedTheme },
178
+ children,
179
+ ),
144
180
  )
145
181
 
146
182
  return (
147
183
  <DocumentContext.Provider value={store}>
148
184
  <BasePathContext.Provider value={resolvedBasePath}>
149
- {children}
185
+ <ThemeContext.Provider value={resolvedTheme}>
186
+ {children}
187
+ </ThemeContext.Provider>
150
188
  </BasePathContext.Provider>
151
189
  </DocumentContext.Provider>
152
190
  )
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Document theme — colors, fonts, and other cross-cutting brand tokens
3
+ * that builders should consult when foundations don't pass explicit
4
+ * literal values.
5
+ *
6
+ * Stage 5 of kb/framework/plans/press-professional-docx.md.
7
+ *
8
+ * The shape is intentionally small and forgiving: a foundation passes
9
+ * whatever subset of keys it cares about, and builders fall back to
10
+ * sensible defaults when a key is absent. The DEFAULT_THEME below
11
+ * documents the full surface; foundations may override any subset.
12
+ *
13
+ * Resolution timing: builders consume the theme during React render
14
+ * and emit literal hex/font values into data-* attributes. The IR
15
+ * walker and the docx adapter never see theme keys — by the time IR
16
+ * exists, the substitution has already happened. This keeps the
17
+ * adapter ignorant of theming and means foundations can pre-render
18
+ * outside a provider for testing without crashing on missing keys.
19
+ *
20
+ * `wrapWithProviders` re-applies the theme during the compile pass
21
+ * (renderToStaticMarkup outside the original React tree), so the
22
+ * fragment registered live and the fragment compiled later resolve
23
+ * to the same values.
24
+ */
25
+ import { createContext, useContext } from 'react'
26
+
27
+ /**
28
+ * The full shape, with documented defaults. Foundations may override
29
+ * any subset.
30
+ *
31
+ * Color values are hex strings without the leading `#`. Foundations
32
+ * that pass a value with `#` get it stripped on resolve so authors
33
+ * don't have to remember which form.
34
+ */
35
+ export const DEFAULT_THEME = {
36
+ colors: {
37
+ // Primary brand accent — used for titles, header rows, and
38
+ // emphasis. Foundations should override this.
39
+ accent: '0B5394',
40
+ // Body text color.
41
+ body: '3B3B3B',
42
+ // Secondary / muted text (helper labels, footers).
43
+ muted: '757575',
44
+ // Soft border / grid color (table grid, dividers).
45
+ softBorder: 'BFD3ED',
46
+ // Stage 6+: surface, surfaceAlt for tinted backgrounds.
47
+ surface: 'FFFFFF',
48
+ surfaceAlt: 'F5F7FB',
49
+ },
50
+ fonts: {
51
+ // Default body font.
52
+ body: 'Calibri',
53
+ // Headings / titles. Match body to keep things simple.
54
+ heading: 'Calibri',
55
+ // Monospace (code, fixed-width data). Stage 6+.
56
+ mono: 'Consolas',
57
+ },
58
+ /**
59
+ * Default locale for date / currency formatting. Foundations
60
+ * override per-document via `website.config.business_docs.theme.locale`.
61
+ * Builders that read the theme (`<DateText>`, `<DateRangeText>`,
62
+ * `<Currency>`) fall back to this value when no explicit `locale`
63
+ * prop is given. Format strings follow the BCP-47 convention.
64
+ */
65
+ locale: 'en-CA',
66
+ /**
67
+ * Default ISO 4217 currency code for `<Currency>` when no explicit
68
+ * `code` is set on the builder.
69
+ */
70
+ currency: 'CAD',
71
+ /**
72
+ * Typography roles. Each entry is a small `{ font, size, bold,
73
+ * italics, color, smallCaps, allCaps, strike, paragraph }` shape;
74
+ * `font` and `color` may be theme keys ('body', 'heading', 'accent',
75
+ * …) which resolve at compile time. `size` is in half-points
76
+ * (use the convertPointsToHalfPoints helper / `pt(n)` wrapper).
77
+ *
78
+ * Press synthesises OOXML paragraph styles + character styles from
79
+ * this registry on docx compile. Builders consume roles via
80
+ * `<Paragraph role="Title">` / `<TextRun role="Label">`; the docx
81
+ * adapter emits style references (<w:pStyle>/<w:rStyle>) instead
82
+ * of inline run properties, so users can edit fonts/colors/sizes
83
+ * from Word's Styles pane without find-and-replace formatting.
84
+ *
85
+ * Roles split into two natural buckets:
86
+ * - block-level (paragraph): set both paragraph and run properties,
87
+ * applied via `<Paragraph role="…">`. Use for whole-paragraph
88
+ * constructs like Title, Heading1-3, Body, Display.
89
+ * - inline (character): set run properties only, applied via
90
+ * `<TextRun role="…">`. Use for inline emphasis like Label,
91
+ * Caption, BodyStrong.
92
+ *
93
+ * Foundations override individual entries by passing
94
+ * `theme.typography.<RoleName>` partial — anything not specified
95
+ * inherits the default.
96
+ */
97
+ typography: {
98
+ // ---- Block-level roles (paragraph) -----------------------------
99
+ Title: {
100
+ font: 'heading',
101
+ size: 56,
102
+ bold: true,
103
+ color: 'accent',
104
+ paragraph: { spacing: { after: 240 } },
105
+ },
106
+ Heading1: {
107
+ font: 'heading',
108
+ size: 32,
109
+ bold: true,
110
+ color: 'body',
111
+ paragraph: { spacing: { before: 240, after: 120 } },
112
+ },
113
+ Heading2: {
114
+ font: 'heading',
115
+ size: 26,
116
+ bold: true,
117
+ color: 'body',
118
+ paragraph: { spacing: { before: 200, after: 100 } },
119
+ },
120
+ Heading3: {
121
+ font: 'heading',
122
+ size: 22,
123
+ bold: true,
124
+ color: 'body',
125
+ paragraph: { spacing: { before: 160, after: 80 } },
126
+ },
127
+ Body: {
128
+ font: 'body',
129
+ size: 22,
130
+ color: 'body',
131
+ paragraph: { spacing: { line: 276 } }, // 1.15 line height
132
+ },
133
+ Display: {
134
+ font: 'body',
135
+ size: 28,
136
+ bold: true,
137
+ color: 'body',
138
+ },
139
+
140
+ // ---- Inline roles (character) ----------------------------------
141
+ BodyStrong: { font: 'body', size: 22, bold: true, color: 'body' },
142
+ Label: {
143
+ font: 'body',
144
+ size: 18,
145
+ bold: true,
146
+ color: 'muted',
147
+ allCaps: true,
148
+ },
149
+ Caption: { font: 'body', size: 18, color: 'muted' },
150
+ TableHeader: {
151
+ font: 'heading',
152
+ size: 20,
153
+ bold: true,
154
+ color: 'surface',
155
+ },
156
+ TotalLine: {
157
+ font: 'heading',
158
+ size: 26,
159
+ bold: true,
160
+ color: 'surface',
161
+ },
162
+ },
163
+ /**
164
+ * Roles whose declaration should land in the OOXML paragraphStyles
165
+ * bucket vs the characterStyles bucket. Block roles cover both
166
+ * paragraph and run properties; inline roles cover only run
167
+ * properties. Foundations adding new roles should classify them
168
+ * here; unclassified roles default to character-style (run-only).
169
+ */
170
+ typographyKinds: {
171
+ Title: 'paragraph',
172
+ Heading1: 'paragraph',
173
+ Heading2: 'paragraph',
174
+ Heading3: 'paragraph',
175
+ Body: 'paragraph',
176
+ Display: 'paragraph',
177
+ BodyStrong: 'character',
178
+ Label: 'character',
179
+ Caption: 'character',
180
+ TableHeader: 'character',
181
+ TotalLine: 'character',
182
+ },
183
+ }
184
+
185
+ export const ThemeContext = createContext(DEFAULT_THEME)
186
+
187
+ /**
188
+ * Look up the active theme. Builders that need theme values call this
189
+ * at render time. Outside a DocumentProvider, returns DEFAULT_THEME.
190
+ */
191
+ export function useDocumentTheme() {
192
+ return useContext(ThemeContext) || DEFAULT_THEME
193
+ }
194
+
195
+ /**
196
+ * Resolve a color prop value against the theme.
197
+ *
198
+ * Accepts:
199
+ * - A theme key matching `theme.colors.<key>` (e.g. 'accent', 'body',
200
+ * 'muted', 'softBorder') — returned as the resolved hex.
201
+ * - A hex literal with or without leading '#' — returned without '#'.
202
+ * - Any other string — returned verbatim (the docx layer falls back
203
+ * to library defaults if it doesn't recognize the value).
204
+ *
205
+ * Returns undefined for nullish input so builders can chain
206
+ * `if (resolved) defaults['data-color'] = resolved` cleanly.
207
+ */
208
+ export function resolveThemeColor(value, theme = DEFAULT_THEME) {
209
+ if (!value) return undefined
210
+ if (typeof value !== 'string') return value
211
+ const colors = theme && theme.colors ? theme.colors : DEFAULT_THEME.colors
212
+ if (Object.prototype.hasOwnProperty.call(colors, value)) {
213
+ return stripHash(colors[value])
214
+ }
215
+ return stripHash(value)
216
+ }
217
+
218
+ /**
219
+ * Resolve a font-family prop against the theme.
220
+ *
221
+ * Accepts:
222
+ * - A theme key matching `theme.fonts.<key>` (e.g. 'body', 'heading').
223
+ * - A literal family name — returned verbatim.
224
+ */
225
+ export function resolveThemeFont(value, theme = DEFAULT_THEME) {
226
+ if (!value) return undefined
227
+ if (typeof value !== 'string') return value
228
+ const fonts = theme && theme.fonts ? theme.fonts : DEFAULT_THEME.fonts
229
+ if (Object.prototype.hasOwnProperty.call(fonts, value)) {
230
+ return fonts[value]
231
+ }
232
+ return value
233
+ }
234
+
235
+ function stripHash(hex) {
236
+ return typeof hex === 'string' && hex.startsWith('#') ? hex.slice(1) : hex
237
+ }