sdocs-dev 1.13.1 → 1.14.1

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.
@@ -26,7 +26,7 @@ const fs = require('fs');
26
26
  const path = require('path');
27
27
  const crypto = require('crypto');
28
28
  const FormBlock = require('../shared/sdocs-form-block.js');
29
- const { isWrappedFile, wrapForDisplay } = require('../lib/cells-transclude');
29
+ const { isWrappedFile, wrapForDisplay } = require('../lib/file-wrap');
30
30
 
31
31
  // ── Constants ─────────────────────────────────────────────
32
32
 
package/bin/sdocs-dev.js CHANGED
@@ -47,6 +47,8 @@ function buildRouter() {
47
47
  r.register('schema', { handler: () => { console.log(helpText.SCHEMA); process.exit(0); } });
48
48
  r.register('charts', { handler: () => { console.log(helpText.CHARTS_HELP); process.exit(0); } });
49
49
  r.register('diagrams', { handler: () => { console.log(helpText.DIAGRAMS_HELP); process.exit(0); } });
50
+ r.register('videos', { handler: () => { console.log(helpText.VIDEOS_HELP); process.exit(0); } });
51
+ r.register('video', { handler: () => { console.log(helpText.VIDEOS_HELP); process.exit(0); } });
50
52
  // `sdoc cells` prints the reference; `sdoc cells verify <file>` evaluates a
51
53
  // document's tabs headlessly and prints the computed values (the handler
52
54
  // calls process.exit with the 0/1/2 result code).
@@ -54,6 +56,7 @@ function buildRouter() {
54
56
  if ((opts.file || '').toLowerCase() === 'verify') return cellsVerify.cellsVerifyCommand(opts);
55
57
  console.log(helpText.CELLS_HELP); process.exit(0);
56
58
  } });
59
+ r.register('code', { handler: () => { console.log(helpText.CODE_HELP); process.exit(0); } });
57
60
  r.register('comments', { handler: () => { console.log(helpText.COMMENTS_HELP); process.exit(0); } });
58
61
 
59
62
  // Setup / refresh / auto-update.
@@ -18,8 +18,8 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { SETUP_CACHE } = require('./constants');
20
20
 
21
- const AGENT_BLOCK_VERSION = 11;
22
- const AGENT_BLOCK_REASON = 'Notes that cells blocks can be multi-tab: naming a block (```cells Expenses) builds a workbook of several sheets whose formulas reference each other across tabs (=Expenses!B4), and `sdoc cells verify file.md` computes the whole workbook headlessly so the agent can read the values back. The rest of the cells line is unchanged.';
21
+ const AGENT_BLOCK_VERSION = 12;
22
+ const AGENT_BLOCK_REASON = 'Adds `sdoc code`: opening a source file (sdoc app.rb) or a ```lang fenced block as a syntax-highlighted listing - a light code viewer for reading code with the user away from the IDE - with source comments given a prominent lane, a fullscreen view that folds by method/class, a comment mode for the user to annotate a line or method, and agent annotations (sdoc app.py 22:"...") that pin markdown callouts to lines so an agent can walk the user through code without editing it. One bullet added after the cells line; the rest of the block is unchanged from v11.';
23
23
 
24
24
  const AGENT_BLOCK_BODY = `## SmallDocs
25
25
 
@@ -49,6 +49,7 @@ Each command below prints its reference when run with no arguments - run it befo
49
49
  - \`sdoc diagrams\` - rendering inline Mermaid diagrams (\`\`\`mermaid blocks; has full-screen mode for zoom). Reach for this when drawing system or architectural diagrams (sequence, flow, component layout) - a diagram often communicates the shape of something faster than the equivalent prose.
50
50
  - \`sdoc slides\` - inline slide decks (\`\`\`slide / ~~~slide blocks; has full-screen presentation mode). Slides can be standalone exported as \`.pdf\` or \`.pptx\`. \`sdoc present file.md\` - open file directly in fullscreen presentation mode.
51
51
  - \`sdoc cells\` - rendering spreadsheets (\`\`\`cells blocks): CSV rows where plain values and =formulas (SUM, AVERAGE, IF, ROUND...) sit in the same grid and compute live. The reader can sort, select ranges for quick stats, edit a scratch copy fullscreen, and download the sheet as Excel (.xlsx) with the formulas still working. Name a block (\`\`\`cells Expenses) to build a workbook of several tabs whose formulas reference each other across sheets (\`=Expenses!B4\`); run \`sdoc cells verify file.md\` to compute the whole workbook headlessly and read the values back. Reach for this when handing the user numbers they will want to check or play with - totals, budgets, projections. \`sdoc report.csv\` opens a CSV file directly as a sheet.
52
+ - \`sdoc code\` - opening a source file or a fenced code block as a syntax-highlighted listing: a light code viewer for reading code with the user away from the IDE. \`sdoc app.rb\` (or \`.js\`, \`.py\`, \`.go\`, \`.rs\`, \`.ts\`...) opens a file as a highlighted listing; a \`\`\`lang fenced block is highlighted inline. Comments in the source get a prominent lane so the code reads clearly top to bottom. The fullscreen view adds a line-number gutter and language-aware folding (collapse a whole method or class); a comment mode lets the user annotate a line or method with review notes, kept in the browser rather than the file. You can also pin your own explanations to lines as you open a file - \`sdoc app.py 22:"this method has the bug" 25-28:"wrong comparison here"\` - and they render as markdown callouts below those lines, a way to walk the user through code without editing the source. The file rides in the URL like any document; nothing is uploaded. Reach for it when reading or reviewing code with the user, not for prose.
52
53
  - \`sdoc schema\` - styling Markdown (fonts, colors, spacing). The default styles are already comfortable to read; reach for this only when they aren't enough - client-facing polish or a bit of fun.
53
54
  - \`sdoc feedback\` - rendering interactive elements (\`\`\`form blocks) to receive structured input from the user. Run \`sdoc feedback file.md\` and the user's submission lands as a JSON line on stdout. Good for eliciting complex/subtle feedback. All standard interactive HTML elements with prefilled (but editable) content of your choosing.
54
55
  `;
