@entropicwarrior/sdoc 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@entropicwarrior/sdoc",
3
3
  "displayName": "SDOC",
4
4
  "description": "A plain-text documentation format with explicit brace scoping — deterministic parsing, AI-agent efficiency, and 10-50x token savings vs Markdown.",
5
- "version": "0.1.3",
5
+ "version": "0.1.4",
6
6
  "publisher": "entropicwarrior",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -23,6 +23,8 @@
23
23
  ],
24
24
  "exports": {
25
25
  ".": "./index.js",
26
+ "./slides": "./src/slide-renderer.js",
27
+ "./slide-pdf": "./src/slide-pdf.js",
26
28
  "./notion": "./src/notion-renderer.js"
27
29
  },
28
30
  "engines": {
@@ -0,0 +1,99 @@
1
+ // SDOC Slides — PDF export via headless Chrome.
2
+ // Zero dependencies: uses child_process to shell out to the system Chrome/Chromium.
3
+
4
+ const { execFile } = require("child_process");
5
+ const { execFileSync } = require("child_process");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const os = require("os");
9
+
10
+ const CHROME_PATHS = {
11
+ darwin: [
12
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
13
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
14
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
15
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
16
+ ],
17
+ linux: [
18
+ "google-chrome",
19
+ "google-chrome-stable",
20
+ "chromium",
21
+ "chromium-browser",
22
+ ],
23
+ win32: [
24
+ path.join(process.env.PROGRAMFILES || "", "Google", "Chrome", "Application", "chrome.exe"),
25
+ path.join(process.env["PROGRAMFILES(X86)"] || "", "Google", "Chrome", "Application", "chrome.exe"),
26
+ path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
27
+ ],
28
+ };
29
+
30
+ function findChrome() {
31
+ if (process.env.CHROME_PATH) {
32
+ return process.env.CHROME_PATH;
33
+ }
34
+
35
+ const platform = os.platform();
36
+ const candidates = CHROME_PATHS[platform] || [];
37
+
38
+ for (const candidate of candidates) {
39
+ if (path.isAbsolute(candidate)) {
40
+ if (fs.existsSync(candidate)) return candidate;
41
+ } else {
42
+ try {
43
+ const result = execFileSync("which", [candidate], { encoding: "utf-8" }).trim();
44
+ if (result) return result;
45
+ } catch {
46
+ // not found, try next
47
+ }
48
+ }
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ function exportPdf(htmlPath, pdfPath) {
55
+ return new Promise((resolve, reject) => {
56
+ const chrome = findChrome();
57
+ if (!chrome) {
58
+ reject(new Error(
59
+ "Chrome/Chromium not found. Install Google Chrome or set CHROME_PATH environment variable."
60
+ ));
61
+ return;
62
+ }
63
+
64
+ const fileUrl = "file://" + path.resolve(htmlPath);
65
+ const resolvedPdf = path.resolve(pdfPath);
66
+
67
+ // Use a temporary user-data-dir so headless Chrome doesn't conflict
68
+ // with any running browser session (Chrome 112+ new headless shares
69
+ // the browser process by default, which corrupts PDF output).
70
+ const tmpProfile = fs.mkdtempSync(path.join(os.tmpdir(), "sdoc-chrome-"));
71
+
72
+ // 13.333 x 7.5 inches = 16:9 landscape (standard presentation aspect ratio)
73
+ const args = [
74
+ "--headless=new",
75
+ "--disable-gpu",
76
+ "--no-first-run",
77
+ "--no-default-browser-check",
78
+ "--user-data-dir=" + tmpProfile,
79
+ "--no-pdf-header-footer",
80
+ "--print-to-pdf=" + resolvedPdf,
81
+ "--print-to-pdf-paper-width=13.333",
82
+ "--print-to-pdf-paper-height=7.5",
83
+ fileUrl,
84
+ ];
85
+
86
+ execFile(chrome, args, { timeout: 30000 }, (err, _stdout, stderr) => {
87
+ // Clean up temp profile
88
+ fs.rm(tmpProfile, { recursive: true, force: true }, () => {});
89
+
90
+ if (err) {
91
+ reject(new Error(`Chrome PDF export failed: ${err.message}\n${stderr}`));
92
+ } else {
93
+ resolve(resolvedPdf);
94
+ }
95
+ });
96
+ });
97
+ }
98
+
99
+ module.exports = { findChrome, exportPdf };
@@ -0,0 +1,344 @@
1
+ // SDOC Slide Renderer — converts parsed SDOC AST to an HTML slide deck.
2
+ //
3
+ // Usage:
4
+ // const { parseSdoc, extractMeta } = require("./sdoc");
5
+ // const { renderSlides } = require("./slide-renderer");
6
+ // const parsed = parseSdoc(text);
7
+ // const { nodes, meta } = extractMeta(parsed.nodes);
8
+ // const html = renderSlides(nodes, { meta, themeCss, themeJs });
9
+
10
+ const { parseInline, escapeHtml, escapeAttr } = require("./sdoc");
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Inline rendering — produces clean HTML without sdoc-* classes
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function renderInlineNodes(nodes) {
17
+ return nodes
18
+ .map((node) => {
19
+ switch (node.type) {
20
+ case "text":
21
+ return escapeHtml(node.value);
22
+ case "code":
23
+ return `<code>${escapeHtml(node.value)}</code>`;
24
+ case "em":
25
+ return `<em>${renderInlineNodes(node.children)}</em>`;
26
+ case "strong":
27
+ return `<strong>${renderInlineNodes(node.children)}</strong>`;
28
+ case "strike":
29
+ return `<del>${renderInlineNodes(node.children)}</del>`;
30
+ case "link":
31
+ return `<a href="${escapeAttr(node.href)}" target="_blank" rel="noopener noreferrer">${renderInlineNodes(node.children)}</a>`;
32
+ case "image": {
33
+ const imgParts = [];
34
+ if (node.width) imgParts.push(`width:${escapeAttr(node.width)}`);
35
+ if (node.align === "center") imgParts.push("display:block", "margin-left:auto", "margin-right:auto");
36
+ else if (node.align === "left") imgParts.push("display:block", "float:left", "margin-right:1rem");
37
+ else if (node.align === "right") imgParts.push("display:block", "float:right", "margin-left:1rem");
38
+ const imgStyle = imgParts.length ? ` style="${imgParts.join(";")}"` : "";
39
+ return `<img src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt)}"${imgStyle} />`;
40
+ }
41
+ case "ref":
42
+ return `@${escapeHtml(node.id)}`;
43
+ default:
44
+ return "";
45
+ }
46
+ })
47
+ .join("");
48
+ }
49
+
50
+ function renderInline(text) {
51
+ return renderInlineNodes(parseInline(text));
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Node rendering — clean slide HTML
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function renderNode(node) {
59
+ switch (node.type) {
60
+ case "paragraph":
61
+ return `<p>${renderInline(node.text)}</p>`;
62
+ case "list":
63
+ return renderList(node);
64
+ case "table":
65
+ return renderTable(node);
66
+ case "code": {
67
+ if (node.lang === "mermaid") {
68
+ return `<pre class="mermaid">${escapeHtml(node.text)}</pre>`;
69
+ }
70
+ const langClass = node.lang ? ` class="language-${escapeAttr(node.lang)}"` : "";
71
+ return `<pre><code${langClass}>${escapeHtml(node.text)}</code></pre>`;
72
+ }
73
+ case "blockquote": {
74
+ const paragraphs = node.paragraphs
75
+ .map((text) => `<p>${renderInline(text)}</p>`)
76
+ .join("\n");
77
+ return `<blockquote>${paragraphs}</blockquote>`;
78
+ }
79
+ case "hr":
80
+ return `<hr />`;
81
+ case "scope":
82
+ return renderNestedScope(node);
83
+ default:
84
+ return "";
85
+ }
86
+ }
87
+
88
+ function renderList(list) {
89
+ const tag = list.listType === "number" ? "ol" : "ul";
90
+ const items = list.items
91
+ .map((item) => {
92
+ const text = item.title ? renderInline(item.title) : "";
93
+ const children = item.children
94
+ .map((child) => renderNode(child))
95
+ .join("\n");
96
+ const body = children ? `\n${children}` : "";
97
+ return `<li>${text}${body}</li>`;
98
+ })
99
+ .join("\n");
100
+ return `<${tag}>\n${items}\n</${tag}>`;
101
+ }
102
+
103
+ function renderTable(table) {
104
+ const opts = table.options || {};
105
+ const classes = [];
106
+ if (opts.borderless) classes.push("borderless");
107
+ if (opts.headerless) classes.push("headerless");
108
+ const classAttr = classes.length ? ` class="${classes.join(" ")}"` : "";
109
+
110
+ let thead = "";
111
+ if (table.headers.length > 0) {
112
+ const headerCells = table.headers
113
+ .map((cell) => `<th>${renderInline(cell)}</th>`)
114
+ .join("");
115
+ thead = `<thead><tr>${headerCells}</tr></thead>`;
116
+ }
117
+
118
+ const bodyRows = table.rows
119
+ .map((row) => {
120
+ const cells = row.map((cell) => `<td>${renderInline(cell)}</td>`).join("");
121
+ return `<tr>${cells}</tr>`;
122
+ })
123
+ .join("\n");
124
+ const tbody = bodyRows ? `<tbody>\n${bodyRows}\n</tbody>` : "";
125
+
126
+ return `<table${classAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
127
+ }
128
+
129
+ function renderNestedScope(scope) {
130
+ const heading = scope.hasHeading !== false && scope.title
131
+ ? `<h3>${renderInline(scope.title)}</h3>`
132
+ : "";
133
+ const children = scope.children.map((child) => renderNode(child)).join("\n");
134
+ return `<section>${heading}\n${children}</section>`;
135
+ }
136
+
137
+ function renderChildren(nodes) {
138
+ return nodes.map((node) => renderNode(node)).join("\n");
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Slide-level extraction
143
+ // ---------------------------------------------------------------------------
144
+
145
+ // Extracts config: lines from the beginning of a scope's children.
146
+ // Returns { config: { key: value, ... }, contentNodes: [...] }
147
+ function extractSlideConfig(children) {
148
+ const config = {};
149
+ const contentNodes = [];
150
+ let pastConfig = false;
151
+
152
+ for (const child of children) {
153
+ if (!pastConfig && child.type === "paragraph") {
154
+ const match = child.text.match(/^config\s*:\s*(.+)$/i);
155
+ if (match) {
156
+ const value = match[1].trim();
157
+ // Support multiple config lines; last one wins for the same key
158
+ // For now, config values are simple strings; primary use is layout
159
+ config.layout = value;
160
+ continue;
161
+ }
162
+ }
163
+ pastConfig = true;
164
+ contentNodes.push(child);
165
+ }
166
+
167
+ return { config, contentNodes };
168
+ }
169
+
170
+ // Separates @notes child scope from other children
171
+ function extractNotes(children) {
172
+ const notes = [];
173
+ const rest = [];
174
+ for (const child of children) {
175
+ if (child.type === "scope" && child.id && child.id.toLowerCase() === "notes") {
176
+ notes.push(child);
177
+ } else {
178
+ rest.push(child);
179
+ }
180
+ }
181
+ return { notes, contentNodes: rest };
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Slide rendering
186
+ // ---------------------------------------------------------------------------
187
+
188
+ function renderSlide(scope, slideIndex, overlayHtml) {
189
+ const { config, contentNodes: afterConfig } = extractSlideConfig(scope.children);
190
+ const { notes, contentNodes } = extractNotes(afterConfig);
191
+
192
+ const classes = ["slide"];
193
+ if (config.layout) {
194
+ classes.push(config.layout);
195
+ }
196
+
197
+ const title = scope.hasHeading !== false && scope.title
198
+ ? `<h2>${renderInline(scope.title)}</h2>`
199
+ : "";
200
+
201
+ let bodyHtml;
202
+ if (config.layout === "two-column") {
203
+ // In two-column layout, child scopes become columns
204
+ const columns = contentNodes.filter((n) => n.type === "scope");
205
+ const nonColumns = contentNodes.filter((n) => n.type !== "scope");
206
+ const preamble = nonColumns.length ? renderChildren(nonColumns) : "";
207
+ const columnsHtml = columns
208
+ .map((col) => {
209
+ const colTitle = col.hasHeading !== false && col.title
210
+ ? `<h3>${renderInline(col.title)}</h3>`
211
+ : "";
212
+ const colContent = renderChildren(col.children);
213
+ return `<div class="column">${colTitle}\n${colContent}</div>`;
214
+ })
215
+ .join("\n");
216
+ bodyHtml = preamble + `\n<div class="columns">\n${columnsHtml}\n</div>`;
217
+ } else {
218
+ bodyHtml = renderChildren(contentNodes);
219
+ }
220
+
221
+ const notesHtml = notes.length
222
+ ? `\n<aside class="notes">${notes.map((n) => renderChildren(n.children)).join("\n")}</aside>`
223
+ : "";
224
+
225
+ const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
226
+ const overlay = overlayHtml || "";
227
+
228
+ return `<div class="${classes.join(" ")}"${idAttr}>\n${title}\n${bodyHtml}${notesHtml}${overlay}\n</div>`;
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Main entry point
233
+ // ---------------------------------------------------------------------------
234
+
235
+ function renderSlides(nodes, options = {}) {
236
+ const {
237
+ meta = {},
238
+ themeCss = "",
239
+ themeJs = ""
240
+ } = options;
241
+
242
+ // The nodes from extractMeta have @meta already stripped.
243
+ // If there's a document scope wrapper, unwrap it to get the slides.
244
+ let slideScopes;
245
+ if (nodes.length === 1 && nodes[0].type === "scope" && nodes[0].children) {
246
+ slideScopes = nodes[0].children;
247
+ } else {
248
+ slideScopes = nodes;
249
+ }
250
+
251
+ // Filter to scope nodes only (skip stray paragraphs at top level)
252
+ const slides = slideScopes.filter((n) => n.type === "scope");
253
+
254
+ // Build per-slide footer: < CONFIDENTIAL ---gap--- Company >
255
+ const footerParts = [];
256
+ footerParts.push(`<span class="nav-prev">&lsaquo;</span>`);
257
+ if (meta.confidential) {
258
+ const val = meta.confidential.trim();
259
+ const entity = val.toLowerCase() === "true" ? meta.company : val;
260
+ const text = entity
261
+ ? `CONFIDENTIAL \u2014 ${escapeHtml(entity)}`
262
+ : "CONFIDENTIAL";
263
+ footerParts.push(`<span class="sdoc-confidential-notice">${text}</span>`);
264
+ }
265
+ footerParts.push(`<span class="slide-footer-gap"></span>`);
266
+ if (meta.company) {
267
+ footerParts.push(`<span class="sdoc-company-footer">${escapeHtml(meta.company)}</span>`);
268
+ }
269
+ footerParts.push(`<span class="nav-next">&rsaquo;</span>`);
270
+ const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
271
+
272
+ const slidesHtml = slides
273
+ .map((scope, index) => renderSlide(scope, index, overlayHtml))
274
+ .join("\n\n");
275
+
276
+ const title = meta.properties?.title
277
+ || (nodes.length === 1 && nodes[0].title ? nodes[0].title : "Slides");
278
+
279
+ // Structural styles — always injected regardless of theme.
280
+ const structuralCss = `
281
+ .slide { position: relative; }
282
+ .slide-footer {
283
+ position: absolute; bottom: 20px; left: 32px; right: 32px;
284
+ display: flex; align-items: baseline;
285
+ pointer-events: none;
286
+ }
287
+ .slide-footer-gap { flex: 1; }
288
+ .nav-prev, .nav-next {
289
+ font-size: 1.4em; color: #ccc;
290
+ cursor: pointer; pointer-events: auto;
291
+ user-select: none;
292
+ }
293
+ .sdoc-company-footer {
294
+ font-size: 0.7em; color: rgba(0,0,0,0.35);
295
+ letter-spacing: 0.04em;
296
+ margin-right: 0.8em;
297
+ }
298
+ .sdoc-confidential-notice {
299
+ font-size: 0.65em; font-weight: 600;
300
+ letter-spacing: 0.12em; text-transform: uppercase;
301
+ color: rgba(160, 40, 40, 0.6);
302
+ margin-left: 0.8em;
303
+ }
304
+ @media print {
305
+ @page { size: 13.333in 7.5in; margin: 0; }
306
+ body { overflow: visible; height: auto; }
307
+ .slide {
308
+ display: flex !important;
309
+ position: relative !important;
310
+ opacity: 1 !important;
311
+ pointer-events: auto !important;
312
+ page-break-after: always; break-after: page;
313
+ width: 100vw; height: 100vh; max-width: none;
314
+ page-break-inside: avoid; break-inside: avoid;
315
+ }
316
+ .slide:last-child { page-break-after: auto; break-after: auto; }
317
+ .nav-prev, .nav-next { display: none !important; }
318
+ .notes { display: none; }
319
+ }`;
320
+
321
+ const cssTag = `<style>\n${structuralCss}\n${themeCss}\n</style>`;
322
+ const jsTag = themeJs ? `<script>\n${themeJs}\n</script>` : "";
323
+ const mermaidCdn = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
324
+ const mermaidTag = slidesHtml.includes('class="mermaid"')
325
+ ? `\n<script src="${mermaidCdn}"></script>\n<script>mermaid.initialize({startOnLoad:true,theme:"neutral",themeCSS:".node rect, .node polygon, .node circle { rx: 4; ry: 4; }"});</script>`
326
+ : "";
327
+
328
+ return `<!DOCTYPE html>
329
+ <html lang="en">
330
+ <head>
331
+ <meta charset="UTF-8">
332
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
333
+ <title>${escapeHtml(title)}</title>
334
+ ${cssTag}
335
+ </head>
336
+ <body>
337
+ ${slidesHtml}
338
+
339
+ ${jsTag}${mermaidTag}
340
+ </body>
341
+ </html>`;
342
+ }
343
+
344
+ module.exports = { renderSlides, renderSlide, renderNode, renderInline };