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/bin/sdocs-bridge.js +974 -0
- package/bin/sdocs-dev.js +145 -2102
- package/bin/sdocs-icon-names.js +1965 -0
- package/lib/agent-block.js +245 -0
- package/lib/agent-files.js +162 -0
- package/lib/bridge-commands.js +171 -0
- package/lib/cells-transclude.js +111 -0
- package/lib/commands.js +291 -0
- package/lib/constants.js +283 -0
- package/lib/help-text.js +2706 -0
- package/lib/io.js +173 -0
- package/lib/library-autostart.js +145 -0
- package/lib/library-commands.js +307 -0
- package/lib/library-ephemeral.js +111 -0
- package/lib/library-index.js +280 -0
- package/lib/library-paths.js +20 -0
- package/lib/library-scan.js +258 -0
- package/lib/library-server.js +400 -0
- package/lib/library-store.js +141 -0
- package/lib/router.js +52 -0
- package/lib/safe.js +200 -0
- package/lib/setup.js +332 -0
- package/lib/short-link.js +105 -0
- package/lib/styles.js +91 -0
- package/lib/update-check.js +163 -0
- package/lib/url.js +111 -0
- package/package.json +5 -16
- package/shared/sdocs-contrast.js +196 -0
- package/shared/sdocs-form-block.js +605 -0
- package/shared/sdocs-library-tags.js +41 -0
- package/{public → shared}/sdocs-styles.js +134 -5
- package/README.md +0 -149
- /package/{public → shared}/sdocs-slugify.js +0 -0
- /package/{public → shared}/sdocs-yaml.js +0 -0
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.
|
|
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
|
-
"
|
|
13
|
-
"
|
|
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/
|
|
32
|
+
"url": "git+https://github.com/espressoplease/smalldocs.git"
|
|
36
33
|
},
|
|
37
|
-
"homepage": "https://
|
|
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 = {}));
|