@@ -22,6 +22,8 @@ const { startBridge } = require('../bin/sdocs-bridge');
22
22
  const { DEFAULT_URL } = require('./constants');
23
23
  const { openBrowser } = require('./io');
24
24
  const { stripAndCompress } = require('./url');
25
+ const { wrapForDisplay } = require('./file-wrap');
26
+ const { isCodeFile } = require('./code-langs');
25
27
 
26
28
  function baseUrlFor(opts) {
27
29
  return opts.url || process.env.SDOCS_URL || DEFAULT_URL;
@@ -41,7 +43,20 @@ function bridgeSnapshot(file) {
41
43
  if (!file) return null;
42
44
  if (WRAPPED_EXT.has(path.extname(file).toLowerCase())) return null;
43
45
  try {
44
- return stripAndCompress(fs.readFileSync(file, 'utf-8'));
46
+ const raw = fs.readFileSync(file, 'utf-8');
47
+ // A code file's renderable document is the file wrapped in a fence (the same
48
+ // transform the live bridge applies). The snapshot must embed THAT, not the
49
+ // raw source: otherwise the read-only fallback - which is what every page
50
+ // refresh renders until the socket reconnects - shows the file as markdown
51
+ // (docstrings as prose, indented bodies as stray code blocks) instead of the
52
+ // fullscreen code view with its folding and comments. Carry the filename in
53
+ // front matter so the snapshot is self-contained: the code view names it and
54
+ // auto-expands without depending on the bridge's `file=` param surviving.
55
+ if (isCodeFile(file)) {
56
+ const doc = '---\nfile: ' + path.basename(file) + '\n---\n' + wrapForDisplay(raw, file);
57
+ return stripAndCompress(doc);
58
+ }
59
+ return stripAndCompress(raw);
45
60
  } catch (_) {
46
61
  return null;
47
62
  }
@@ -166,6 +181,7 @@ function feedbackCommand(opts) { return runBridge(opts, 'feedback', 'Feedback o
166
181
  module.exports = {
167
182
  runBridgedOpen,
168
183
  feedbackCommand,
169
- buildBridgeUrl, // for tests
170
- timeoutOpts, // for tests
184
+ buildBridgeUrl, // for tests
185
+ bridgeSnapshot, // for tests
186
+ timeoutOpts, // for tests
171
187
  };
@@ -86,29 +86,7 @@ function wrapCsvFile(csv, filename) {
86
86
  String(csv).replace(/\s+$/, '') + '\n```\n';
87
87
  }
88
88
 
89
- // A "wrapped" file is one whose renderable document is DERIVED from the file
90
- // (its contents inside a fenced block) rather than the file itself. The two
91
- // open paths (URL snapshot via readContent, live sync via the bridge) both
92
- // wrap these for display, and the bridge refuses to save the derived view
93
- // back - that would overwrite the .csv / .mmd with fence markup.
94
- function isWrappedFile(filePath) {
95
- return /\.(csv|mmd|mermaid)$/i.test(String(filePath || ''));
96
- }
97
-
98
- // File contents -> renderable document. Wrapped types get their fence;
99
- // everything else (markdown) passes through untouched.
100
- function wrapForDisplay(raw, filePath) {
101
- var name = String(filePath || '');
102
- if (/\.(mmd|mermaid)$/i.test(name)) {
103
- return '```mermaid\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
104
- }
105
- if (/\.csv$/i.test(name)) return wrapCsvFile(raw, name);
106
- return String(raw);
107
- }
108
-
109
89
  module.exports = {
110
90
  transcludeCells: transcludeCells,
111
91
  wrapCsvFile: wrapCsvFile,
112
- isWrappedFile: isWrappedFile,
113
- wrapForDisplay: wrapForDisplay,
114
92
  };
@@ -31,30 +31,44 @@ function scanCellsBlocks(md) {
31
31
  var blocks = [];
32
32
  var m;
33
33
  while ((m = re.exec(md))) {
34
- blocks.push({ name: (m[2] || '').trim().replace(/"/g, ''), body: m[3] });
34
+ blocks.push({ info: (m[2] || '').replace(/"/g, ''), body: m[3] });
35
35
  }
36
36
  return blocks;
37
37
  }
38
38
 
39
- // Parse + name every block into a workbook, mirroring the renderer: a fence
40
- // name wins, then an explicit `name=` directive, then auto Sheet1/Sheet2... by
41
- // order among the blocks that became real grids. Non-grid blocks (empty /
42
- // unresolved) are skipped; a parse error is recorded so the exit code reflects it.
39
+ // Parse + name + group every block into workbooks, mirroring the renderer.
40
+ // Each block's fence info is split into a workbook id and a sheet name (the
41
+ // first "/" separates them); a baked `workbook=` / `name=` directive is the
42
+ // fallback. Blocks bucket by workbook id in first-seen order; within each
43
+ // workbook a fence/directive name wins, else auto Sheet1/Sheet2..., and both
44
+ // the auto counter and duplicate detection reset per workbook. recalcWorkbook
45
+ // later resolves references only within one workbook, so a Sheet!A1 into
46
+ // another workbook reads #REF! - the same isolation the browser enforces.
43
47
  function buildWorkbook(md) {
44
48
  var blocks = scanCellsBlocks(md);
45
- var sheets = []; // { name, model }
49
+ var order = []; // workbook ids, first-seen order
50
+ var byWb = {}; // id -> [{ name, model }]
46
51
  var parseErrors = [];
47
- var autoIdx = 0;
48
52
  for (var i = 0; i < blocks.length; i++) {
49
53
  var model;
50
54
  try { model = CELLS.parseCells(blocks[i].body); }
51
55
  catch (e) { parseErrors.push((e && e.message) || 'parse error'); continue; }
52
56
  if (model.error) { parseErrors.push(model.error); continue; }
53
57
  if (model.unresolved || model.empty) continue;
54
- var name = blocks[i].name || (model.name && String(model.name).trim()) || ('Sheet' + (++autoIdx));
55
- sheets.push({ name: name, model: model });
58
+ var fence = CELLS.parseFenceInfo(blocks[i].info || '');
59
+ var wbId = fence.workbook || (model.workbook != null ? String(model.workbook) : '');
60
+ var explicitName = fence.name || (model.name && String(model.name).trim()) || '';
61
+ if (!byWb[wbId]) { byWb[wbId] = []; order.push(wbId); }
62
+ byWb[wbId].push({ name: explicitName, model: model });
56
63
  }
57
- return { sheets: sheets, parseErrors: parseErrors };
64
+ var workbooks = order.map(function (id) {
65
+ var autoIdx = 0;
66
+ var sheets = byWb[id].map(function (s) {
67
+ return { name: s.name || ('Sheet' + (++autoIdx)), model: s.model };
68
+ });
69
+ return { id: id, sheets: sheets };
70
+ });
71
+ return { workbooks: workbooks, parseErrors: parseErrors };
58
72
  }
59
73
 
60
74
  // Render one sheet's computed grid to a 2D array of display strings: a formula
@@ -111,17 +125,24 @@ async function cellsVerifyCommand(opts) {
111
125
  }
112
126
 
113
127
  var wb = buildWorkbook(content);
114
- var fxGrids = FX.recalcWorkbook(wb.sheets.map(function (s) {
115
- return { name: s.name, model: s.model };
116
- }));
117
128
 
118
- // Build the per-sheet view once; --sheet filters it afterwards.
119
- var rendered = wb.sheets.map(function (s, i) {
120
- return {
121
- name: s.name,
122
- values: valuesFor(s.model, fxGrids[i] || []),
123
- errors: errorsFor(s.name, s.model, fxGrids[i] || []),
124
- };
129
+ // Recalc each workbook in isolation, then flatten to a per-sheet view that
130
+ // remembers which workbook each sheet came from. --sheet filters it after.
131
+ var rendered = [];
132
+ wb.workbooks.forEach(function (book) {
133
+ var fxGrids = FX.recalcWorkbook(book.sheets.map(function (s) {
134
+ return { name: s.name, model: s.model };
135
+ }));
136
+ book.sheets.forEach(function (s, i) {
137
+ var errs = errorsFor(s.name, s.model, fxGrids[i] || []);
138
+ if (book.id) errs.forEach(function (e) { e.workbook = book.id; });
139
+ rendered.push({
140
+ workbook: book.id,
141
+ name: s.name,
142
+ values: valuesFor(s.model, fxGrids[i] || []),
143
+ errors: errs,
144
+ });
145
+ });
125
146
  });
126
147
 
127
148
  if (opts.sheetName) {
@@ -141,14 +162,19 @@ async function cellsVerifyCommand(opts) {
141
162
  if (opts.jsonFlag) {
142
163
  process.stdout.write(JSON.stringify({
143
164
  ok: ok,
144
- sheets: rendered.map(function (r) { return { name: r.name, values: r.values }; }),
165
+ sheets: rendered.map(function (r) {
166
+ var o = { name: r.name, values: r.values };
167
+ if (r.workbook) o.workbook = r.workbook;
168
+ return o;
169
+ }),
145
170
  errors: allErrors,
146
171
  parseErrors: wb.parseErrors,
147
172
  }, null, 2) + '\n');
148
173
  } else {
149
174
  if (!rendered.length) console.error('sdoc: no cells tabs found in ' + file);
150
175
  var chunks = rendered.map(function (r) {
151
- return '# sheet: ' + r.name + '\n' + CELLS.serializeCsv(r.values);
176
+ var banner = r.workbook ? ('# workbook: ' + r.workbook + ' / sheet: ' + r.name) : ('# sheet: ' + r.name);
177
+ return banner + '\n' + CELLS.serializeCsv(r.values);
152
178
  });
153
179
  if (chunks.length) process.stdout.write(chunks.join('\n') + '\n');
154
180
  wb.parseErrors.forEach(function (e) { console.error('sdoc: cells parse error - ' + e); });
@@ -0,0 +1,85 @@
1
+ // code-langs.js - map a source file's extension to a fenced code block.
2
+ //
3
+ // `sdoc app.rb` should open as a syntax-highlighted Ruby document, the same way
4
+ // `sdoc chart.mmd` opens as a diagram. The CLI can't highlight anything itself;
5
+ // it just wraps the file in a ```<lang> fence and the browser's highlight.js
6
+ // does the colouring. So all this module decides is: which fence label?
7
+ //
8
+ // The label is the highlight.js language name (or one of its aliases), so the
9
+ // fence the CLI writes is the same one a user would type by hand.
10
+
11
+ var path = require('path');
12
+
13
+ // Extension (no dot, lowercase) -> fence label. Markdown and plain text are
14
+ // intentionally absent: those open as documents, not as code listings.
15
+ var LANG_BY_EXT = {
16
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
17
+ ts: 'typescript', tsx: 'typescript',
18
+ py: 'python', pyw: 'python',
19
+ rb: 'ruby', rake: 'ruby',
20
+ go: 'go',
21
+ rs: 'rust',
22
+ java: 'java',
23
+ kt: 'kotlin', kts: 'kotlin',
24
+ swift: 'swift',
25
+ c: 'c', h: 'c',
26
+ cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp',
27
+ cs: 'csharp',
28
+ php: 'php',
29
+ scala: 'scala',
30
+ ex: 'elixir', exs: 'elixir',
31
+ erl: 'erlang',
32
+ clj: 'clojure', cljs: 'clojure',
33
+ hs: 'haskell',
34
+ lua: 'lua',
35
+ pl: 'perl', pm: 'perl',
36
+ r: 'r',
37
+ dart: 'dart',
38
+ sh: 'bash', bash: 'bash', zsh: 'bash',
39
+ ps1: 'powershell',
40
+ sql: 'sql',
41
+ yml: 'yaml', yaml: 'yaml',
42
+ toml: 'toml',
43
+ ini: 'ini', cfg: 'ini', conf: 'ini',
44
+ json: 'json',
45
+ xml: 'xml', svg: 'xml',
46
+ html: 'xml', htm: 'xml',
47
+ css: 'css',
48
+ scss: 'scss', sass: 'scss',
49
+ less: 'less',
50
+ dockerfile: 'dockerfile',
51
+ diff: 'diff', patch: 'diff',
52
+ graphql: 'graphql', gql: 'graphql',
53
+ proto: 'protobuf'
54
+ };
55
+
56
+ function extOf(filePath) {
57
+ var name = path.basename(String(filePath || '')).toLowerCase();
58
+ // Dotfiles named exactly like a known type, e.g. "Dockerfile".
59
+ if (name === 'dockerfile') return 'dockerfile';
60
+ var ext = path.extname(name);
61
+ return ext ? ext.slice(1) : '';
62
+ }
63
+
64
+ // The highlight.js language for a path, or '' if we don't wrap it as code.
65
+ function langForFile(filePath) {
66
+ return LANG_BY_EXT[extOf(filePath)] || '';
67
+ }
68
+
69
+ function isCodeFile(filePath) {
70
+ return !!langForFile(filePath);
71
+ }
72
+
73
+ // File contents -> a fenced code document. Trailing whitespace is trimmed so a
74
+ // file's final newline doesn't render as an empty last line in the block.
75
+ function wrapCodeFile(raw, filePath) {
76
+ var lang = langForFile(filePath);
77
+ return '```' + lang + '\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
78
+ }
79
+
80
+ module.exports = {
81
+ LANG_BY_EXT: LANG_BY_EXT,
82
+ langForFile: langForFile,
83
+ isCodeFile: isCodeFile,
84
+ wrapCodeFile: wrapCodeFile
85
+ };
package/lib/commands.js CHANGED
@@ -36,11 +36,19 @@ async function prepareUrl(opts) {
36
36
  }
37
37
 
38
38
  // Inject `file:` into front matter (basename only — safe to share).
39
- // Respects user-set file: if already present.
39
+ // Respects user-set file: if already present. Agent annotations
40
+ // (`sdoc app.py 22:"..."`) ride here too, in `annotations:`, so they travel
41
+ // with the link and through `sdoc share` (front matter rides in the body,
42
+ // unlike `local` which share strips).
40
43
  if (content && opts.file) {
41
44
  const parsed = SDocYaml.parseFrontMatter(content);
42
- if (!parsed.meta.file) {
43
- parsed.meta.file = path.basename(opts.file);
45
+ let changed = false;
46
+ if (!parsed.meta.file) { parsed.meta.file = path.basename(opts.file); changed = true; }
47
+ if (opts.annotations && opts.annotations.length) {
48
+ parsed.meta.annotations = opts.annotations;
49
+ changed = true;
50
+ }
51
+ if (changed) {
44
52
  content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
45
53
  }
46
54
  }
@@ -0,0 +1,39 @@
1
+ // file-wrap.js - turn a file's contents into a renderable document.
2
+ //
3
+ // Some file types don't open as themselves: a .csv opens as a sheet, a .mmd as
4
+ // a diagram, a .rb as a highlighted listing. For those, the document SDocs
5
+ // renders is DERIVED from the file (its contents inside a fenced block) rather
6
+ // than the file itself. This module is the one dispatcher that decides which
7
+ // wrapping applies. Markdown and plain text are not wrapped - they open as-is.
8
+ //
9
+ // Both open paths go through here: readContent (the URL-snapshot path, in
10
+ // io.js) and the bridge (the live-sync path). The bridge also reads
11
+ // isWrappedFile to refuse saving a derived view back over the source file -
12
+ // that would overwrite the .csv / .mmd / .rb with fence markup.
13
+
14
+ const { wrapCsvFile } = require('./cells-transclude');
15
+ const codeLangs = require('./code-langs');
16
+
17
+ // A "wrapped" file is one whose renderable document is derived from a fence
18
+ // around its contents, not the raw file.
19
+ function isWrappedFile(filePath) {
20
+ var name = String(filePath || '');
21
+ return /\.(csv|mmd|mermaid)$/i.test(name) || codeLangs.isCodeFile(name);
22
+ }
23
+
24
+ // File contents -> renderable document. Wrapped types get their fence;
25
+ // everything else (markdown) passes through untouched.
26
+ function wrapForDisplay(raw, filePath) {
27
+ var name = String(filePath || '');
28
+ if (/\.(mmd|mermaid)$/i.test(name)) {
29
+ return '```mermaid\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
30
+ }
31
+ if (/\.csv$/i.test(name)) return wrapCsvFile(raw, name);
32
+ if (codeLangs.isCodeFile(name)) return codeLangs.wrapCodeFile(raw, name);
33
+ return String(raw);
34
+ }
35
+
36
+ module.exports = {
37
+ isWrappedFile: isWrappedFile,
38
+ wrapForDisplay: wrapForDisplay,
39
+ };
package/lib/help-text.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Long help strings printed by `sdoc help`, `sdoc schema`, `sdoc charts`,
2
- // `sdoc diagrams`, `sdoc comments`. Kept as plain data with no logic.
2
+ // `sdoc diagrams`, `sdoc code`, `sdoc comments`. Kept as plain data with no logic.
3
3
 
4
4
  const HELP = `
5
5
  SmallDocs CLI
@@ -21,7 +21,10 @@ USAGE
21
21
  sdoc schema Print the full styles schema
22
22
  sdoc charts Chart types, options, and styling guide
23
23
  sdoc diagrams Mermaid diagrams reference (\`\`\`mermaid blocks)
24
+ sdoc videos YouTube embed reference (\`\`\`video blocks)
24
25
  sdoc cells Inline spreadsheet reference (\`\`\`cells blocks)
26
+ sdoc code Syntax highlighting + code-viewer reference
27
+ sdoc app.rb / server.js / ... Open a source file as a highlighted listing
25
28
  sdoc color-analysis <file> Check custom colours for readable contrast
26
29
  (both themes). Run after styling a doc.
27
30
  sdoc comments Comment-format reference (for agents)
@@ -926,6 +929,55 @@ EXAMPLE
926
929
  `;
927
930
 
928
931
 
932
+ const VIDEOS_HELP = `
933
+ SmallDocs - Videos
934
+ ==================
935
+ Embed a YouTube video in markdown using a \`\`\`video fenced code block.
936
+ The body is a YouTube URL (any common shape) or a bare 11-character
937
+ video id, with optional \`title:\` and \`start:\` lines.
938
+
939
+ BASIC SYNTAX
940
+ \`\`\`video
941
+ https://www.youtube.com/watch?v=dQw4w9WgXcQ
942
+ \`\`\`
943
+
944
+ Bare id, short link, embed link and shorts link all work:
945
+ \`\`\`video
946
+ dQw4w9WgXcQ
947
+ \`\`\`
948
+
949
+ WITH A TITLE AND START TIME
950
+ \`\`\`video
951
+ https://youtu.be/dQw4w9WgXcQ
952
+ title: Our launch walkthrough
953
+ start: 1:30
954
+ \`\`\`
955
+ \`start:\` accepts seconds (90), clock (1:30, 1:02:03) or 1m30s / 1h2m3s.
956
+ A \`t=\` parameter already on the URL is used when no \`start:\` is given.
957
+ \`title:\` sets the iframe title (accessibility) and a caption link.
958
+
959
+ WHAT RENDERS
960
+ A responsive 16:9 player that scales with the body column. The embed
961
+ host is youtube-nocookie.com, so no tracking cookie is set until the
962
+ viewer presses play. \`rel=0\` is applied, which limits the end-screen
963
+ suggestions to the source channel (YouTube no longer allows fully
964
+ removing them from an embed).
965
+
966
+ LIMITS
967
+ - Per-block source cap: 8 KB.
968
+ - Per-document video cap: 50 (excess rendered as plain code).
969
+
970
+ SECURITY
971
+ No iframe ever travels through the document markup - SmallDocs still
972
+ strips iframes from ordinary markdown. The renderer reads only the
973
+ block text, extracts the 11-character video id, validates it against
974
+ \`[A-Za-z0-9_-]{11}\`, and builds the player from a src it constructs
975
+ itself. Anything that is not a recognised YouTube URL or a valid id
976
+ renders as an inline error, never an embed. Treat block source as
977
+ untrusted - it travels in the URL hash with the rest of the document.
978
+ `;
979
+
980
+
929
981
  const CELLS_HELP = `
930
982
  SmallDocs - Cells (sheets)
931
983
  ======================
@@ -1047,6 +1099,32 @@ MULTIPLE TABS (SHEETS)
1047
1099
  tabs collapse into a single widget (a tab strip + one grid at a time),
1048
1100
  placed where the first tab sat.
1049
1101
 
1102
+ WORKBOOK GROUPS (independent tab sets in one document)
1103
+ Name a workbook before the sheet to split a document into separate,
1104
+ independent workbooks. The part before the first "/" is the workbook, the
1105
+ part after is the sheet name:
1106
+
1107
+ \`\`\`cells financials/Model
1108
+ Line,2025,2026
1109
+ Revenue,=Drivers!B2,=Drivers!C2
1110
+ \`\`\`
1111
+
1112
+ \`\`\`cells financials/Drivers
1113
+ Driver,2025,2026
1114
+ Seats,800,2500
1115
+ \`\`\`
1116
+
1117
+ Blocks that share a workbook id form one workbook: their own tab strip (under
1118
+ cells-tabs: tabbed), their own sheet-name namespace, and their own formula
1119
+ scope. Two workbooks on a page render as two separate tab strips. A Sheet!A1
1120
+ reference into another workbook reads #REF! - the isolation is total, so two
1121
+ workbooks may reuse a sheet name without colliding. A block with no "/" stays
1122
+ in the default workbook and behaves exactly as before.
1123
+
1124
+ In the fullscreen view a "Download workbook" button exports the whole
1125
+ workbook as one .xlsx, every sheet and its cross-sheet formulas intact; the
1126
+ per-sheet download on each grid still exports just that one sheet.
1127
+
1050
1128
  VERIFYING (for agents)
1051
1129
  Check the computed values without a browser:
1052
1130
 
@@ -1060,9 +1138,11 @@ VERIFYING (for agents)
1060
1138
  every tab computes cleanly and 1 when any cell errors, so an agent can gate
1061
1139
  on it. Write formulas, run verify, read the values back, fix, repeat.
1062
1140
 
1063
- The default output banners each tab with "# sheet: <name>". A data row
1064
- could itself start with that text, so for machine parsing use --json (its
1065
- per-tab values array is unambiguous).
1141
+ The default output banners each tab with "# sheet: <name>", or "# workbook:
1142
+ <id> / sheet: <name>" when the sheet sits in a named workbook (each workbook
1143
+ is verified in isolation). A data row could itself start with that text, so
1144
+ for machine parsing use --json (its per-tab values array, and each tab's
1145
+ optional workbook field, are unambiguous).
1066
1146
 
1067
1147
  SORTING
1068
1148
  Hover a column letter: an arrow appears on its right showing what a click
@@ -1815,7 +1895,8 @@ navigate.
1815
1895
  errors + the full slide source — for pasting back to an agent.
1816
1896
 
1817
1897
  Common errors:
1818
- - "shape extends outside grid WxH" y+h > H (or x+w > W)
1898
+ - "shape extends outside grid WxH" bbox past an edge by >10% of the
1899
+ grid (a little bleed is allowed)
1819
1900
  - \`unknown id "@name"\` @-ref before the shape is declared
1820
1901
  - \`duplicate id "#name"\` two shapes share an id
1821
1902
  - \`invalid attribute key\` key must start with a letter
@@ -1939,6 +2020,37 @@ rather than as designed.
1939
2020
  default. Spend visual weight only on the one or two things the
1940
2021
  slide is about. Empty space is half the design.
1941
2022
 
2023
+ Text alignment.
2024
+ Except for slides with very few elements, or custom shapes with
2025
+ very few words, prefer left-aligned text throughout a slide.
2026
+ Left-aligning most elements - particularly any with more than
2027
+ three words - creates a calm, confident slide that is easy to
2028
+ follow.
2029
+
2030
+ An implicit grid.
2031
+ Take inspiration from Josef Muller-Brockmann's work on grid
2032
+ systems. Consistency of alignment between elements gives off the
2033
+ feeling of considered, structured thinking. Unless you are
2034
+ creating something specific from custom shapes, prefer to build
2035
+ an implicit grid on the slide. A presentation feels even more
2036
+ professional when that grid language flows from slide to slide.
2037
+
2038
+ Branding, borders and background.
2039
+ Less is often more. Precise layouts are hard with custom shapes:
2040
+ overlapping positions may not land where you expect, and you
2041
+ cannot easily check how the slide feels. So when you give a slide
2042
+ a mood through coloured background shapes, keep it relatively
2043
+ simple. This should not stop you being bold, but design with a
2044
+ few key shapes (which may overlap), not many overlapping ones.
2045
+
2046
+ Contrast and legibility.
2047
+ Consider the colour of text against the colour of whatever falls
2048
+ directly beneath it. Readers should be able to calmly take text
2049
+ in from a slide, so ensure there is healthy contrast, at least,
2050
+ between text and the colour beneath it. In general, try to
2051
+ overlay text on a single patch of colour (or very closely
2052
+ related colours) to keep it straightforwardly legible.
2053
+
1942
2054
  ── SHAPE KINDS ───────────────────────────────────────
1943
2055
  r x y w h rectangle (x,y = top-left; w,h = size)
1944
2056
  i x y w h image rect (sugar for \`r\` with \`image=\`; see IMAGES)
@@ -2039,6 +2151,12 @@ rather than as designed.
2039
2151
  so the layout doesn't reflow when
2040
2152
  icons swap in.
2041
2153
 
2154
+ Pair an icon with its label as two separate shapes - the icon in
2155
+ its own small box, the text in a shape beside or below it. Don't
2156
+ put \`|\` text on an icon (it overlaps the glyph) or drop an icon
2157
+ onto a text shape. Keep every icon in a set the same size (w = h)
2158
+ on a shared row.
2159
+
2042
2160
  Arrow geometry: the coordinates are the line's centerline. The head is
2043
2161
  symmetric around the line, extending up to 3 * strokeWidth perpendicular
2044
2162
  on each side. So a horizontal arrow at y=5.78 with strokeWidth=0.06
@@ -2249,6 +2367,18 @@ rather than as designed.
2249
2367
  The three cards then read as a parallel set rather than three
2250
2368
  improvisations.
2251
2369
 
2370
+ 5. Icons collide with text when they share a box.
2371
+
2372
+ The \`icon\` shape paints its glyph across its whole bbox, so
2373
+ text in the same box - or a text shape the icon sits on top of
2374
+ - overlaps it. Keep them as two shapes on one row:
2375
+
2376
+ icon 8 20 4 4 name=recycle color=#0165a5
2377
+ r 13 20 26 4 text=body align=left valign=center | Repairable by design
2378
+
2379
+ Icon in its own 4x4 box; the label starts after it at x=13,
2380
+ centred on the same y. Size every icon in a set the same.
2381
+
2252
2382
  ── SHAPE ATTRIBUTES ──────────────────────────────────
2253
2383
  Between geometry and \`|\`:
2254
2384
 
@@ -2756,4 +2886,65 @@ COMMON QUESTIONS
2756
2886
  drop a \`.sdocsignore\` into the directory with the pattern.
2757
2887
  `;
2758
2888
 
2759
- module.exports = { HELP, COMMENTS_HELP, SCHEMA, CHARTS_HELP, DIAGRAMS_HELP, CELLS_HELP, SLIDES_HELP, SLIDES_CUSTOM_SHAPES_HELP, LIBRARY_HELP };
2889
+ const CODE_HELP = `
2890
+ SDocs - Code (syntax highlighting)
2891
+ ==================================
2892
+ Fenced code blocks are syntax-highlighted by language. Tag the fence with a
2893
+ language and the block is coloured in the browser; highlight.js is loaded from
2894
+ a CDN on first use, so a document with no code blocks pays nothing.
2895
+
2896
+ BASIC SYNTAX
2897
+ Tag the opening fence with a language name:
2898
+
2899
+ \`\`\`ruby
2900
+ def greet(name)
2901
+ # comments are styled to stand out, not fade away
2902
+ puts "hello, #{name}"
2903
+ end
2904
+ \`\`\`
2905
+
2906
+ An untagged fence stays plain (no guessing). Blocks claimed by other
2907
+ features - chart, mermaid, cells, form, math, slide - are never treated
2908
+ as code.
2909
+
2910
+ OPENING A SOURCE FILE
2911
+ Point sdoc at a source file and it opens as a highlighted listing:
2912
+
2913
+ sdoc app.rb
2914
+ sdoc server.js
2915
+ sdoc main.go
2916
+
2917
+ The file travels in the URL like any document; nothing is uploaded. The
2918
+ fence label is chosen from the extension (.rb -> ruby, .py -> python,
2919
+ .ts -> typescript, and so on). Markdown and plain text open as documents,
2920
+ not as code.
2921
+
2922
+ ANNOTATIONS (walk someone through the code)
2923
+ Pin a markdown explanation to a line as you open the file:
2924
+
2925
+ sdoc app.py 22:"this method is where the bug is"
2926
+ sdoc app.py 25-28:"the equality check compares the **wrong** value"
2927
+
2928
+ Each note renders as a callout below its line (a range stripes the lines it
2929
+ covers), with markdown inside - bold, inline code, links. They ride in the
2930
+ link and through \`sdoc share\`, and never touch the source file. This is for
2931
+ an agent explaining code to the reader; the in-browser comment mode
2932
+ (--comment) is the reverse - the reader's own review notes.
2933
+
2934
+ COMMENTS
2935
+ Comments are deliberately prominent - italic, full-contrast colour, a faint
2936
+ tint - rather than the usual muted grey, so the human explanation in a
2937
+ listing is the easiest part to read.
2938
+
2939
+ STYLING
2940
+ Token colours track the light/dark theme. The block background follows the
2941
+ same control as other code blocks (Blocks / Code in the style panel).
2942
+
2943
+ SUPPORTED LANGUAGES
2944
+ The common set ships in the core bundle (javascript, typescript, python,
2945
+ ruby, go, rust, java, c, cpp, csharp, php, bash, sql, yaml, json, xml,
2946
+ css, and more). Less common languages are fetched on demand the first time
2947
+ they appear. An unknown language label renders as plain text.
2948
+ `;
2949
+
2950
+ module.exports = { HELP, COMMENTS_HELP, SCHEMA, CHARTS_HELP, DIAGRAMS_HELP, VIDEOS_HELP, CELLS_HELP, CODE_HELP, SLIDES_HELP, SLIDES_CUSTOM_SHAPES_HELP, LIBRARY_HELP };
package/lib/io.js CHANGED
@@ -3,11 +3,12 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
- const { transcludeCells, isWrappedFile, wrapForDisplay } = require('./cells-transclude');
6
+ const { transcludeCells } = require('./cells-transclude');
7
+ const { isWrappedFile, wrapForDisplay } = require('./file-wrap');
7
8
 
8
9
  const SUBCOMMANDS = new Set([
9
10
  'new', 'share', 'schema', 'defaults', 'help', 'version',
10
- 'charts', 'diagrams', 'cells', 'comments',
11
+ 'charts', 'diagrams', 'videos', 'video', 'cells', 'code', 'comments',
11
12
  'setup', 'safe', 'auto-update', 'refresh', 'upgrade',
12
13
  'bridge', 'feedback',
13
14
  'slides', 'present',
@@ -20,6 +21,14 @@ const SUBCOMMANDS = new Set([
20
21
  // front matter is the only place SDocs stores tags.
21
22
  const TAG_ARG = /^\+[A-Za-z][\w-]{0,63}$/;
22
23
 
24
+ // Agent annotation arguments: `N:"text"` or `N-M:"text"` (the shell removes the
25
+ // quotes, so the token arrives as `22:this is the bug`). Line numbers are
26
+ // 1-based, matching what the reader sees in the gutter. Collected into an
27
+ // annotations list and written into the opened file's front matter so they
28
+ // travel with the link. The part before the colon must be digits (an optional
29
+ // range), so file paths like `app.py` never match.
30
+ const ANNOTATION_ARG = /^(\d+)(?:-(\d+))?:([\s\S]+)$/;
31
+
23
32
  function parseArgs(argv) {
24
33
  const args = argv || process.argv.slice(2);
25
34
  let file = null;
@@ -46,6 +55,7 @@ function parseArgs(argv) {
46
55
  let dryRunFlag = false;
47
56
  let sheetName = null;
48
57
  const addTags = [];
58
+ const annotations = [];
49
59
 
50
60
  for (let i = 0; i < args.length; i++) {
51
61
  const arg = args[i];
@@ -108,6 +118,20 @@ function parseArgs(argv) {
108
118
  // front matter at open time.
109
119
  if (TAG_ARG.test(arg)) { addTags.push(arg.slice(1).toLowerCase()); continue; }
110
120
 
121
+ // Agent annotation: `N:"text"` / `N-M:"text"`. Captured before the file /
122
+ // extra slots so a `22:...` token is never mistaken for a path.
123
+ const ann = ANNOTATION_ARG.exec(arg);
124
+ if (ann) {
125
+ const start = parseInt(ann[1], 10);
126
+ const end = ann[2] ? parseInt(ann[2], 10) : start;
127
+ // Strip one layer of surrounding quotes if a shell preserved them.
128
+ const text = ann[3].replace(/^"([\s\S]*)"$/, '$1').replace(/^'([\s\S]*)'$/, '$1');
129
+ if (start >= 1 && end >= start && text.trim()) {
130
+ annotations.push({ line: start, endLine: end, text });
131
+ }
132
+ continue;
133
+ }
134
+
111
135
  if (!file) { file = arg; continue; }
112
136
  // Second positional is captured as `extra` so `sdoc slides icons heart`
113
137
  // gets {subcommand: 'slides', file: 'icons', extra: 'heart'}.
@@ -120,7 +144,7 @@ function parseArgs(argv) {
120
144
  messageText, connectTimeoutS, idleTimeoutS, reconnectGraceMs,
121
145
  keepOpenFlag, logFile,
122
146
  tagsFlag, helpFlag, yesFlag, dryRunFlag, sheetName,
123
- addTags,
147
+ addTags, annotations,
124
148
  };
125
149
  }
126
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs-dev",
3
- "version": "1.13.1",
3
+ "version": "1.14.1",
4
4
  "description": "Open, share, and style markdown files from the terminal",
5
5
  "main": "bin/sdocs-dev.js",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "agents"
27
27
  ],
28
28
  "author": "Josh Summers",
29
- "license": "MIT",
29
+ "license": "Elastic-2.0",
30
30
  "repository": {
31
31
  "type": "git",
32
32
  "url": "git+https://github.com/espressoplease/smalldocs.git"
@@ -112,6 +112,20 @@
112
112
  return meta;
113
113
  }
114
114
 
115
+ // Split a fence info string (everything after "cells ") into a workbook id
116
+ // and a sheet name. The first "/" separates them: "financials/Model" ->
117
+ // workbook "financials", name "Model". No slash -> default workbook "" and
118
+ // the whole string is the name. Everything after the first "/" is the name
119
+ // verbatim, so a sheet name may contain slashes; a workbook id may not. This
120
+ // is the one place both the browser renderer and the CLI split the fence, so
121
+ // the two never drift.
122
+ function parseFenceInfo(rest) {
123
+ var s = String(rest == null ? '' : rest).trim();
124
+ var slash = s.indexOf('/');
125
+ if (slash < 0) return { workbook: '', name: s };
126
+ return { workbook: s.slice(0, slash).trim(), name: s.slice(slash + 1).trim() };
127
+ }
128
+
115
129
  // Build the grid model from a ```cells block body.
116
130
  // Returns { rows, cols, cells, empty } where cells is row-major, every row
117
131
  // padded to `cols` with empty cells so the grid is rectangular. May instead
@@ -130,7 +144,7 @@
130
144
  // `name` is the tab name. Authored as the fence info string (```cells
131
145
  // Sales); the renderer + CLI normalise that into this directive so the
132
146
  // name has one home in the model.
133
- var source, range, formats, name;
147
+ var source, range, formats, name, workbook;
134
148
  var lines = trimmed.split('\n');
135
149
  var idx = 0;
136
150
  var FORMAT_RE = /^format:\s*(.*)$/i;
@@ -141,7 +155,8 @@
141
155
  var meta = parseDirectives(dm[1]);
142
156
  source = meta.source; range = meta.range;
143
157
  if (meta.name != null) name = meta.name;
144
- if (meta.error) return { empty: false, error: meta.error, source: source, name: name };
158
+ if (meta.workbook != null) workbook = meta.workbook;
159
+ if (meta.error) return { empty: false, error: meta.error, source: source, name: name, workbook: workbook };
145
160
  idx++; continue;
146
161
  }
147
162
  if (fm) { formats = parseFormats(fm[1]); idx++; continue; }
@@ -151,8 +166,8 @@
151
166
 
152
167
  // Unresolved reference (after directives): the CLI never baked it.
153
168
  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 };
169
+ if (ref) return { empty: false, unresolved: ref[1], formats: formats, name: name, workbook: workbook };
170
+ if (body === '') return { rows: 0, cols: 0, cells: [], empty: true, source: source, range: range, formats: formats, name: name, workbook: workbook };
156
171
 
157
172
  var raw = parseCsv(body);
158
173
  var cols = 0;
@@ -168,7 +183,7 @@
168
183
  }
169
184
  cells.push(out);
170
185
  }
171
- return { rows: raw.length, cols: cols, cells: cells, empty: false, source: source, range: range, formats: formats, name: name };
186
+ return { rows: raw.length, cols: cols, cells: cells, empty: false, source: source, range: range, formats: formats, name: name, workbook: workbook };
172
187
  }
173
188
 
174
189
  // Serialize a 2D array of raw cell strings back to CSV (RFC 4180 quoting:
@@ -378,6 +393,7 @@
378
393
  exports.classify = classify;
379
394
  exports.parseCsv = parseCsv;
380
395
  exports.parseCells = parseCells;
396
+ exports.parseFenceInfo = parseFenceInfo;
381
397
  exports.serializeCsv = serializeCsv;
382
398
  exports.selectionStats = selectionStats;
383
399
  exports.formatNumber = formatNumber;