mdboard 1.0.0 → 1.2.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/watcher.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * mdboard — File watcher
3
+ *
4
+ * Watches project directories for changes to .md, config, and CSS files.
5
+ * Supports watching multiple directories for multi-source workspaces.
6
+ */
7
+
8
+ const fs = require('fs');
9
+
10
+ const watchTimers = new Map();
11
+
12
+ /**
13
+ * Setup recursive watcher on a directory for .md, mdboard.json, and mdboard.css changes.
14
+ *
15
+ * @param {string} dir - Directory to watch recursively
16
+ * @param {object} callbacks - { onChange(filename), onConfigChange(), onCssChange(filename) }
17
+ */
18
+ function watchDir(dir, callbacks) {
19
+ if (!fs.existsSync(dir)) return;
20
+
21
+ try {
22
+ fs.watch(dir, { recursive: true }, (eventType, filename) => {
23
+ if (!filename) return;
24
+
25
+ const isMd = filename.endsWith('.md');
26
+ const isConfig = filename === 'mdboard.json' || filename.endsWith('/mdboard.json');
27
+ const isCss = filename === 'mdboard.css' || filename.endsWith('/mdboard.css');
28
+
29
+ if (!isMd && !isConfig && !isCss) return;
30
+
31
+ const key = dir + ':' + filename;
32
+ if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
33
+
34
+ watchTimers.set(key, setTimeout(() => {
35
+ watchTimers.delete(key);
36
+ if (isConfig && callbacks.onConfigChange) callbacks.onConfigChange();
37
+ if (callbacks.onChange) callbacks.onChange(filename, isCss);
38
+ }, 200));
39
+ });
40
+ } catch {
41
+ console.warn(' Warning: file watching unavailable for ' + dir);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Watch a single file for changes (e.g., workspace.json).
47
+ *
48
+ * @param {string} filePath - Absolute path to file
49
+ * @param {function} onChange - Callback on change
50
+ */
51
+ function watchFile(filePath, onChange) {
52
+ if (!fs.existsSync(filePath)) return;
53
+
54
+ try {
55
+ fs.watch(filePath, (eventType) => {
56
+ const key = 'file:' + filePath;
57
+ if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
58
+
59
+ watchTimers.set(key, setTimeout(() => {
60
+ watchTimers.delete(key);
61
+ if (onChange) onChange();
62
+ }, 200));
63
+ });
64
+ } catch {
65
+ // Non-critical
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Setup watchers for project directories and workspace-level config.
71
+ *
72
+ * @param {object} opts
73
+ * @param {string[]} opts.projectDirs - List of project/ directories to watch recursively
74
+ * @param {string[]} opts.rootDirs - List of root directories to watch for mdboard.json/css
75
+ * @param {function} opts.onScan - Called when files change and a rescan is needed
76
+ * @param {function} opts.onConfigReload - Called when mdboard.json changes
77
+ * @param {function} opts.onBroadcast - Called with event data to broadcast via SSE
78
+ * @param {string} [opts.workspacePath] - Path to workspace.json to watch
79
+ * @param {function} [opts.onWorkspaceChange] - Called when workspace.json changes
80
+ */
81
+ function setupWatchers(opts) {
82
+ const { projectDirs, rootDirs, onScan, onConfigReload, onBroadcast, workspacePath, onWorkspaceChange } = opts;
83
+
84
+ // Watch each project directory recursively
85
+ for (const dir of projectDirs) {
86
+ watchDir(dir, {
87
+ onConfigChange: () => {
88
+ if (onConfigReload) onConfigReload();
89
+ },
90
+ onChange: (filename, isCss) => {
91
+ if (onScan) onScan();
92
+ const eventData = { type: 'update', file: filename, timestamp: new Date().toISOString() };
93
+ if (isCss) eventData.cssReload = true;
94
+ if (onBroadcast) onBroadcast(eventData);
95
+ },
96
+ });
97
+ }
98
+
99
+ // Watch root directories for workspace-level mdboard.json and mdboard.css
100
+ for (const dir of rootDirs) {
101
+ try {
102
+ fs.watch(dir, (eventType, filename) => {
103
+ if (!filename) return;
104
+ if (filename !== 'mdboard.json' && filename !== 'mdboard.css') return;
105
+
106
+ const key = 'root:' + dir + ':' + filename;
107
+ if (watchTimers.has(key)) clearTimeout(watchTimers.get(key));
108
+
109
+ watchTimers.set(key, setTimeout(() => {
110
+ watchTimers.delete(key);
111
+ if (filename === 'mdboard.json') {
112
+ if (onConfigReload) onConfigReload();
113
+ if (onScan) onScan();
114
+ }
115
+ const eventData = { type: 'update', file: filename, timestamp: new Date().toISOString() };
116
+ if (filename === 'mdboard.css') eventData.cssReload = true;
117
+ if (onBroadcast) onBroadcast(eventData);
118
+ }, 200));
119
+ });
120
+ } catch {
121
+ // Non-critical
122
+ }
123
+ }
124
+
125
+ // Watch workspace.json
126
+ if (workspacePath && onWorkspaceChange) {
127
+ watchFile(workspacePath, onWorkspaceChange);
128
+ }
129
+ }
130
+
131
+ module.exports = { setupWatchers, watchDir, watchFile };
package/workspace.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * mdboard — Workspace loader
3
+ *
4
+ * Handles multi-repo workspace via workspace.json.
5
+ * Resolves source paths, manages remote git clones, and provides
6
+ * author info via git log.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const { execSync } = require('child_process');
13
+
14
+ const CACHE_DIR = path.join(os.homedir(), '.cache', 'mdboard');
15
+
16
+ /**
17
+ * Load and resolve workspace.json.
18
+ *
19
+ * @param {string} projectDir - The workspace root directory
20
+ * @param {string} [explicitPath] - Explicit path to workspace.json
21
+ * @returns {object|null} - Parsed workspace with resolved source paths, or null
22
+ */
23
+ function loadWorkspace(projectDir, explicitPath) {
24
+ let wsPath = null;
25
+ let ws = null;
26
+
27
+ const candidates = [];
28
+ if (explicitPath) candidates.push(path.resolve(explicitPath));
29
+ if (projectDir) {
30
+ candidates.push(path.join(projectDir, 'workspace.json'));
31
+ }
32
+
33
+ for (const p of candidates) {
34
+ try {
35
+ const raw = fs.readFileSync(p, 'utf-8');
36
+ ws = JSON.parse(raw);
37
+ wsPath = p;
38
+ break;
39
+ } catch {
40
+ continue;
41
+ }
42
+ }
43
+
44
+ if (!ws || !Array.isArray(ws.sources) || ws.sources.length === 0) return null;
45
+
46
+ const wsDir = path.dirname(wsPath);
47
+
48
+ // Resolve each source
49
+ for (const source of ws.sources) {
50
+ if (!source.name) continue;
51
+
52
+ source.type = source.type || 'local';
53
+ source.label = source.label || source.name;
54
+ source.icon = source.icon || source.name.charAt(0).toUpperCase();
55
+
56
+ if (!source.color) {
57
+ source.color = hashColor(source.name);
58
+ }
59
+
60
+ if (source.readonly == null) {
61
+ source.readonly = source.type === 'remote';
62
+ }
63
+
64
+ if (source.type === 'local') {
65
+ if (source.path) {
66
+ const resolved = path.resolve(wsDir, source.path);
67
+ const root = source.root || 'project';
68
+ source._resolvedPath = path.join(resolved, root);
69
+ source._repoRoot = resolved;
70
+ }
71
+ } else if (source.type === 'remote') {
72
+ const root = source.root || 'project';
73
+ const cacheKey = source.name;
74
+ const cachePath = path.join(CACHE_DIR, cacheKey);
75
+ source._resolvedPath = path.join(cachePath, root);
76
+ source._repoRoot = cachePath;
77
+ source._cachePath = cachePath;
78
+ }
79
+ }
80
+
81
+ // Also resolve the overview (workspace root's project/ dir)
82
+ const overviewPath = path.join(projectDir, 'project');
83
+ if (fs.existsSync(overviewPath)) {
84
+ ws.sources.unshift({
85
+ name: 'overview',
86
+ label: 'Overview',
87
+ icon: '\u2605',
88
+ color: '#5B6EF5',
89
+ type: 'local',
90
+ readonly: false,
91
+ _resolvedPath: overviewPath,
92
+ _repoRoot: projectDir,
93
+ });
94
+ }
95
+
96
+ ws._path = wsPath;
97
+ ws.settings = ws.settings || {};
98
+
99
+ return ws;
100
+ }
101
+
102
+ /**
103
+ * Generate a deterministic hex color from a string.
104
+ */
105
+ function hashColor(str) {
106
+ const colors = ['#F97316', '#8B5CF6', '#EC4899', '#10B981', '#06B6D4', '#5B6EF5', '#6366F1', '#D4A72C'];
107
+ let hash = 0;
108
+ for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
109
+ return colors[Math.abs(hash) % colors.length];
110
+ }
111
+
112
+ /**
113
+ * Sync a single remote source by cloning or pulling.
114
+ *
115
+ * @param {object} source - A source entry with type=remote
116
+ */
117
+ async function syncRemote(source) {
118
+ if (source.type !== 'remote' || !source.url) return;
119
+
120
+ const cachePath = source._cachePath;
121
+ if (!cachePath) return;
122
+
123
+ const branch = source.branch || 'main';
124
+
125
+ try {
126
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
127
+
128
+ if (fs.existsSync(path.join(cachePath, '.git'))) {
129
+ // Pull latest
130
+ execSync(`git -C "${cachePath}" fetch origin ${branch} --depth=1 && git -C "${cachePath}" reset --hard origin/${branch}`, {
131
+ stdio: 'pipe',
132
+ timeout: 30000,
133
+ });
134
+ } else {
135
+ // Clone
136
+ execSync(`git clone --depth=1 --branch ${branch} "${source.url}" "${cachePath}"`, {
137
+ stdio: 'pipe',
138
+ timeout: 60000,
139
+ });
140
+ }
141
+
142
+ source._lastSync = new Date().toISOString();
143
+ source._error = null;
144
+ } catch (err) {
145
+ source._error = err.message;
146
+ console.warn(` Warning: sync failed for ${source.name}: ${err.message}`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Sync all remote sources.
152
+ *
153
+ * @param {object[]} sources - Array of source entries
154
+ */
155
+ async function syncAllRemotes(sources) {
156
+ const remotes = sources.filter(s => s.type === 'remote');
157
+ for (const source of remotes) {
158
+ await syncRemote(source);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get the git author of the last commit that touched a file.
164
+ *
165
+ * @param {string} repoRoot - Path to git repo root
166
+ * @param {string} relFile - Relative file path
167
+ * @returns {string|null}
168
+ */
169
+ const authorCache = new Map();
170
+ const AUTHOR_CACHE_MAX = 500;
171
+
172
+ function getGitAuthor(repoRoot, relFile) {
173
+ if (!repoRoot || !relFile) return null;
174
+
175
+ const key = repoRoot + ':' + relFile;
176
+ if (authorCache.has(key)) return authorCache.get(key);
177
+
178
+ try {
179
+ const author = execSync(
180
+ `git -C "${repoRoot}" log -1 --format="%an" -- "${relFile}"`,
181
+ { stdio: 'pipe', timeout: 5000 }
182
+ ).toString().trim();
183
+
184
+ if (authorCache.size >= AUTHOR_CACHE_MAX) {
185
+ const first = authorCache.keys().next().value;
186
+ authorCache.delete(first);
187
+ }
188
+ authorCache.set(key, author || null);
189
+ return author || null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * List all cached remote repos.
197
+ *
198
+ * @returns {string[]} - Directory names in cache
199
+ */
200
+ function listCache() {
201
+ try {
202
+ return fs.readdirSync(CACHE_DIR).filter(e => !e.startsWith('.'));
203
+ } catch {
204
+ return [];
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Clean the entire cache directory.
210
+ */
211
+ function cleanCache() {
212
+ try {
213
+ fs.rmSync(CACHE_DIR, { recursive: true, force: true });
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ module.exports = { loadWorkspace, syncRemote, syncAllRemotes, getGitAuthor, listCache, cleanCache, CACHE_DIR };
package/yaml.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * mdboard — YAML frontmatter parser and serializer
3
+ *
4
+ * Lightweight parser for YAML frontmatter in markdown files.
5
+ * No external dependencies.
6
+ */
7
+
8
+ function parseFrontmatter(content) {
9
+ const result = { frontmatter: {}, content: '' };
10
+ if (!content.startsWith('---')) return { frontmatter: {}, content };
11
+
12
+ const end = content.indexOf('\n---', 3);
13
+ if (end === -1) return { frontmatter: {}, content };
14
+
15
+ const yaml = content.substring(4, end).trim();
16
+ result.content = content.substring(end + 4).trim();
17
+ result.frontmatter = parseYaml(yaml);
18
+ return result;
19
+ }
20
+
21
+ function parseYaml(text) {
22
+ const obj = {};
23
+ const lines = text.split('\n');
24
+ let i = 0;
25
+
26
+ while (i < lines.length) {
27
+ const line = lines[i];
28
+ const match = line.match(/^(\w[\w.-]*)\s*:\s*(.*)/);
29
+
30
+ if (!match) { i++; continue; }
31
+
32
+ const key = match[1];
33
+ const rawValue = match[2].trim();
34
+
35
+ if (rawValue === '' || rawValue === '') {
36
+ const nested = {};
37
+ let dashList = null;
38
+ let j = i + 1;
39
+
40
+ while (j < lines.length) {
41
+ const nextLine = lines[j];
42
+ if (!nextLine.match(/^\s/) || nextLine.trim() === '') break;
43
+
44
+ const dashMatch = nextLine.match(/^\s+-\s+(.*)/);
45
+ const nestedMatch = nextLine.match(/^\s+(\w[\w.-]*)\s*:\s*(.*)/);
46
+
47
+ if (dashMatch) {
48
+ if (!dashList) dashList = [];
49
+ dashList.push(parseValue(dashMatch[1].trim()));
50
+ } else if (nestedMatch) {
51
+ nested[nestedMatch[1]] = parseValue(nestedMatch[2].trim());
52
+ }
53
+ j++;
54
+ }
55
+
56
+ obj[key] = dashList || (Object.keys(nested).length > 0 ? nested : parseValue(rawValue));
57
+ i = j;
58
+ continue;
59
+ }
60
+
61
+ obj[key] = parseValue(rawValue);
62
+ i++;
63
+ }
64
+
65
+ return obj;
66
+ }
67
+
68
+ function parseValue(raw) {
69
+ if (raw === '' || raw === undefined) return null;
70
+
71
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
72
+ return raw.slice(1, -1);
73
+ }
74
+
75
+ if (raw.startsWith('[') && raw.endsWith(']')) {
76
+ const inner = raw.slice(1, -1).trim();
77
+ if (inner === '') return [];
78
+ return inner.split(',').map(s => parseValue(s.trim()));
79
+ }
80
+
81
+ if (raw === 'true') return true;
82
+ if (raw === 'false') return false;
83
+ if (raw === 'null' || raw === '~') return null;
84
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
85
+
86
+ return raw;
87
+ }
88
+
89
+ function serializeValue(v) {
90
+ if (v === null || v === undefined) return '';
91
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
92
+ if (typeof v === 'number') return String(v);
93
+ if (typeof v === 'string') {
94
+ if (/[:#\[\]{}&*!|>'"%@`,\n]/.test(v) || v === '' || v === 'true' || v === 'false' || v === 'null') {
95
+ return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
96
+ }
97
+ return v;
98
+ }
99
+ return String(v);
100
+ }
101
+
102
+ function serializeYaml(obj) {
103
+ const lines = [];
104
+ for (const [key, value] of Object.entries(obj)) {
105
+ if (key.startsWith('_')) continue;
106
+ if (value === undefined) continue;
107
+ if (value === null) {
108
+ lines.push(key + ':');
109
+ continue;
110
+ }
111
+ if (Array.isArray(value)) {
112
+ if (value.length === 0) {
113
+ lines.push(key + ': []');
114
+ } else {
115
+ lines.push(key + ': [' + value.map(v => serializeValue(v)).join(', ') + ']');
116
+ }
117
+ } else if (typeof value === 'object') {
118
+ lines.push(key + ':');
119
+ for (const [k, v] of Object.entries(value)) {
120
+ lines.push(' ' + k + ': ' + serializeValue(v));
121
+ }
122
+ } else {
123
+ lines.push(key + ': ' + serializeValue(value));
124
+ }
125
+ }
126
+ return lines.join('\n');
127
+ }
128
+
129
+ module.exports = { parseFrontmatter, parseYaml, parseValue, serializeYaml, serializeValue };