mdboard 1.1.0 → 1.3.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,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 };
@@ -0,0 +1,133 @@
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
+ if (Array.isArray(v)) {
121
+ lines.push(' ' + k + ': ' + (v.length === 0 ? '[]' : '[' + v.map(sv => serializeValue(sv)).join(', ') + ']'));
122
+ } else {
123
+ lines.push(' ' + k + ': ' + serializeValue(v));
124
+ }
125
+ }
126
+ } else {
127
+ lines.push(key + ': ' + serializeValue(value));
128
+ }
129
+ }
130
+ return lines.join('\n');
131
+ }
132
+
133
+ module.exports = { parseFrontmatter, parseYaml, parseValue, serializeYaml, serializeValue };