sdocs-dev 1.6.2 → 1.13.0

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.
@@ -0,0 +1,389 @@
1
+ // sdocs-cells.js - pure grid data model for ```cells fenced blocks.
2
+ //
3
+ // This is the BACKBONE of the cells/sheets feature. It is deliberately
4
+ // just the model: parse a block body into an addressable grid of cells.
5
+ // Every renderer (the inline grid, the future fullscreen sheet, the
6
+ // future editor) and the exporter read FROM this model; none of them is
7
+ // the source of truth. The DOM is always a view, never the data.
8
+ //
9
+ // A cell is two values from day one:
10
+ // { raw, value, type }
11
+ // raw - exactly what the source held (a literal today, a formula
12
+ // string like "=B2-B3" tomorrow). Never rewritten.
13
+ // value - the computed display value. In v1 (no formula engine) this
14
+ // equals the parsed literal. When formulas land, only the
15
+ // compute step changes; raw already carries what it needs.
16
+ // type - 'number' | 'text' | 'empty', drives alignment + display.
17
+ //
18
+ // The block body is CSV. That is on purpose: inline raw data and a future
19
+ // {{file.csv}} transclusion then share one parser and one model, so those
20
+ // two features are one code path rather than two with a seam between them.
21
+ //
22
+ // UMD so Node tests can require it directly; no DOM, no window.SDocs here.
23
+ (function (exports) {
24
+ 'use strict';
25
+
26
+ // Bijective base-26 spreadsheet column name. 0 -> A, 25 -> Z, 26 -> AA,
27
+ // 701 -> ZZ, 702 -> AAA. Matches how every spreadsheet labels columns.
28
+ function colName(index) {
29
+ var i = index + 1;
30
+ var s = '';
31
+ while (i > 0) {
32
+ var rem = (i - 1) % 26;
33
+ s = String.fromCharCode(65 + rem) + s;
34
+ i = Math.floor((i - 1) / 26);
35
+ }
36
+ return s;
37
+ }
38
+
39
+ // Inverse of colName: a column letter -> 0-based index ("A" -> 0, "AA" -> 26).
40
+ // Returns -1 for anything that isn't a run of A-Z letters.
41
+ function colIndex(letter) {
42
+ var s = String(letter).toUpperCase();
43
+ var n = 0;
44
+ for (var i = 0; i < s.length; i++) {
45
+ var c = s.charCodeAt(i) - 64; // A -> 1
46
+ if (c < 1 || c > 26) return -1;
47
+ n = n * 26 + c;
48
+ }
49
+ return s.length ? n - 1 : -1;
50
+ }
51
+
52
+ // Strict literal number: optional sign, digits, optional single decimal.
53
+ // No thousands separators in v1 - a bare "1,000" is two CSV fields, and a
54
+ // quoted "1,000" stays text rather than guessing the user's locale.
55
+ var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
56
+
57
+ // Classify a raw field string into a cell record. The trimmed text drives
58
+ // type detection; `raw` is preserved verbatim for display and round-trip.
59
+ function classify(raw) {
60
+ var t = String(raw).trim();
61
+ if (t === '') return { raw: raw, value: '', type: 'empty' };
62
+ if (NUMBER_RE.test(t)) return { raw: raw, value: Number(t), type: 'number' };
63
+ return { raw: raw, value: t, type: 'text' };
64
+ }
65
+
66
+ // Parse a CSV string into an array of row arrays of field strings.
67
+ // Handles quoted fields with embedded commas / newlines and "" escapes.
68
+ // Lenient by design: there is no such thing as a malformed sheet, only a
69
+ // ragged one (which parseCells pads).
70
+ function parseCsv(src) {
71
+ var rows = [];
72
+ var row = [];
73
+ var field = '';
74
+ var inQuotes = false;
75
+ var i = 0;
76
+ var n = src.length;
77
+ while (i < n) {
78
+ var ch = src.charAt(i);
79
+ if (inQuotes) {
80
+ if (ch === '"') {
81
+ if (src.charAt(i + 1) === '"') { field += '"'; i += 2; continue; }
82
+ inQuotes = false; i++; continue;
83
+ }
84
+ field += ch; i++; continue;
85
+ }
86
+ if (ch === '"') { inQuotes = true; i++; continue; }
87
+ if (ch === ',') { row.push(field); field = ''; i++; continue; }
88
+ if (ch === '\r') { i++; continue; }
89
+ if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
90
+ field += ch; i++;
91
+ }
92
+ row.push(field);
93
+ rows.push(row);
94
+ return rows;
95
+ }
96
+
97
+ // A bare {{path/to/file.csv}} reference (optionally with a :range suffix).
98
+ // Present only when a doc is opened WITHOUT the CLI (which otherwise bakes
99
+ // the data in); the renderer shows a "load it with sdoc" message.
100
+ var REFERENCE_RE = /^\{\{\s*([^}]+?)\s*\}\}$/;
101
+ // The machine-generated metadata line the CLI prepends to a baked block:
102
+ // sdoc-cells: source=report.csv range=B5:J32
103
+ // sdoc-cells: error="Could not read report.csv"
104
+ var DIRECTIVE_RE = /^sdoc-cells:\s*(.*)$/;
105
+
106
+ // Parse `key=value` / `key="quoted value"` pairs from a directive line.
107
+ function parseDirectives(str) {
108
+ var meta = {};
109
+ var re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
110
+ var m;
111
+ while ((m = re.exec(str))) meta[m[1]] = m[2] !== undefined ? m[2] : m[3];
112
+ return meta;
113
+ }
114
+
115
+ // Build the grid model from a ```cells block body.
116
+ // Returns { rows, cols, cells, empty } where cells is row-major, every row
117
+ // padded to `cols` with empty cells so the grid is rectangular. May instead
118
+ // return { unresolved: <ref> } or { error: <msg> } for reference blocks.
119
+ function parseCells(src) {
120
+ var text = String(src == null ? '' : src);
121
+ // Drop leading blank lines and all trailing whitespace (incl. the final
122
+ // newline marked emits) but keep interior blank rows - a blank row is
123
+ // meaningful in a sheet.
124
+ var trimmed = text.replace(/^\n+/, '').replace(/\s+$/, '');
125
+ if (trimmed === '') return { rows: 0, cols: 0, cells: [], empty: true };
126
+
127
+ // Peel leading directive lines (in any order, machine or author):
128
+ // sdoc-cells: source=... range=... error=... name=... (baked metadata)
129
+ // format: A=$ B=% C=plain (author column formats)
130
+ // `name` is the tab name. Authored as the fence info string (```cells
131
+ // Sales); the renderer + CLI normalise that into this directive so the
132
+ // name has one home in the model.
133
+ var source, range, formats, name;
134
+ var lines = trimmed.split('\n');
135
+ var idx = 0;
136
+ var FORMAT_RE = /^format:\s*(.*)$/i;
137
+ while (idx < lines.length) {
138
+ var dm = lines[idx].match(DIRECTIVE_RE);
139
+ var fm = lines[idx].match(FORMAT_RE);
140
+ if (dm) {
141
+ var meta = parseDirectives(dm[1]);
142
+ source = meta.source; range = meta.range;
143
+ if (meta.name != null) name = meta.name;
144
+ if (meta.error) return { empty: false, error: meta.error, source: source, name: name };
145
+ idx++; continue;
146
+ }
147
+ if (fm) { formats = parseFormats(fm[1]); idx++; continue; }
148
+ break;
149
+ }
150
+ var body = lines.slice(idx).join('\n').replace(/\s+$/, '');
151
+
152
+ // Unresolved reference (after directives): the CLI never baked it.
153
+ var ref = body.match(REFERENCE_RE);
154
+ if (ref) return { empty: false, unresolved: ref[1], formats: formats, name: name };
155
+ if (body === '') return { rows: 0, cols: 0, cells: [], empty: true, source: source, range: range, formats: formats, name: name };
156
+
157
+ var raw = parseCsv(body);
158
+ var cols = 0;
159
+ for (var r = 0; r < raw.length; r++) {
160
+ if (raw[r].length > cols) cols = raw[r].length;
161
+ }
162
+ var cells = [];
163
+ for (var r2 = 0; r2 < raw.length; r2++) {
164
+ var line = raw[r2];
165
+ var out = [];
166
+ for (var c = 0; c < cols; c++) {
167
+ out.push(classify(c < line.length ? line[c] : ''));
168
+ }
169
+ cells.push(out);
170
+ }
171
+ return { rows: raw.length, cols: cols, cells: cells, empty: false, source: source, range: range, formats: formats, name: name };
172
+ }
173
+
174
+ // Serialize a 2D array of raw cell strings back to CSV (RFC 4180 quoting:
175
+ // wrap in quotes and double any embedded quote when a field contains a
176
+ // comma, quote, or newline). The inverse of parseCsv for the copy actions.
177
+ function serializeCsv(rows) {
178
+ return rows.map(function (row) {
179
+ return row.map(function (v) {
180
+ var s = String(v == null ? '' : v);
181
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
182
+ }).join(',');
183
+ }).join('\n');
184
+ }
185
+
186
+ // Stats for a selected rectangle [r0..r1] x [c0..c1] of a model. Numbers
187
+ // drive sum/avg/min/max; count is every non-empty cell (Excel's "Count").
188
+ // Cells past the data (fullscreen padding) read as empty. fx (optional) is
189
+ // a recalc results grid aligned to this model's rows: a formula cell then
190
+ // contributes its computed value (errors count as non-empty, not numeric).
191
+ function selectionStats(model, r0, c0, r1, c1, fx) {
192
+ var count = 0, numericCount = 0, sum = 0, min = null, max = null;
193
+ for (var r = r0; r <= r1; r++) {
194
+ var line = model.cells[r];
195
+ for (var c = c0; c <= c1; c++) {
196
+ var cell = line && line[c];
197
+ if (!cell || cell.type === 'empty') continue;
198
+ count++;
199
+ var isNum = cell.type === 'number';
200
+ var v = cell.value;
201
+ // A formula cell's raw is "=..." text; its computed result decides.
202
+ if (fx && cell.raw && cell.raw.charAt(0) === '=') {
203
+ var fxCell = fx[r] && fx[r][c];
204
+ isNum = !!fxCell && fxCell.kind === 'number';
205
+ if (isNum) v = fxCell.value;
206
+ }
207
+ if (isNum) {
208
+ numericCount++;
209
+ sum += v;
210
+ if (min === null || v < min) min = v;
211
+ if (max === null || v > max) max = v;
212
+ }
213
+ }
214
+ }
215
+ return {
216
+ count: count,
217
+ numericCount: numericCount,
218
+ sum: sum,
219
+ avg: numericCount > 0 ? sum / numericCount : null,
220
+ min: min,
221
+ max: max,
222
+ };
223
+ }
224
+
225
+ // Heuristic: is row 0 a header? True when row 0 has no numbers and there is
226
+ // numeric data below it - so a sort can keep it pinned to the top.
227
+ function looksLikeHeader(model) {
228
+ if (!model || model.rows < 2) return false;
229
+ var row0 = model.cells[0];
230
+ for (var c = 0; c < model.cols; c++) {
231
+ if (row0[c] && row0[c].type === 'number') return false;
232
+ }
233
+ for (var r = 1; r < model.rows; r++) {
234
+ var line = model.cells[r];
235
+ for (var c2 = 0; c2 < model.cols; c2++) {
236
+ if (line[c2] && line[c2].type === 'number') return true;
237
+ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ // Sort key: numbers (rank 0) sort before text (rank 1); empty (rank 2) last.
243
+ // fxCell, when given, is the cell's computed formula result ({kind, value} /
244
+ // {kind: 'error', code}) and takes precedence: a formula sorts by the value
245
+ // the user sees, not by its "=..." source text. Errors sort with text.
246
+ function sortKey(cell, fxCell) {
247
+ if (fxCell && fxCell.kind === 'number') return { rank: 0, v: fxCell.value };
248
+ if (fxCell && fxCell.kind === 'error') return { rank: 1, v: String(fxCell.code || '').toLowerCase() };
249
+ if (!cell || cell.type === 'empty') return { rank: 2, v: 0 };
250
+ if (cell.type === 'number') return { rank: 0, v: cell.value };
251
+ return { rank: 1, v: String(cell.value).toLowerCase() };
252
+ }
253
+
254
+ // A summary row aggregates other rows: it holds at least one formula with a
255
+ // range spanning 2+ rows, e.g. =SUM(D2:D5). Per-row formulas (=B2*C2) refer
256
+ // to single cells and do not count. Used by sortRows to keep a trailing
257
+ // "Total" row pinned at the bottom instead of jumbling it into the data.
258
+ var ROW_RANGE_RE = /[A-Za-z]+([0-9]+)\s*:\s*[A-Za-z]+([0-9]+)/;
259
+ function isSummaryRow(row) {
260
+ if (!row) return false;
261
+ for (var i = 0; i < row.length; i++) {
262
+ var raw = row[i] && row[i].raw;
263
+ if (!raw || raw.charAt(0) !== '=') continue;
264
+ var m = ROW_RANGE_RE.exec(raw);
265
+ if (m && m[1] !== m[2]) return true; // the range spans rows
266
+ }
267
+ return false;
268
+ }
269
+
270
+ // Return the row order (array of original indices) sorting the model by a
271
+ // column. A view reorder - the model itself is not changed. Empty cells stay
272
+ // last either direction; a header row (when hasHeader) stays pinned to row 0;
273
+ // trailing summary rows (see isSummaryRow) stay pinned at the bottom.
274
+ // fx (optional) is a recalc results grid indexed by SOURCE row: formula
275
+ // cells then sort by their computed value instead of their source text.
276
+ function sortRows(model, col, dir, hasHeader, fx) {
277
+ var order = [];
278
+ for (var r = 0; r < model.rows; r++) order.push(r);
279
+ var start = hasHeader ? 1 : 0;
280
+ // Trailing summary rows (a Total / Average footer) sit outside the sort,
281
+ // in their original order.
282
+ var end = model.rows;
283
+ while (end > start && isSummaryRow(model.cells[end - 1])) end--;
284
+ var head = order.slice(0, start);
285
+ var tail = order.slice(end);
286
+ var body = order.slice(start, end);
287
+ var sign = dir === 'desc' ? -1 : 1;
288
+ function fxFor(r, cell) {
289
+ if (!fx || !cell || !cell.raw || cell.raw.charAt(0) !== '=') return null;
290
+ return (fx[r] && fx[r][col]) || null;
291
+ }
292
+ body.sort(function (ra, rb) {
293
+ var ca = model.cells[ra] && model.cells[ra][col];
294
+ var cb = model.cells[rb] && model.cells[rb][col];
295
+ var a = sortKey(ca, fxFor(ra, ca));
296
+ var b = sortKey(cb, fxFor(rb, cb));
297
+ if (a.rank === 2 || b.rank === 2) return a.rank - b.rank; // empty always last
298
+ if (a.rank !== b.rank) return (a.rank - b.rank) * sign;
299
+ if (a.v < b.v) return -1 * sign;
300
+ if (a.v > b.v) return 1 * sign;
301
+ return 0;
302
+ });
303
+ return head.concat(body).concat(tail);
304
+ }
305
+
306
+ // Display formatting for a numeric raw string: group the integer part with
307
+ // thousands separators, preserve the sign and the decimal part verbatim
308
+ // (so "1234.50" keeps its trailing zero). Display only - the model's raw is
309
+ // untouched, so copy / export still emit the original value.
310
+ function formatNumber(raw) {
311
+ var s = String(raw == null ? '' : raw).trim();
312
+ var neg = s.charAt(0) === '-';
313
+ if (neg) s = s.slice(1);
314
+ var dot = s.indexOf('.');
315
+ var intPart = dot === -1 ? s : s.slice(0, dot);
316
+ var rest = dot === -1 ? '' : s.slice(dot);
317
+ intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
318
+ return (neg ? '-' : '') + intPart + rest;
319
+ }
320
+
321
+ // Parse one format token into a spec: { kind, symbol?, decimals? }.
322
+ // Tokens: $ / usd / currency, £ / gbp, € / eur, % / percent, , / comma /
323
+ // number, plain / text / raw, plus an optional trailing .N for decimals
324
+ // (e.g. "$.0", "%.1", ".2"). Unknown tokens return null.
325
+ function parseFmtToken(tok) {
326
+ var t = String(tok).trim();
327
+ var decimals = null;
328
+ var dm = t.match(/\.(\d+)$/);
329
+ if (dm) { decimals = parseInt(dm[1], 10); t = t.slice(0, t.length - dm[0].length); }
330
+ var lc = t.toLowerCase();
331
+ if (t === '$' || lc === 'usd' || lc === 'currency') return { kind: 'currency', symbol: '$', decimals: decimals == null ? 2 : decimals };
332
+ if (t === '£' || lc === 'gbp') return { kind: 'currency', symbol: '£', decimals: decimals == null ? 2 : decimals };
333
+ if (t === '€' || lc === 'eur') return { kind: 'currency', symbol: '€', decimals: decimals == null ? 2 : decimals };
334
+ if (t === '%' || lc === 'percent') { var p = { kind: 'percent' }; if (decimals != null) p.decimals = decimals; return p; }
335
+ if (t === ',' || lc === 'comma' || lc === 'number' || lc === 'num') { var nn = { kind: 'number' }; if (decimals != null) nn.decimals = decimals; return nn; }
336
+ if (lc === 'plain' || lc === 'text' || lc === 'raw') return { kind: 'plain' };
337
+ if (t === '' && decimals != null) return { kind: 'number', decimals: decimals };
338
+ return null;
339
+ }
340
+
341
+ // Parse a per-column format spec like "A=plain B=$ C=%.1" into a map
342
+ // { colIndex: fmt }. Keys are column letters; unknown tokens are skipped.
343
+ function parseFormats(spec) {
344
+ var out = {};
345
+ var re = /([A-Za-z]+)\s*=\s*(\S+)/g;
346
+ var m;
347
+ while ((m = re.exec(spec))) {
348
+ var col = colIndex(m[1]);
349
+ var fmt = parseFmtToken(m[2]);
350
+ if (col >= 0 && fmt) out[col] = fmt;
351
+ }
352
+ return out;
353
+ }
354
+
355
+ // Format a numeric cell's display per a column format. Returns null for
356
+ // non-number cells (the caller falls back to text rendering). Display only -
357
+ // the model's raw is untouched, so copy / export emit the original.
358
+ function formatValue(cell, fmt) {
359
+ if (!cell || cell.type !== 'number') return null;
360
+ var v = cell.value;
361
+ if (!fmt || fmt.kind === 'number') {
362
+ return (fmt && fmt.decimals != null) ? formatNumber(v.toFixed(fmt.decimals)) : formatNumber(cell.raw);
363
+ }
364
+ if (fmt.kind === 'plain') return cell.raw;
365
+ if (fmt.kind === 'currency') {
366
+ var d = fmt.decimals == null ? 2 : fmt.decimals;
367
+ return (v < 0 ? '-' : '') + fmt.symbol + formatNumber(Math.abs(v).toFixed(d));
368
+ }
369
+ if (fmt.kind === 'percent') {
370
+ var p = v * 100;
371
+ var str = fmt.decimals != null ? p.toFixed(fmt.decimals) : String(Math.round(p * 1e6) / 1e6);
372
+ return formatNumber(str) + '%';
373
+ }
374
+ return formatNumber(cell.raw);
375
+ }
376
+
377
+ exports.colName = colName;
378
+ exports.classify = classify;
379
+ exports.parseCsv = parseCsv;
380
+ exports.parseCells = parseCells;
381
+ exports.serializeCsv = serializeCsv;
382
+ exports.selectionStats = selectionStats;
383
+ exports.formatNumber = formatNumber;
384
+ exports.colIndex = colIndex;
385
+ exports.parseFormats = parseFormats;
386
+ exports.formatValue = formatValue;
387
+ exports.looksLikeHeader = looksLikeHeader;
388
+ exports.sortRows = sortRows;
389
+ })(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocCells = {}));
@@ -0,0 +1,196 @@
1
+ // sdocs-contrast.js - WCAG contrast analysis for custom-styled documents.
2
+ //
3
+ // Why this exists: an agent that hand-picks colours can easily produce an
4
+ // unreadable pair - dark text on a dark background, a navy heading on a near
5
+ // black page - without noticing, especially when it tuned the colours while
6
+ // viewing one theme. This module resolves the effective palette for BOTH the
7
+ // light and dark themes (mirroring how the browser applies front-matter
8
+ // styles) and grades every text-on-background pair against WCAG ratios, so
9
+ // `sdoc color-analysis` can warn before the document ships.
10
+ //
11
+ // Pure: no I/O, no third-party deps. Shared between the CLI and tests (and
12
+ // available to the browser via window.SDocContrast).
13
+ (function (exports) {
14
+ 'use strict';
15
+
16
+ var SDocStyles = (typeof module !== 'undefined' && module.exports)
17
+ ? require('./sdocs-styles.js')
18
+ : (typeof window !== 'undefined' ? window.SDocStyles : null);
19
+
20
+ // Light-theme defaults for colours the document didn't override. Mirrors
21
+ // the LIGHT_DEFAULTS / DARK_DEFAULTS tables in sdocs-theme.js. Headings
22
+ // default to the body text colour (the colour cascade root).
23
+ var LIGHT_DEFAULTS = {
24
+ bg: '#ffffff', text: '#1c1917', link: '#2563eb',
25
+ blockBg: '#f4f1ed', blockText: '#6b6560',
26
+ codeBg: '#f4f1ed', codeText: '#6b21a8',
27
+ bqBg: '#f7f5f2', bqText: '#6b6560'
28
+ };
29
+ var DARK_DEFAULTS = {
30
+ bg: '#2c2a26', text: '#e7e5e2', link: '#60a5fa',
31
+ blockBg: '#1a1816', blockText: '#a8a29e',
32
+ codeBg: '#1a1816', codeText: '#b8a99a',
33
+ bqBg: '#252320', bqText: '#a8a29e'
34
+ };
35
+
36
+ // ── WCAG maths ────────────────────────────────────────
37
+ function hexToRgb(hex) {
38
+ if (typeof hex !== 'string') return null;
39
+ var h = hex.trim().replace(/^#/, '');
40
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
41
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
42
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
43
+ }
44
+
45
+ function channelLin(c) {
46
+ var s = c / 255;
47
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
48
+ }
49
+
50
+ function relativeLuminance(hex) {
51
+ var rgb = hexToRgb(hex);
52
+ if (!rgb) return null;
53
+ return 0.2126 * channelLin(rgb.r) + 0.7152 * channelLin(rgb.g) + 0.0722 * channelLin(rgb.b);
54
+ }
55
+
56
+ // WCAG contrast ratio, 1..21. Returns null for unparseable input.
57
+ function contrastRatio(a, b) {
58
+ var la = relativeLuminance(a), lb = relativeLuminance(b);
59
+ if (la == null || lb == null) return null;
60
+ var hi = Math.max(la, lb), lo = Math.min(la, lb);
61
+ return (hi + 0.05) / (lo + 0.05);
62
+ }
63
+
64
+ // Fail line. Calibrated against human review rather than the strict
65
+ // WCAG-AA body bar (4.5:1): pairs in the ~3.2-4.4 range read fine on a
66
+ // normal screen, while everything that actually caused unreadable docs
67
+ // sat well below 3:1. Set as a single constant so it's easy to retune.
68
+ // (WCAG bands are still reported via `level` for anyone who wants them.)
69
+ var MIN_CONTRAST = 3.0;
70
+
71
+ // Grade a ratio against MIN_CONTRAST. `large` is kept for callers that
72
+ // want to annotate heading vs body, but the pass/fail line is uniform.
73
+ // level: 'fail' | 'aa-large' | 'aa' | 'aaa' (informational WCAG bands)
74
+ // ok: ratio meets the fail line
75
+ function grade(ratio, large) {
76
+ var level;
77
+ if (ratio == null) level = 'unknown';
78
+ else if (ratio >= 7) level = 'aaa';
79
+ else if (ratio >= 4.5) level = 'aa';
80
+ else if (ratio >= 3) level = 'aa-large';
81
+ else level = 'fail';
82
+ return {
83
+ ratio: ratio == null ? null : Math.round(ratio * 100) / 100,
84
+ level: level,
85
+ need: MIN_CONTRAST,
86
+ ok: ratio != null && ratio >= MIN_CONTRAST
87
+ };
88
+ }
89
+
90
+ // ── Palette resolution ────────────────────────────────
91
+ // Resolve a single colour for both themes. `explicit` is the front-matter
92
+ // value (or null/undefined). The dark value mirrors applyStylesFromMeta:
93
+ // an explicit dark override wins, else an explicit light value is inverted,
94
+ // else the theme default applies.
95
+ function resolve(explicit, ctrlId, darkBlock, lightDefault, darkDefault) {
96
+ var light = explicit || lightDefault;
97
+ var dark;
98
+ if (darkBlock && darkBlock[ctrlId]) dark = darkBlock[ctrlId];
99
+ else if (explicit && SDocStyles && SDocStyles.invertLightness) {
100
+ dark = SDocStyles.invertLightness(explicit, SDocStyles.colorControlRole
101
+ ? SDocStyles.colorControlRole(ctrlId) : undefined);
102
+ } else dark = darkDefault;
103
+ return { light: light, dark: dark };
104
+ }
105
+
106
+ // Resolve the full set of text-on-background pairs for a parsed `styles`
107
+ // object. Returns { light: [pairs], dark: [pairs] } where each pair is
108
+ // { label, surface, fg, bg, large }.
109
+ function resolvePairs(styles) {
110
+ styles = styles || {};
111
+ var darkBlock = (SDocStyles && SDocStyles.parseDarkBlock) ? SDocStyles.parseDarkBlock(styles.dark) : {};
112
+ var headers = styles.headers || {};
113
+ var h = function (n) { return styles['h' + n] || {}; };
114
+
115
+ // Page background and the colours that sit on it.
116
+ var bg = resolve(styles.background, '_sd_ctrl-bg-color', darkBlock, LIGHT_DEFAULTS.bg, DARK_DEFAULTS.bg);
117
+ var body = resolve(styles.color, '_sd_ctrl-color', darkBlock, LIGHT_DEFAULTS.text, DARK_DEFAULTS.text);
118
+ var headingFallback = headers.color || styles.color;
119
+ function heading(n) {
120
+ var explicit = h(n).color || headers.color;
121
+ var id = '_sd_ctrl-h' + n + '-color';
122
+ var lightDef = headingFallback || LIGHT_DEFAULTS.text;
123
+ var darkDef = body.dark;
124
+ return resolve(explicit, id, darkBlock, lightDef, darkDef);
125
+ }
126
+ var h1 = heading(1), h2 = heading(2), h3 = heading(3), h4 = heading(4);
127
+ var link = resolve((styles.link || {}).color, '_sd_ctrl-link-color', darkBlock, LIGHT_DEFAULTS.link, DARK_DEFAULTS.link);
128
+
129
+ var blocks = styles.blocks || {};
130
+ var bqBg = resolve((styles.blockquote || {}).background || blocks.background, '_sd_ctrl-bq-bg', darkBlock, LIGHT_DEFAULTS.bqBg, DARK_DEFAULTS.bqBg);
131
+ var bqText = resolve((styles.blockquote || {}).color || blocks.color, '_sd_ctrl-bq-color', darkBlock, LIGHT_DEFAULTS.bqText, DARK_DEFAULTS.bqText);
132
+ var codeBg = resolve((styles.code || {}).background || blocks.background, '_sd_ctrl-code-bg', darkBlock, LIGHT_DEFAULTS.codeBg, DARK_DEFAULTS.codeBg);
133
+ var codeText = resolve((styles.code || {}).color || blocks.color, '_sd_ctrl-code-color', darkBlock, LIGHT_DEFAULTS.codeText, DARK_DEFAULTS.codeText);
134
+
135
+ function build(theme) {
136
+ var pick = function (c) { return c[theme]; };
137
+ return [
138
+ { label: 'body text', surface: 'page', fg: pick(body), bg: pick(bg), large: false },
139
+ { label: 'h1', surface: 'page', fg: pick(h1), bg: pick(bg), large: true },
140
+ { label: 'h2', surface: 'page', fg: pick(h2), bg: pick(bg), large: true },
141
+ { label: 'h3', surface: 'page', fg: pick(h3), bg: pick(bg), large: true },
142
+ { label: 'h4', surface: 'page', fg: pick(h4), bg: pick(bg), large: true },
143
+ { label: 'link', surface: 'page', fg: pick(link), bg: pick(bg), large: false },
144
+ { label: 'blockquote text', surface: 'blockquote', fg: pick(bqText), bg: pick(bqBg), large: false },
145
+ { label: 'code text', surface: 'code block', fg: pick(codeText), bg: pick(codeBg), large: false }
146
+ ];
147
+ }
148
+ return { light: build('light'), dark: build('dark') };
149
+ }
150
+
151
+ // Full analysis for a parsed styles object: grades every pair in both
152
+ // themes. `hasCustomStyles` is false when the document set no colours, in
153
+ // which case the built-in defaults are known-good and nothing is flagged.
154
+ function analyzeStyles(styles) {
155
+ var hasColors = styles && hasCustomColors(styles);
156
+ var pairs = resolvePairs(styles);
157
+ function gradeList(list) {
158
+ return list.map(function (p) {
159
+ var g = grade(contrastRatio(p.fg, p.bg), p.large);
160
+ return {
161
+ label: p.label, surface: p.surface, fg: p.fg, bg: p.bg, large: p.large,
162
+ ratio: g.ratio, level: g.level, need: g.need, ok: g.ok
163
+ };
164
+ });
165
+ }
166
+ var light = gradeList(pairs.light);
167
+ var dark = gradeList(pairs.dark);
168
+ var fails = light.concat(dark).filter(function (p) { return !p.ok; });
169
+ return { hasCustomColors: !!hasColors, light: light, dark: dark, fails: fails };
170
+ }
171
+
172
+ function hasCustomColors(styles) {
173
+ if (!styles) return false;
174
+ var keys = ['background', 'color', 'link', 'blocks', 'blockquote', 'code', 'headers', 'h1', 'h2', 'h3', 'h4', 'dark'];
175
+ for (var i = 0; i < keys.length; i++) {
176
+ var v = styles[keys[i]];
177
+ if (v == null) continue;
178
+ if (typeof v === 'string') return true; // background / color
179
+ if (typeof v === 'object') {
180
+ if (v.color || v.background || v.borderColor) return true;
181
+ }
182
+ }
183
+ return false;
184
+ }
185
+
186
+ exports.hexToRgb = hexToRgb;
187
+ exports.relativeLuminance = relativeLuminance;
188
+ exports.contrastRatio = contrastRatio;
189
+ exports.grade = grade;
190
+ exports.MIN_CONTRAST = MIN_CONTRAST;
191
+ exports.resolvePairs = resolvePairs;
192
+ exports.analyzeStyles = analyzeStyles;
193
+ exports.hasCustomColors = hasCustomColors;
194
+ exports.LIGHT_DEFAULTS = LIGHT_DEFAULTS;
195
+ exports.DARK_DEFAULTS = DARK_DEFAULTS;
196
+ })(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocContrast = {}));