sdocs-dev 1.6.2 → 1.12.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.
package/lib/styles.js ADDED
@@ -0,0 +1,91 @@
1
+ // Default styles stored at ~/.sdocs/styles.yaml.
2
+ //
3
+ // Users tune a document in the browser, click "Save as Default", and
4
+ // the resulting YAML lands here. Every subsequent `sdoc <file>` merges
5
+ // these defaults under the file's own `styles:` block (file wins on
6
+ // conflict). `sdoc defaults` shows or removes the file.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const SDocYaml = require('../shared/sdocs-yaml.js');
12
+
13
+ function getDefaultsPath() {
14
+ return path.join(os.homedir(), '.sdocs', 'styles.yaml');
15
+ }
16
+
17
+ function loadDefaultStyles() {
18
+ const configPath = getDefaultsPath();
19
+ if (!fs.existsSync(configPath)) return null;
20
+ try {
21
+ const yaml = fs.readFileSync(configPath, 'utf-8');
22
+ return SDocYaml.parseSimpleYaml(yaml);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function showDefaults() {
29
+ const configPath = getDefaultsPath();
30
+ if (!fs.existsSync(configPath)) {
31
+ console.log('No default styles set (~/.sdocs/styles.yaml not found).');
32
+ console.log('\nTo set defaults, style a document in SDocs and use');
33
+ console.log('the "Save as Default" panel to generate the command.');
34
+ return;
35
+ }
36
+ console.log(fs.readFileSync(configPath, 'utf-8'));
37
+ }
38
+
39
+ function resetDefaults() {
40
+ const configPath = getDefaultsPath();
41
+ if (!fs.existsSync(configPath)) {
42
+ console.log('No default styles to remove.');
43
+ return;
44
+ }
45
+ fs.unlinkSync(configPath);
46
+ console.log('Removed ' + configPath);
47
+ }
48
+
49
+ // Deep merge: defaults under file styles (file wins on conflict).
50
+ // Recurses one level deeper for light:/dark: sub-objects that contain
51
+ // nested objects (e.g. h1: { color: ... }).
52
+ function mergeStyles(defaults, fileStyles) {
53
+ if (!defaults) return fileStyles || {};
54
+ if (!fileStyles) return { ...defaults };
55
+ const merged = { ...defaults };
56
+ for (const [k, v] of Object.entries(fileStyles)) {
57
+ if (typeof v === 'object' && v !== null && typeof merged[k] === 'object' && merged[k] !== null) {
58
+ const inner = { ...merged[k] };
59
+ for (const [ik, iv] of Object.entries(v)) {
60
+ if (typeof iv === 'object' && iv !== null && typeof inner[ik] === 'object' && inner[ik] !== null) {
61
+ inner[ik] = { ...inner[ik], ...iv };
62
+ } else {
63
+ inner[ik] = iv;
64
+ }
65
+ }
66
+ merged[k] = inner;
67
+ } else {
68
+ merged[k] = v;
69
+ }
70
+ }
71
+ return merged;
72
+ }
73
+
74
+ function applyDefaultStyles(content) {
75
+ const defaults = loadDefaultStyles();
76
+ if (!defaults) return content;
77
+
78
+ const { meta, body } = SDocYaml.parseFrontMatter(content);
79
+ const mergedStyles = mergeStyles(defaults, meta.styles);
80
+ const newMeta = { ...meta, styles: mergedStyles };
81
+ return SDocYaml.serializeFrontMatter(newMeta) + '\n' + body;
82
+ }
83
+
84
+ module.exports = {
85
+ getDefaultsPath,
86
+ loadDefaultStyles,
87
+ showDefaults,
88
+ resetDefaults,
89
+ mergeStyles,
90
+ applyDefaultStyles,
91
+ };
@@ -0,0 +1,163 @@
1
+ // Daily npm version check + optional auto-install.
2
+ //
3
+ // refreshUpdateCache(): non-blocking GET of dist-tags from npm, written to
4
+ // ~/.sdocs/update-check.json. Runs at most once per day, never on CI.
5
+ //
6
+ // maybeUpdateBinary(): reads the cached `latest`, compares to VERSION, and:
7
+ // - autoInstallUpdates=true: silent self-upgrade + re-exec.
8
+ // - interactive TTY: Y/n prompt.
9
+ // - non-TTY: one-line hint.
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const https = require('https');
15
+ const readline = require('readline');
16
+ const { execSync, spawnSync } = require('child_process');
17
+
18
+ const { UPDATE_CACHE, VERSION, ONE_DAY, GITHUB_REPO_URL, INSTALL_SH_URL } = require('./constants');
19
+ const { readSetupState } = require('./agent-block');
20
+
21
+ // Install-method detection. The URL installer (install.sh) drops the CLI into
22
+ // $SDOCS_HOME/cli (default ~/.sdocs/cli); a global npm install lives under
23
+ // npm's prefix. The two upgrade differently, so every upgrade path branches on
24
+ // this. If you change the installed layout in install.sh, update this check.
25
+ //
26
+ // Both sides are realpath-resolved before comparing: `__dirname` is already
27
+ // canonical, but the home path is raw, so a symlink anywhere above ~/.sdocs
28
+ // (common on macOS/managed homes) would make a raw startsWith() miss. The
29
+ // SDOCS_HOME env var mirrors install.sh so a custom install dir is detected
30
+ // too. realpathSync throws when $SDOCS_HOME/cli does not exist (the npm and
31
+ // dev-checkout cases); the catch turns that into `false`.
32
+ function isUrlInstall(moduleDir) {
33
+ try {
34
+ const home = process.env.SDOCS_HOME || path.join(os.homedir(), '.sdocs');
35
+ const cliRoot = fs.realpathSync(path.join(home, 'cli')) + path.sep;
36
+ const here = fs.realpathSync(path.resolve(moduleDir || __dirname, '..')) + path.sep;
37
+ return here.startsWith(cliRoot);
38
+ } catch (_) { return false; }
39
+ }
40
+
41
+ // The command that upgrades sdoc in place, given how it was installed.
42
+ function upgradeCommand() {
43
+ return isUrlInstall()
44
+ ? `curl -fsSL ${INSTALL_SH_URL} | sh`
45
+ : 'npm i -g sdocs-dev@latest';
46
+ }
47
+
48
+ function isNewer(latest, current) {
49
+ const a = latest.split('.').map(Number);
50
+ const b = current.split('.').map(Number);
51
+ for (let i = 0; i < 3; i++) {
52
+ if (a[i] > b[i]) return true;
53
+ if (a[i] < b[i]) return false;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function readCachedLatest() {
59
+ try { return JSON.parse(fs.readFileSync(UPDATE_CACHE, 'utf-8')).latest; }
60
+ catch (_) { return null; }
61
+ }
62
+
63
+ // Self-upgrade: runs the right upgrade command for the install method, then
64
+ // re-execs into the new binary. On any failure, falls through (so the user's
65
+ // actual command still runs).
66
+ function autoInstallAndReexec(latest) {
67
+ console.log(`\nUpdating sdoc ${VERSION} → ${latest}...`);
68
+ const cmd = upgradeCommand();
69
+ try {
70
+ execSync(cmd, { stdio: 'pipe' });
71
+ } catch (e) {
72
+ console.error(`! sdoc auto-update to ${latest} failed: ${(e.stderr || e.message || '').toString().trim().split('\n')[0]}`);
73
+ console.error(` Run \`${cmd}\` manually to upgrade.`);
74
+ return false;
75
+ }
76
+ console.log(`✓ sdoc updated ${VERSION} → ${latest}`);
77
+ console.log(` Diff: ${GITHUB_REPO_URL}/compare/v${VERSION}...v${latest}`);
78
+ const r = spawnSync(process.argv0, process.argv.slice(1), { stdio: 'inherit' });
79
+ process.exit(r.status == null ? 0 : r.status);
80
+ }
81
+
82
+ async function maybeUpdateBinary() {
83
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
84
+ const latest = readCachedLatest();
85
+ if (!latest || !isNewer(latest, VERSION)) return;
86
+
87
+ const state = readSetupState();
88
+ const autoInstall = state && state.autoInstallUpdates === true;
89
+
90
+ if (autoInstall) {
91
+ autoInstallAndReexec(latest);
92
+ return;
93
+ }
94
+
95
+ const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
96
+ if (!isInteractive) {
97
+ console.log(`Update available: ${VERSION} → ${latest}. Run \`${upgradeCommand()}\` to upgrade.`);
98
+ return;
99
+ }
100
+
101
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
102
+ const answer = await new Promise(resolve => {
103
+ rl.question(`\nUpdate available: ${VERSION} → ${latest}. Install now? [Y/n] `, a => {
104
+ rl.close(); resolve(a.trim().toLowerCase());
105
+ });
106
+ });
107
+ if (answer && answer !== 'y' && answer !== 'yes') return;
108
+
109
+ const cmd = upgradeCommand();
110
+ console.log('Installing the latest sdoc...');
111
+ try {
112
+ execSync(cmd, { stdio: 'inherit' });
113
+ console.log(`✓ Updated to v${latest}`);
114
+ } catch (_) {
115
+ console.error(`Update failed. Run \`${cmd}\` to upgrade.`);
116
+ }
117
+ }
118
+
119
+ // `sdoc upgrade` — force an upgrade to the latest version right now,
120
+ // regardless of the daily update cache. Branches on install method.
121
+ function runUpgrade() {
122
+ const cmd = upgradeCommand();
123
+ console.log(`Upgrading sdoc (currently ${VERSION})...`);
124
+ try {
125
+ execSync(cmd, { stdio: 'inherit' });
126
+ } catch (_) {
127
+ console.error(`\nUpgrade failed. Run \`${cmd}\` manually.`);
128
+ process.exit(1);
129
+ }
130
+ console.log('✓ sdoc is up to date.');
131
+ }
132
+
133
+ // Daily refresh of the cached `latest` version from npm. Not gated on TTY:
134
+ // agents populate the cache too, so the update hint reaches them on next run.
135
+ function refreshUpdateCache() {
136
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return;
137
+ try {
138
+ if (Date.now() - fs.statSync(UPDATE_CACHE).mtimeMs < ONE_DAY) return;
139
+ } catch (_) {}
140
+
141
+ https.get('https://registry.npmjs.org/-/package/sdocs-dev/dist-tags', { timeout: 3000 }, res => {
142
+ let data = '';
143
+ res.on('data', chunk => { data += chunk; });
144
+ res.on('end', () => {
145
+ try {
146
+ const latest = JSON.parse(data).latest;
147
+ fs.mkdirSync(path.dirname(UPDATE_CACHE), { recursive: true });
148
+ fs.writeFileSync(UPDATE_CACHE, JSON.stringify({ latest }));
149
+ } catch (_) {}
150
+ });
151
+ }).on('error', () => {}).on('timeout', function () { this.destroy(); });
152
+ }
153
+
154
+ module.exports = {
155
+ isNewer,
156
+ readCachedLatest,
157
+ isUrlInstall,
158
+ upgradeCommand,
159
+ autoInstallAndReexec,
160
+ maybeUpdateBinary,
161
+ runUpgrade,
162
+ refreshUpdateCache,
163
+ };
package/lib/url.js ADDED
@@ -0,0 +1,111 @@
1
+ // URL encoding for SDocs links.
2
+ //
3
+ // - toBase64Url / fromBase64Url: URL-safe base64.
4
+ // - compressToBase64Url / decompressFromBase64Url: brotli + base64url.
5
+ // The browser uses the same shape so a URL built here decodes there
6
+ // identically. Decompression falls back to raw inflate for old links.
7
+ // - buildUrl: the public form (`#md=<compressed>`), default-style
8
+ // stripping included so the URL is as short as the browser would write.
9
+
10
+ const zlib = require('zlib');
11
+ const SDocYaml = require('../shared/sdocs-yaml.js');
12
+ const SDocStyles = require('../shared/sdocs-styles.js');
13
+ const { slugify } = require('../shared/sdocs-slugify.js');
14
+ const { DEFAULT_URL } = require('./constants');
15
+
16
+ function toBase64Url(buf) {
17
+ return Buffer.from(buf).toString('base64')
18
+ .replace(/\+/g, '-')
19
+ .replace(/\//g, '_')
20
+ .replace(/=+$/, '');
21
+ }
22
+
23
+ function fromBase64Url(b64url) {
24
+ let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
25
+ const pad = (4 - b64.length % 4) % 4;
26
+ b64 += '='.repeat(pad);
27
+ return Buffer.from(b64, 'base64');
28
+ }
29
+
30
+ function compressToBase64Url(text) {
31
+ const compressed = zlib.brotliCompressSync(Buffer.from(text, 'utf-8'), {
32
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
33
+ });
34
+ return toBase64Url(compressed);
35
+ }
36
+
37
+ function decompressFromBase64Url(b64url) {
38
+ const buf = fromBase64Url(b64url);
39
+ try {
40
+ return zlib.brotliDecompressSync(buf).toString('utf-8');
41
+ } catch (_) {
42
+ return zlib.inflateRawSync(buf).toString('utf-8');
43
+ }
44
+ }
45
+
46
+ // Strip default styles from the front matter, then brotli+base64url the result.
47
+ // This is the exact `md=` payload the browser's fragment Source decodes. Shared
48
+ // by buildUrl (static / share links) and the bridge URL builder so both paths
49
+ // emit one identical encoding - no second code path to drift out of sync.
50
+ function stripAndCompress(content) {
51
+ const parsed = SDocYaml.parseFrontMatter(content);
52
+ if (parsed.meta && parsed.meta.styles) {
53
+ const stripped = SDocStyles.stripStyleDefaults(parsed.meta.styles);
54
+ if (Object.keys(stripped).length > 0) {
55
+ parsed.meta.styles = stripped;
56
+ } else {
57
+ delete parsed.meta.styles;
58
+ }
59
+ content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
60
+ }
61
+ return compressToBase64Url(content);
62
+ }
63
+
64
+ function buildUrl(content, opts) {
65
+ const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
66
+ const params = new URLSearchParams();
67
+
68
+ // Runtime-only metadata (paths). Stripped from the URL by the browser on load,
69
+ // so anything the user copies from the address bar won't contain them.
70
+ if (opts.local && Object.keys(opts.local).length > 0) {
71
+ const json = JSON.stringify(opts.local);
72
+ const b64 = Buffer.from(json, 'utf-8').toString('base64')
73
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
74
+ params.set('local', b64);
75
+ }
76
+
77
+ if (content) {
78
+ params.set('md', stripAndCompress(content));
79
+ } else if (opts.defaultStyles) {
80
+ const stylesJson = JSON.stringify(opts.defaultStyles);
81
+ params.set('styles', encodeURIComponent(Buffer.from(stylesJson, 'utf-8').toString('base64')));
82
+ }
83
+
84
+ const mode = opts.mode || (content ? 'read' : 'style');
85
+ if (mode && mode !== 'read') params.set('mode', mode);
86
+
87
+ if (opts.theme) params.set('theme', opts.theme);
88
+
89
+ if (opts.section) {
90
+ params.set('sec', slugify(opts.section));
91
+ }
92
+
93
+ // `sdoc present <file>` opens straight into fullscreen slide view.
94
+ // The browser checks for `present` in the hash and triggers present
95
+ // mode after the document loads. The value "0" picks the first slide.
96
+ if (opts.present) {
97
+ params.set('present', '0');
98
+ }
99
+
100
+ const qs = params.toString();
101
+ return qs ? `${baseUrl}/#${qs}` : baseUrl;
102
+ }
103
+
104
+ module.exports = {
105
+ toBase64Url,
106
+ fromBase64Url,
107
+ compressToBase64Url,
108
+ decompressFromBase64Url,
109
+ stripAndCompress,
110
+ buildUrl,
111
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs-dev",
3
- "version": "1.6.2",
3
+ "version": "1.12.0",
4
4
  "description": "Open, share, and style markdown files from the terminal",
5
5
  "main": "bin/sdocs-dev.js",
6
6
  "bin": {
@@ -9,13 +9,10 @@
9
9
  },
10
10
  "files": [
11
11
  "bin/",
12
- "public/sdocs-yaml.js",
13
- "public/sdocs-styles.js",
14
- "public/sdocs-slugify.js"
12
+ "lib/",
13
+ "shared/"
15
14
  ],
16
15
  "scripts": {
17
- "start": "node server.js",
18
- "test": "node test/run.js",
19
16
  "postinstall": "node bin/sdocs-postinstall.js"
20
17
  },
21
18
  "keywords": [
@@ -32,15 +29,7 @@
32
29
  "license": "MIT",
33
30
  "repository": {
34
31
  "type": "git",
35
- "url": "git+https://github.com/espressoplease/SDocs.git"
32
+ "url": "git+https://github.com/espressoplease/smalldocs.git"
36
33
  },
37
- "homepage": "https://sdocs.dev",
38
- "devDependencies": {
39
- "@playwright/test": "^1.59.1",
40
- "better-sqlite3": "^12.8.0",
41
- "brotli": "^1.3.3",
42
- "brotli-dec-wasm": "^2.3.2",
43
- "brotli-wasm": "^3.0.1",
44
- "marked": "^11.0.0"
45
- }
34
+ "homepage": "https://smalldocs.org"
46
35
  }
@@ -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 = {}));