sdocs-dev 1.0.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,443 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sdoc CLI
4
+ * Usage:
5
+ * sdoc report.md # open file in browser
6
+ * sdoc share report.md # print shareable URL to stdout
7
+ * sdoc new # blank document in write mode
8
+ * cat file.md | sdoc # pipe markdown to browser
9
+ * sdoc # open studio with empty editor
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const zlib = require('zlib');
15
+ const { execSync } = require('child_process');
16
+ const SDocYaml = require('../public/sdocs-yaml.js');
17
+
18
+ const DEFAULT_URL = 'https://sdocs.dev';
19
+
20
+ // ── Help ───────────────────────────────────────────────────
21
+ const HELP = `
22
+ SDocs CLI
23
+ =========
24
+ Open, share, and style markdown files from the terminal.
25
+
26
+ USAGE
27
+ sdoc <file> Open file in browser (read mode)
28
+ sdoc <file> --write Open in write mode
29
+ sdoc <file> --style Open with style panel
30
+ sdoc <file> --raw Open raw markdown source
31
+ sdoc new New blank document (write mode)
32
+ sdoc share <file> Print shareable URL to stdout
33
+ sdoc share <file> --section "X" URL with section anchor
34
+ sdoc schema Print the full styles schema
35
+ sdoc defaults Show ~/.sdocs/styles.yaml
36
+ sdoc defaults --reset Remove default styles
37
+ sdoc help Show this help
38
+ cat file.md | sdoc Pipe markdown from stdin
39
+ cat file.md | sdoc share Pipe to shareable URL
40
+
41
+ MODE FLAGS
42
+ --read Clean reading view (default when file given)
43
+ --write Opens the contentEditable writer
44
+ --style Styled preview with style panel visible
45
+ --raw Shows raw markdown source
46
+
47
+ OPTIONS
48
+ --section <heading> Scroll to heading section on load
49
+ --url <base> Custom base URL (default: https://sdocs.dev)
50
+ --mode <m> Alias for --read / --write / --style / --raw
51
+
52
+ ENVIRONMENT
53
+ SDOCS_URL Fallback base URL if --url is not passed.
54
+
55
+ STYLED MARKDOWN FORMAT
56
+ SDocs extends standard .md files with an optional YAML
57
+ front matter block (the same standard used by Jekyll, Hugo, Obsidian).
58
+ The \`styles\` key controls every visual aspect of the rendered document.
59
+
60
+ ---
61
+ title: "My Document"
62
+ styles:
63
+ fontFamily: Inter
64
+ baseFontSize: 16
65
+ color: "#1c1917"
66
+ h1: { fontSize: 2.2, color: "#1a3a5c", fontWeight: 700 }
67
+ p: { lineHeight: 1.85, marginBottom: 1.1 }
68
+ ---
69
+ # My Document
70
+ Content here...
71
+
72
+ Run \`sdoc schema\` for the complete list of style properties.
73
+ `;
74
+
75
+ const SCHEMA = `
76
+ SDocs — Styles Schema
77
+ =====================
78
+ All style values live under the \`styles:\` key in YAML front matter.
79
+ Every property is optional — omit anything you want left at its default.
80
+
81
+ GENERAL
82
+ fontFamily string Any of the supported fonts (see FONTS below)
83
+ Default: "Inter"
84
+ baseFontSize number Base font size in px. All rem/em values scale from this.
85
+ Default: 16
86
+ color string Master body text color (hex). Cascades to headings,
87
+ paragraphs, and lists unless those are overridden.
88
+ Default: "#1c1917"
89
+ lineHeight number Global line-height multiplier.
90
+ Default: 1.75
91
+
92
+ HEADINGS (general heading controls)
93
+ headers:
94
+ scale number Relative size multiplier applied across all heading levels.
95
+ Default: 1.0
96
+ marginBottom number Space below headings (em). Default: 0.4
97
+ color string Heading color — cascades to h1/h2/h3/h4 unless overridden.
98
+ Default: inherits \`color\`
99
+
100
+ PER-HEADING (each independently overrides the heading defaults above)
101
+ h1: { fontSize: number, color: string, fontWeight: number }
102
+ h2: { fontSize: number, color: string, fontWeight: number }
103
+ h3: { fontSize: number, color: string, fontWeight: number }
104
+ h4: { fontSize: number, color: string, fontWeight: number }
105
+
106
+ fontSize is in rem (relative to baseFontSize).
107
+ Sensible defaults: h1 2.2, h2 1.55, h3 1.2, h4 1.0
108
+ fontWeight: 400 (regular) · 600 (semibold) · 700 (bold)
109
+
110
+ PARAGRAPH
111
+ p:
112
+ lineHeight number Line height for body paragraphs. Default: 1.75
113
+ marginBottom number Space between paragraphs (em). Default: 1.1
114
+ color string Paragraph text color. Default: inherits \`color\`
115
+
116
+ LISTS
117
+ list:
118
+ color string Color for list items and bullet/number markers.
119
+ Default: inherits paragraph color
120
+
121
+ LINKS
122
+ link:
123
+ color string Link color. Default: "#2563eb"
124
+ decoration string "underline" | "none". Default: "underline"
125
+
126
+ CODE
127
+ code:
128
+ fontFamily string Monospace font. Default: "ui-monospace, monospace"
129
+ background string Inline/block code background color. Default: "#F1EDE8"
130
+ padding number Inline code padding (em). Default: 0.2
131
+
132
+ BLOCKQUOTE
133
+ blockquote:
134
+ borderColor string Left border accent color. Default: "#2563eb"
135
+ borderWidth number Left border thickness (px). Default: 3
136
+ color string Quote text color. Default: "#6b6560"
137
+
138
+ COLOR CASCADE
139
+ Colors cascade from general → specific:
140
+ color → headers.color → h1.color, h2.color, h3.color, h4.color
141
+ color → p.color → list.color
142
+ Set a child color only when you want it to differ from its parent.
143
+
144
+ FONTS (24 supported, loaded lazily from Google Fonts)
145
+ Inter · Roboto · Open Sans · Lato · Montserrat · Source Sans 3
146
+ Oswald · Raleway · Poppins · Merriweather · Ubuntu · Nunito
147
+ Playfair Display · Roboto Slab · PT Sans · Lora · Mulish · Noto Sans
148
+ Rubik · Dosis · Josefin Sans · PT Serif · Libre Franklin · Crimson Text
149
+
150
+ EXAMPLE — editorial article with colored heading tiers
151
+ ---
152
+ styles:
153
+ fontFamily: Lora
154
+ baseFontSize: 17
155
+ color: "#1a1a2e"
156
+ headers:
157
+ color: "#2c3e50"
158
+ h1: { fontSize: 2.3, color: "#c0392b", fontWeight: 700 }
159
+ h2: { fontSize: 1.55, color: "#8e44ad", fontWeight: 600 }
160
+ h3: { fontSize: 1.2, color: "#16a085", fontWeight: 600 }
161
+ p: { lineHeight: 1.9, marginBottom: 1.2 }
162
+ link: { color: "#e67e22", decoration: "underline" }
163
+ blockquote: { borderColor: "#c0392b", borderWidth: 4, color: "#7f8c8d" }
164
+ ---
165
+ `;
166
+
167
+ // ── Compression (deflate-raw + base64url) ─────────────────
168
+
169
+ function compressToBase64Url(text) {
170
+ const deflated = zlib.deflateRawSync(Buffer.from(text, 'utf-8'));
171
+ return deflated.toString('base64')
172
+ .replace(/\+/g, '-')
173
+ .replace(/\//g, '_')
174
+ .replace(/=+$/, '');
175
+ }
176
+
177
+ function decompressFromBase64Url(b64url) {
178
+ let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
179
+ const pad = (4 - b64.length % 4) % 4;
180
+ b64 += '='.repeat(pad);
181
+ return zlib.inflateRawSync(Buffer.from(b64, 'base64')).toString('utf-8');
182
+ }
183
+
184
+ // ── Slugify ───────────────────────────────────────────────
185
+
186
+ function slugify(text) {
187
+ return text.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
188
+ }
189
+
190
+ // ── Parse args ────────────────────────────────────────────
191
+
192
+ const SUBCOMMANDS = new Set(['new', 'share', 'schema', 'defaults', 'help']);
193
+
194
+ function parseArgs(argv) {
195
+ const args = argv || process.argv.slice(2);
196
+ let file = null;
197
+ let mode = null;
198
+ let url = null;
199
+ let subcommand = null;
200
+ let section = null;
201
+ let resetFlag = false;
202
+
203
+ for (let i = 0; i < args.length; i++) {
204
+ const arg = args[i];
205
+
206
+ // Legacy / shortcut flags that map to subcommands
207
+ if (arg === '--help' || arg === '-h') { subcommand = 'help'; continue; }
208
+ if (arg === '--schema') { subcommand = 'schema'; continue; }
209
+
210
+ // Mode shorthand flags
211
+ if (arg === '--write') { mode = 'write'; continue; }
212
+ if (arg === '--style') { mode = 'style'; continue; }
213
+ if (arg === '--raw') { mode = 'raw'; continue; }
214
+ if (arg === '--read') { mode = 'read'; continue; }
215
+
216
+ // Long-form --mode
217
+ if (arg === '--mode' || arg === '-m') {
218
+ mode = args[++i];
219
+ if (!['read', 'write', 'style', 'raw'].includes(mode)) {
220
+ console.error(`sdoc: unknown mode "${mode}" — use read, write, style, or raw`);
221
+ process.exit(1);
222
+ }
223
+ continue;
224
+ }
225
+
226
+ // --url flag
227
+ if (arg === '--url') { url = args[++i]; continue; }
228
+
229
+ // --section flag
230
+ if (arg === '--section' || arg === '-s') { section = args[++i]; continue; }
231
+
232
+ // --reset flag (for defaults subcommand)
233
+ if (arg === '--reset') { resetFlag = true; continue; }
234
+
235
+ // Positional: check for subcommand first, then file
236
+ if (!subcommand && SUBCOMMANDS.has(arg)) {
237
+ subcommand = arg;
238
+ continue;
239
+ }
240
+
241
+ if (!file) { file = arg; continue; }
242
+ }
243
+
244
+ return { file, mode, url, subcommand, section, resetFlag };
245
+ }
246
+
247
+ // ── Build URL ─────────────────────────────────────────────
248
+
249
+ function buildUrl(content, opts) {
250
+ const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
251
+ const params = new URLSearchParams();
252
+
253
+ if (content) {
254
+ params.set('md', compressToBase64Url(content));
255
+ } else if (opts.defaultStyles) {
256
+ const stylesJson = JSON.stringify(opts.defaultStyles);
257
+ params.set('styles', encodeURIComponent(Buffer.from(stylesJson, 'utf-8').toString('base64')));
258
+ }
259
+
260
+ const mode = opts.mode || (content ? 'read' : 'style');
261
+ if (mode && mode !== 'read') params.set('mode', mode);
262
+
263
+ if (opts.section) {
264
+ params.set('sec', slugify(opts.section));
265
+ }
266
+
267
+ const qs = params.toString();
268
+ return qs ? `${baseUrl}/#${qs}` : baseUrl;
269
+ }
270
+
271
+ // ── YAML parsing (shared module) ──
272
+ const { parseSimpleYaml, parseFrontMatter, serializeFrontMatter } = SDocYaml;
273
+
274
+ // ── ~/.sdocs/styles.yaml default styles ────────────────────
275
+
276
+ function getDefaultsPath() {
277
+ return path.join(require('os').homedir(), '.sdocs', 'styles.yaml');
278
+ }
279
+
280
+ function loadDefaultStyles() {
281
+ const configPath = getDefaultsPath();
282
+ if (!fs.existsSync(configPath)) return null;
283
+ try {
284
+ const yaml = fs.readFileSync(configPath, 'utf-8');
285
+ return parseSimpleYaml(yaml);
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ function showDefaults() {
292
+ const configPath = getDefaultsPath();
293
+ if (!fs.existsSync(configPath)) {
294
+ console.log('No default styles set (~/.sdocs/styles.yaml not found).');
295
+ console.log('\nTo set defaults, style a document in SDocs and use');
296
+ console.log('the "Save as Default" panel to generate the command.');
297
+ return;
298
+ }
299
+ console.log(fs.readFileSync(configPath, 'utf-8'));
300
+ }
301
+
302
+ function resetDefaults() {
303
+ const configPath = getDefaultsPath();
304
+ if (!fs.existsSync(configPath)) {
305
+ console.log('No default styles to remove.');
306
+ return;
307
+ }
308
+ fs.unlinkSync(configPath);
309
+ console.log('Removed ' + configPath);
310
+ }
311
+
312
+ // Deep merge: defaults under file styles (file wins on conflict)
313
+ function mergeStyles(defaults, fileStyles) {
314
+ if (!defaults) return fileStyles || {};
315
+ if (!fileStyles) return { ...defaults };
316
+ const merged = { ...defaults };
317
+ for (const [k, v] of Object.entries(fileStyles)) {
318
+ if (typeof v === 'object' && v !== null && typeof merged[k] === 'object' && merged[k] !== null) {
319
+ merged[k] = { ...merged[k], ...v };
320
+ } else {
321
+ merged[k] = v;
322
+ }
323
+ }
324
+ return merged;
325
+ }
326
+
327
+ // Apply default styles to content, returning modified content
328
+ function applyDefaultStyles(content) {
329
+ const defaults = loadDefaultStyles();
330
+ if (!defaults) return content;
331
+
332
+ const { meta, body } = parseFrontMatter(content);
333
+ const mergedStyles = mergeStyles(defaults, meta.styles);
334
+ const newMeta = { ...meta, styles: mergedStyles };
335
+ return serializeFrontMatter(newMeta) + '\n' + body;
336
+ }
337
+
338
+ // ── Read content ───────────────────────────────────────────
339
+
340
+ async function readContent(file) {
341
+ if (file) {
342
+ const resolved = path.resolve(file);
343
+ if (!fs.existsSync(resolved)) {
344
+ console.error(`sdoc: file not found: ${file}`);
345
+ process.exit(1);
346
+ }
347
+ return fs.readFileSync(resolved, 'utf-8');
348
+ }
349
+
350
+ // Check if stdin has data (piped input)
351
+ if (!process.stdin.isTTY) {
352
+ return new Promise((resolve, reject) => {
353
+ let data = '';
354
+ process.stdin.setEncoding('utf-8');
355
+ process.stdin.on('data', chunk => data += chunk);
356
+ process.stdin.on('end', () => resolve(data));
357
+ process.stdin.on('error', reject);
358
+ });
359
+ }
360
+
361
+ return null; // no content — just open studio
362
+ }
363
+
364
+ // ── Open browser ───────────────────────────────────────────
365
+
366
+ function openBrowser(url) {
367
+ const platform = process.platform;
368
+ try {
369
+ if (platform === 'darwin') execSync(`open "${url}"`);
370
+ else if (platform === 'win32') execSync(`start "" "${url}"`);
371
+ else execSync(`xdg-open "${url}"`);
372
+ } catch {
373
+ console.log(`Open in browser: ${url}`);
374
+ }
375
+ }
376
+
377
+ // ── Main ───────────────────────────────────────────────────
378
+
379
+ if (require.main === module) {
380
+ (async () => {
381
+ const opts = parseArgs();
382
+
383
+ // Subcommand dispatch
384
+ if (opts.subcommand === 'help') { console.log(HELP); process.exit(0); }
385
+ if (opts.subcommand === 'schema') { console.log(SCHEMA); process.exit(0); }
386
+ if (opts.subcommand === 'defaults') {
387
+ if (opts.resetFlag) resetDefaults();
388
+ else showDefaults();
389
+ process.exit(0);
390
+ }
391
+ if (opts.subcommand === 'new') {
392
+ const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
393
+ const url = baseUrl + '/new';
394
+ openBrowser(url);
395
+ console.log(`SDocs → ${url}`);
396
+ process.exit(0);
397
+ }
398
+
399
+ // File / stdin handling
400
+ let content = await readContent(opts.file);
401
+
402
+ // Apply ~/.sdocs/styles.yaml defaults
403
+ const defaults = loadDefaultStyles();
404
+ if (content && defaults) {
405
+ content = applyDefaultStyles(content);
406
+ }
407
+
408
+ const url = buildUrl(content, {
409
+ url: opts.url,
410
+ mode: opts.mode,
411
+ defaultStyles: !content ? defaults : null,
412
+ section: opts.section,
413
+ });
414
+
415
+ // Share: print URL to stdout, don't open browser
416
+ if (opts.subcommand === 'share') {
417
+ process.stdout.write(url + '\n');
418
+ process.exit(0);
419
+ }
420
+
421
+ // Default: open browser
422
+ openBrowser(url);
423
+ console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
424
+ })().catch(e => {
425
+ console.error('sdoc:', e.message);
426
+ process.exit(1);
427
+ });
428
+ }
429
+
430
+ // ── Exports (for tests) ───────────────────────────────────
431
+
432
+ module.exports = {
433
+ mergeStyles,
434
+ applyDefaultStyles,
435
+ parseFrontMatter,
436
+ serializeFrontMatter,
437
+ parseSimpleYaml,
438
+ parseArgs,
439
+ buildUrl,
440
+ slugify,
441
+ compressToBase64Url,
442
+ decompressFromBase64Url,
443
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "sdocs-dev",
3
+ "version": "1.0.0",
4
+ "description": "Open, share, and style markdown files from the terminal",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "sdoc": "./bin/sdocs-dev.js",
8
+ "sdocs-dev": "./bin/sdocs-dev.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "public/",
13
+ "server.js"
14
+ ],
15
+ "scripts": {
16
+ "start": "node server.js",
17
+ "test": "node test/run.js"
18
+ },
19
+ "keywords": [
20
+ "markdown",
21
+ "editor",
22
+ "styling",
23
+ "pdf",
24
+ "docx",
25
+ "cli"
26
+ ],
27
+ "author": "Josh Summers",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/JoshInLisbon/SDocs.git"
32
+ },
33
+ "homepage": "https://sdocs.dev",
34
+ "dependencies": {
35
+ "marked": "^11.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@playwright/test": "^1.58.2"
39
+ }
40
+ }