@entropicwarrior/sdoc 0.1.5 → 0.1.6
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 +4 -1
- package/tools/sync-notion.js +338 -0
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.
|
|
5
|
+
"version": "0.1.6",
|
|
6
6
|
"publisher": "entropicwarrior",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"preview",
|
|
22
22
|
"ai-agent"
|
|
23
23
|
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"sdoc-sync-notion": "./tools/sync-notion.js"
|
|
26
|
+
},
|
|
24
27
|
"exports": {
|
|
25
28
|
".": "./index.js",
|
|
26
29
|
"./slides": "./src/slide-renderer.js",
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SDOC Notion Sync — push SDOC files to Notion pages.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node tools/sync-notion.js <file.sdoc> [--page-id <id>] [options]
|
|
6
|
+
// node tools/sync-notion.js <directory> [options]
|
|
7
|
+
//
|
|
8
|
+
// Options:
|
|
9
|
+
// --page-id <id> Override Notion page ID (single-file mode only)
|
|
10
|
+
// --token <tok> Notion integration token (default: $NOTION_TOKEN)
|
|
11
|
+
// --dry-run Render blocks to stdout, no API calls
|
|
12
|
+
// --verbose Print progress to stderr
|
|
13
|
+
// --help Show usage
|
|
14
|
+
//
|
|
15
|
+
// Files opt in to sync by setting `notion-page: <page-id>` in their @meta scope.
|
|
16
|
+
// When given a directory, scans recursively for .sdoc files with this property.
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { parseSdoc, extractMeta, resolveIncludes } = require("../src/sdoc");
|
|
21
|
+
const { renderNotionBlocks } = require("../src/notion-renderer");
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const NOTION_API_VERSION = "2022-06-28";
|
|
28
|
+
const NOTION_BASE_URL = "https://api.notion.com/v1";
|
|
29
|
+
const BATCH_SIZE = 100;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// CLI
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function usage(exitCode) {
|
|
36
|
+
console.error("Usage:");
|
|
37
|
+
console.error(" sync-notion <file.sdoc> [--page-id <id>] [--token <tok>] [--dry-run] [--verbose]");
|
|
38
|
+
console.error(" sync-notion <directory> [--token <tok>] [--dry-run] [--verbose]");
|
|
39
|
+
console.error("");
|
|
40
|
+
console.error("Files opt in via `notion-page: <page-id>` in @meta.");
|
|
41
|
+
console.error("Token defaults to $NOTION_TOKEN env var.");
|
|
42
|
+
process.exit(exitCode === undefined ? 1 : exitCode);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseArgs() {
|
|
46
|
+
const args = process.argv.slice(2);
|
|
47
|
+
const opts = {
|
|
48
|
+
inputPath: null,
|
|
49
|
+
pageId: null,
|
|
50
|
+
token: process.env.NOTION_TOKEN || null,
|
|
51
|
+
dryRun: false,
|
|
52
|
+
verbose: false
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < args.length; i++) {
|
|
56
|
+
if (args[i] === "--page-id" && i + 1 < args.length) {
|
|
57
|
+
opts.pageId = args[++i];
|
|
58
|
+
} else if (args[i] === "--token" && i + 1 < args.length) {
|
|
59
|
+
opts.token = args[++i];
|
|
60
|
+
} else if (args[i] === "--dry-run") {
|
|
61
|
+
opts.dryRun = true;
|
|
62
|
+
} else if (args[i] === "--verbose") {
|
|
63
|
+
opts.verbose = true;
|
|
64
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
65
|
+
usage(0);
|
|
66
|
+
} else if (!opts.inputPath) {
|
|
67
|
+
opts.inputPath = args[i];
|
|
68
|
+
} else {
|
|
69
|
+
console.error("Unknown argument: " + args[i]);
|
|
70
|
+
usage();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return opts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// File discovery
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function findSdocFiles(dir) {
|
|
82
|
+
const results = [];
|
|
83
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const fullPath = path.join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
|
|
87
|
+
results.push(...findSdocFiles(fullPath));
|
|
88
|
+
} else if (entry.isFile() && entry.name.endsWith(".sdoc")) {
|
|
89
|
+
results.push(fullPath);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractNotionPageId(filePath) {
|
|
96
|
+
const text = fs.readFileSync(filePath, "utf-8");
|
|
97
|
+
const parsed = parseSdoc(text);
|
|
98
|
+
const { meta } = extractMeta(parsed.nodes);
|
|
99
|
+
const pageId = meta.properties["notion-page"] || meta.properties["notion_page"] || null;
|
|
100
|
+
return pageId ? pageId.trim() : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function discoverFiles(inputPath) {
|
|
104
|
+
const resolved = path.resolve(inputPath);
|
|
105
|
+
const allSdoc = findSdocFiles(resolved);
|
|
106
|
+
const withNotion = [];
|
|
107
|
+
for (const f of allSdoc) {
|
|
108
|
+
const pageId = extractNotionPageId(f);
|
|
109
|
+
if (pageId) {
|
|
110
|
+
withNotion.push({ file: f, pageId });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return withNotion;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Notion API client
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function sleep(ms) {
|
|
121
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function notionRequest(method, apiPath, token, body) {
|
|
125
|
+
const url = NOTION_BASE_URL + apiPath;
|
|
126
|
+
const headers = {
|
|
127
|
+
"Authorization": "Bearer " + token,
|
|
128
|
+
"Notion-Version": NOTION_API_VERSION,
|
|
129
|
+
"Content-Type": "application/json"
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const response = await fetch(url, {
|
|
133
|
+
method,
|
|
134
|
+
headers,
|
|
135
|
+
body: body ? JSON.stringify(body) : undefined
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
throw new Error("Notion API " + response.status + " " + method + " " + apiPath + ": " + text);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return response.json();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function notionRequestWithRetry(method, apiPath, token, body, maxRetries) {
|
|
147
|
+
maxRetries = maxRetries || 3;
|
|
148
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
149
|
+
try {
|
|
150
|
+
return await notionRequest(method, apiPath, token, body);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (attempt < maxRetries && err.message.includes("429")) {
|
|
153
|
+
const delay = Math.pow(2, attempt) * 1000;
|
|
154
|
+
await sleep(delay);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Page sync operations
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async function clearPage(pageId, token, verbose) {
|
|
167
|
+
let cursor = undefined;
|
|
168
|
+
let deleted = 0;
|
|
169
|
+
|
|
170
|
+
do {
|
|
171
|
+
const params = cursor ? "?start_cursor=" + cursor : "";
|
|
172
|
+
const result = await notionRequestWithRetry("GET", "/blocks/" + pageId + "/children" + params, token);
|
|
173
|
+
|
|
174
|
+
for (const block of result.results) {
|
|
175
|
+
await notionRequestWithRetry("DELETE", "/blocks/" + block.id, token);
|
|
176
|
+
deleted++;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
cursor = result.has_more ? result.next_cursor : undefined;
|
|
180
|
+
} while (cursor);
|
|
181
|
+
|
|
182
|
+
if (verbose) {
|
|
183
|
+
console.error(" Deleted " + deleted + " existing blocks");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractOverflow(blocks) {
|
|
188
|
+
const overflow = [];
|
|
189
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
190
|
+
const block = blocks[i];
|
|
191
|
+
const key = block.type;
|
|
192
|
+
const children = block[key] && block[key].children;
|
|
193
|
+
if (children && children.length > BATCH_SIZE) {
|
|
194
|
+
overflow.push({ index: i, extra: children.slice(BATCH_SIZE) });
|
|
195
|
+
block[key].children = children.slice(0, BATCH_SIZE);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return overflow;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function appendBlocks(pageId, blocks, token, verbose) {
|
|
202
|
+
for (let i = 0; i < blocks.length; i += BATCH_SIZE) {
|
|
203
|
+
const batch = blocks.slice(i, i + BATCH_SIZE);
|
|
204
|
+
const overflow = extractOverflow(batch);
|
|
205
|
+
|
|
206
|
+
const result = await notionRequestWithRetry("PATCH", "/blocks/" + pageId + "/children", token, {
|
|
207
|
+
children: batch
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (verbose) {
|
|
211
|
+
console.error(" Appended blocks " + (i + 1) + "–" + Math.min(i + BATCH_SIZE, blocks.length) + " of " + blocks.length);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Append overflow children to blocks that exceeded the 100-child limit
|
|
215
|
+
for (const { index, extra } of overflow) {
|
|
216
|
+
const createdId = result.results[index].id;
|
|
217
|
+
if (verbose) {
|
|
218
|
+
console.error(" Appending " + extra.length + " overflow children to block " + createdId);
|
|
219
|
+
}
|
|
220
|
+
await appendBlocks(createdId, extra, token, verbose);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function makeBannerBlock(filePath) {
|
|
226
|
+
return {
|
|
227
|
+
type: "callout",
|
|
228
|
+
callout: {
|
|
229
|
+
rich_text: [{
|
|
230
|
+
type: "text",
|
|
231
|
+
text: {
|
|
232
|
+
content: "Auto-synced from " + filePath + " — do not edit in Notion",
|
|
233
|
+
link: null
|
|
234
|
+
},
|
|
235
|
+
annotations: {
|
|
236
|
+
bold: false, italic: true, strikethrough: false,
|
|
237
|
+
underline: false, code: false, color: "default"
|
|
238
|
+
}
|
|
239
|
+
}],
|
|
240
|
+
icon: { type: "emoji", emoji: "🔄" },
|
|
241
|
+
color: "gray_background"
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Sync logic
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
async function syncFile(filePath, pageId, opts) {
|
|
251
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
252
|
+
const text = fs.readFileSync(filePath, "utf-8");
|
|
253
|
+
const parsed = parseSdoc(text);
|
|
254
|
+
|
|
255
|
+
if (parsed.errors.length > 0 && opts.verbose) {
|
|
256
|
+
for (const err of parsed.errors) {
|
|
257
|
+
console.error(" Warning: line " + err.line + ": " + err.message);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const { nodes, meta } = extractMeta(parsed.nodes);
|
|
262
|
+
|
|
263
|
+
// Resolve code includes if any
|
|
264
|
+
const baseDir = path.dirname(filePath);
|
|
265
|
+
await resolveIncludes(nodes, (src) => {
|
|
266
|
+
const resolved = path.resolve(baseDir, src);
|
|
267
|
+
return fs.readFileSync(resolved, "utf-8");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const blocks = renderNotionBlocks(nodes);
|
|
271
|
+
const allBlocks = [makeBannerBlock(relativePath), ...blocks];
|
|
272
|
+
|
|
273
|
+
if (opts.dryRun) {
|
|
274
|
+
console.log(JSON.stringify(allBlocks, null, 2));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!opts.token) {
|
|
279
|
+
console.error("Error: Notion token required. Set NOTION_TOKEN env var or use --token.");
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (opts.verbose) {
|
|
284
|
+
console.error("Syncing " + relativePath + " → " + pageId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await clearPage(pageId, opts.token, opts.verbose);
|
|
288
|
+
await appendBlocks(pageId, allBlocks, opts.token, opts.verbose);
|
|
289
|
+
|
|
290
|
+
console.log("Synced: " + relativePath + " → " + pageId + " (" + allBlocks.length + " blocks)");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function main() {
|
|
294
|
+
const opts = parseArgs();
|
|
295
|
+
|
|
296
|
+
if (!opts.inputPath) {
|
|
297
|
+
usage();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const resolved = path.resolve(opts.inputPath);
|
|
301
|
+
if (!fs.existsSync(resolved)) {
|
|
302
|
+
console.error("Not found: " + resolved);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const stat = fs.statSync(resolved);
|
|
307
|
+
|
|
308
|
+
if (stat.isFile()) {
|
|
309
|
+
// Single file mode
|
|
310
|
+
let pageId = opts.pageId;
|
|
311
|
+
if (!pageId) {
|
|
312
|
+
pageId = extractNotionPageId(resolved);
|
|
313
|
+
}
|
|
314
|
+
if (!pageId) {
|
|
315
|
+
console.error("Error: No notion-page found in @meta and no --page-id provided.");
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
await syncFile(resolved, pageId, opts);
|
|
319
|
+
} else if (stat.isDirectory()) {
|
|
320
|
+
// Directory scan mode
|
|
321
|
+
const files = discoverFiles(resolved);
|
|
322
|
+
if (files.length === 0) {
|
|
323
|
+
console.error("No .sdoc files with notion-page in @meta found in " + resolved);
|
|
324
|
+
process.exit(0);
|
|
325
|
+
}
|
|
326
|
+
if (opts.verbose) {
|
|
327
|
+
console.error("Found " + files.length + " file(s) to sync");
|
|
328
|
+
}
|
|
329
|
+
for (const entry of files) {
|
|
330
|
+
await syncFile(entry.file, entry.pageId, opts);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
main().catch((err) => {
|
|
336
|
+
console.error("Error: " + err.message);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
});
|