markdownr 0.7.2 → 0.8.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.
- checksums.yaml +4 -4
- data/bin/Dockerfile.markdownr +1 -1
- data/bin/markdownr +79 -0
- data/bin/markdownr-servers.yaml +39 -0
- data/bin/start-claude +2 -0
- data/lib/markdown_server/app.rb +953 -107
- data/lib/markdown_server/assets/editor-loader.js +362 -0
- data/lib/markdown_server/csv_browser/addon_registry.rb +137 -0
- data/lib/markdown_server/csv_browser/config_loader.rb +231 -0
- data/lib/markdown_server/csv_browser/row_context.rb +146 -0
- data/lib/markdown_server/csv_browser/table_reader.rb +259 -0
- data/lib/markdown_server/helpers/admin_helpers.rb +25 -1
- data/lib/markdown_server/helpers/formatting_helpers.rb +3 -1
- data/lib/markdown_server/helpers/markdown_helpers.rb +132 -5
- data/lib/markdown_server/helpers/path_helpers.rb +56 -7
- data/lib/markdown_server/helpers/search_helpers.rb +31 -3
- data/lib/markdown_server/permitted_bases.rb +13 -0
- data/lib/markdown_server/plugin.rb +11 -0
- data/lib/markdown_server/plugins/bible_citations/citations.rb +4 -4
- data/lib/markdown_server/unhide.rb +114 -0
- data/lib/markdown_server/version.rb +1 -1
- data/views/browser.erb +5794 -0
- data/views/layout.erb +124 -20
- data/views/popup_assets.erb +52 -26
- metadata +40 -2
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// markdownr editor loader — CodeMirror 6.
|
|
2
|
+
//
|
|
3
|
+
// All esm.sh URLs are pinned in the page's <script type="importmap"> (see
|
|
4
|
+
// editor_import_map_json in lib/markdown_server/app.rb). Each package has
|
|
5
|
+
// `?external=<shared-deps>` so esm.sh emits bare imports for shared modules
|
|
6
|
+
// (state, view, language, commands), which the importmap then resolves to a
|
|
7
|
+
// single URL each — guaranteeing one instance of each shared module.
|
|
8
|
+
|
|
9
|
+
import { EditorState, Compartment, RangeSetBuilder } from '@codemirror/state';
|
|
10
|
+
import {
|
|
11
|
+
EditorView, keymap, highlightActiveLine, lineNumbers,
|
|
12
|
+
highlightActiveLineGutter, drawSelection, ViewPlugin, Decoration, WidgetType
|
|
13
|
+
} from '@codemirror/view';
|
|
14
|
+
import {
|
|
15
|
+
defaultKeymap, history, historyKeymap, indentWithTab
|
|
16
|
+
} from '@codemirror/commands';
|
|
17
|
+
import {
|
|
18
|
+
defaultHighlightStyle, syntaxHighlighting, indentOnInput, bracketMatching,
|
|
19
|
+
foldGutter, foldKeymap, StreamLanguage, syntaxTree
|
|
20
|
+
} from '@codemirror/language';
|
|
21
|
+
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
|
22
|
+
import {
|
|
23
|
+
autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap
|
|
24
|
+
} from '@codemirror/autocomplete';
|
|
25
|
+
import { oneDark } from '@codemirror/theme-one-dark';
|
|
26
|
+
import { markdown } from '@codemirror/lang-markdown';
|
|
27
|
+
import { javascript } from '@codemirror/lang-javascript';
|
|
28
|
+
import { json } from '@codemirror/lang-json';
|
|
29
|
+
import { yaml } from '@codemirror/lang-yaml';
|
|
30
|
+
import { html } from '@codemirror/lang-html';
|
|
31
|
+
import { css } from '@codemirror/lang-css';
|
|
32
|
+
import { python } from '@codemirror/lang-python';
|
|
33
|
+
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
|
34
|
+
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
|
35
|
+
import { vim, Vim } from '@replit/codemirror-vim';
|
|
36
|
+
|
|
37
|
+
// ── Live-preview plugin (subset: headings, strong, emphasis, inline code, links)
|
|
38
|
+
//
|
|
39
|
+
// Lines whose content the cursor (or selection) is currently on are left as
|
|
40
|
+
// raw markdown so the user can edit the syntax. All other supported tokens
|
|
41
|
+
// are decorated: structural marks (#, **, _, `, [, ]) get hidden via
|
|
42
|
+
// Decoration.replace; content gets a class for CSS styling. Anything we
|
|
43
|
+
// don't recognize (tables, fences, lists, images, embeds, etc.) is left
|
|
44
|
+
// untouched and shows as raw markdown text — that's why the side preview
|
|
45
|
+
// pane is still available for the cases this subset can't render.
|
|
46
|
+
|
|
47
|
+
const HEADING_LEVEL = {
|
|
48
|
+
ATXHeading1: 1, ATXHeading2: 2, ATXHeading3: 3,
|
|
49
|
+
ATXHeading4: 4, ATXHeading5: 5, ATXHeading6: 6,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
class BulletWidget extends WidgetType {
|
|
53
|
+
toDOM() {
|
|
54
|
+
const span = document.createElement('span');
|
|
55
|
+
span.className = 'cm-md-bullet';
|
|
56
|
+
span.textContent = '• ';
|
|
57
|
+
return span;
|
|
58
|
+
}
|
|
59
|
+
eq() { return true; }
|
|
60
|
+
ignoreEvent() { return false; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildLivePreviewDecorations(view) {
|
|
64
|
+
const tree = syntaxTree(view.state);
|
|
65
|
+
|
|
66
|
+
const sel = view.state.selection.main;
|
|
67
|
+
const cursorLines = new Set();
|
|
68
|
+
const lineFromIdx = view.state.doc.lineAt(sel.from).number;
|
|
69
|
+
const lineToIdx = view.state.doc.lineAt(sel.to).number;
|
|
70
|
+
for (let l = lineFromIdx; l <= lineToIdx; l++) cursorLines.add(l);
|
|
71
|
+
|
|
72
|
+
const lineHasCursor = (pos) => cursorLines.has(view.state.doc.lineAt(pos).number);
|
|
73
|
+
|
|
74
|
+
// We collect ranges into an array and let Decoration.set(..., true) sort
|
|
75
|
+
// them. RangeSetBuilder requires strict monotonic (from, startSide)
|
|
76
|
+
// ordering, but the markdown tree visits a parent (e.g. StrongEmphasis)
|
|
77
|
+
// and then its child marker (EmphasisMark) at the same start position —
|
|
78
|
+
// that's a tie the builder rejects unless the child range starts exactly
|
|
79
|
+
// where the parent's last range ended, which it doesn't.
|
|
80
|
+
const ranges = [];
|
|
81
|
+
const seen = Object.create(null);
|
|
82
|
+
const add = (from, to, deco) => { ranges.push(deco.range(from, to)); };
|
|
83
|
+
|
|
84
|
+
// Walk the entire doc (not just visibleRanges) — for typical markdown
|
|
85
|
+
// files in the popup this is fine, and avoids pitfalls when the visible
|
|
86
|
+
// range hasn't settled yet on first construction.
|
|
87
|
+
{
|
|
88
|
+
tree.iterate({
|
|
89
|
+
enter(node) {
|
|
90
|
+
const name = node.type.name;
|
|
91
|
+
seen[name] = (seen[name] || 0) + 1;
|
|
92
|
+
const lvl = HEADING_LEVEL[name];
|
|
93
|
+
|
|
94
|
+
if (lvl) {
|
|
95
|
+
const line = view.state.doc.lineAt(node.from);
|
|
96
|
+
add(line.from, line.from, Decoration.line({
|
|
97
|
+
class: `cm-md-line-h${lvl}`
|
|
98
|
+
}));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (name === 'HeaderMark') {
|
|
103
|
+
const parent = node.node.parent;
|
|
104
|
+
if (!parent || !HEADING_LEVEL[parent.type.name]) return;
|
|
105
|
+
if (lineHasCursor(node.from)) return;
|
|
106
|
+
// Hide "# " (the mark plus the space after it).
|
|
107
|
+
const after = view.state.doc.sliceString(node.to, node.to + 1);
|
|
108
|
+
const hideTo = after === ' ' ? node.to + 1 : node.to;
|
|
109
|
+
add(node.from, hideTo, Decoration.replace({}));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (name === 'ListItem') {
|
|
114
|
+
const line = view.state.doc.lineAt(node.from);
|
|
115
|
+
add(line.from, line.from, Decoration.line({ class: 'cm-md-list-item' }));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (name === 'ListMark') {
|
|
120
|
+
if (lineHasCursor(node.from)) return;
|
|
121
|
+
const parent = node.node.parent;
|
|
122
|
+
if (!parent || parent.type.name !== 'ListItem') return;
|
|
123
|
+
const list = parent.parent;
|
|
124
|
+
if (!list) return;
|
|
125
|
+
if (list.type.name === 'BulletList') {
|
|
126
|
+
// Replace `- ` (or `*`/`+` plus its trailing space) with a `• ` widget.
|
|
127
|
+
const after = view.state.doc.sliceString(node.to, node.to + 1);
|
|
128
|
+
const hideTo = after === ' ' ? node.to + 1 : node.to;
|
|
129
|
+
add(node.from, hideTo, Decoration.replace({ widget: new BulletWidget() }));
|
|
130
|
+
}
|
|
131
|
+
// OrderedList: leave the "1." marker visible — easier than tracking
|
|
132
|
+
// the actual sequence number, and reads fine as-is.
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Styling classes always apply (Obsidian-style: bold/italic/code stay
|
|
137
|
+
// visually styled even when the cursor is on the line). Only the
|
|
138
|
+
// syntax markers (**, _, `, [, ], (url)) are revealed/hidden based on
|
|
139
|
+
// cursor position.
|
|
140
|
+
|
|
141
|
+
if (name === 'StrongEmphasis') {
|
|
142
|
+
add(node.from, node.to, Decoration.mark({ class: 'cm-md-strong' }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (name === 'Emphasis') {
|
|
147
|
+
add(node.from, node.to, Decoration.mark({ class: 'cm-md-em' }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (name === 'EmphasisMark') {
|
|
152
|
+
if (lineHasCursor(node.from)) return;
|
|
153
|
+
const parent = node.node.parent;
|
|
154
|
+
if (!parent) return;
|
|
155
|
+
if (parent.type.name === 'Emphasis' || parent.type.name === 'StrongEmphasis') {
|
|
156
|
+
add(node.from, node.to, Decoration.replace({}));
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (name === 'InlineCode') {
|
|
162
|
+
add(node.from, node.to, Decoration.mark({ class: 'cm-md-code' }));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (name === 'CodeMark') {
|
|
167
|
+
if (lineHasCursor(node.from)) return;
|
|
168
|
+
const parent = node.node.parent;
|
|
169
|
+
if (parent && parent.type.name === 'InlineCode') {
|
|
170
|
+
add(node.from, node.to, Decoration.replace({}));
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (name === 'Link') {
|
|
176
|
+
add(node.from, node.to, Decoration.mark({ class: 'cm-md-link' }));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (name === 'LinkMark' || name === 'URL') {
|
|
181
|
+
if (lineHasCursor(node.from)) return;
|
|
182
|
+
const parent = node.node.parent;
|
|
183
|
+
if (parent && parent.type.name === 'Link') {
|
|
184
|
+
add(node.from, node.to, Decoration.replace({}));
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (typeof window !== 'undefined') {
|
|
192
|
+
window.__lpDebug = {
|
|
193
|
+
treeRoot: tree.type.name,
|
|
194
|
+
docLength: view.state.doc.length,
|
|
195
|
+
seenNodeTypes: seen,
|
|
196
|
+
decorationsAdded: ranges.length,
|
|
197
|
+
ranAt: new Date().toISOString(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return Decoration.set(ranges, true);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const livePreviewPlugin = ViewPlugin.fromClass(class {
|
|
204
|
+
constructor(view) { this.decorations = buildLivePreviewDecorations(view); }
|
|
205
|
+
update(u) {
|
|
206
|
+
if (u.docChanged || u.viewportChanged || u.selectionSet) {
|
|
207
|
+
this.decorations = buildLivePreviewDecorations(u.view);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}, { decorations: v => v.decorations });
|
|
211
|
+
|
|
212
|
+
function languageExtension(name) {
|
|
213
|
+
switch (name) {
|
|
214
|
+
case 'markdown': return markdown({ codeLanguages: [] });
|
|
215
|
+
case 'javascript': return javascript();
|
|
216
|
+
case 'json': return json();
|
|
217
|
+
case 'yaml': return yaml();
|
|
218
|
+
case 'html': return html();
|
|
219
|
+
case 'css': return css();
|
|
220
|
+
case 'python': return python();
|
|
221
|
+
case 'ruby': return StreamLanguage.define(ruby);
|
|
222
|
+
case 'bash': case 'shell': return StreamLanguage.define(shell);
|
|
223
|
+
default: return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const baseExtensions = (onChange) => [
|
|
228
|
+
lineNumbers(),
|
|
229
|
+
highlightActiveLineGutter(),
|
|
230
|
+
history(),
|
|
231
|
+
drawSelection(),
|
|
232
|
+
foldGutter(),
|
|
233
|
+
indentOnInput(),
|
|
234
|
+
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
235
|
+
bracketMatching(),
|
|
236
|
+
closeBrackets(),
|
|
237
|
+
autocompletion(),
|
|
238
|
+
highlightActiveLine(),
|
|
239
|
+
highlightSelectionMatches(),
|
|
240
|
+
oneDark,
|
|
241
|
+
// Live-preview decorations — only emit non-empty results when the active
|
|
242
|
+
// language is lang-markdown (other parsers' syntax trees never contain the
|
|
243
|
+
// node names we look for). Adding it at the base level instead of inside
|
|
244
|
+
// the language compartment avoids confusion when the language is switched.
|
|
245
|
+
livePreviewPlugin,
|
|
246
|
+
EditorView.lineWrapping,
|
|
247
|
+
keymap.of([
|
|
248
|
+
...closeBracketsKeymap,
|
|
249
|
+
...defaultKeymap,
|
|
250
|
+
...searchKeymap,
|
|
251
|
+
...historyKeymap,
|
|
252
|
+
...foldKeymap,
|
|
253
|
+
...completionKeymap,
|
|
254
|
+
indentWithTab,
|
|
255
|
+
]),
|
|
256
|
+
EditorView.updateListener.of((u) => {
|
|
257
|
+
if (u.docChanged && onChange) onChange(u.state.doc.toString());
|
|
258
|
+
}),
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
// Active editor's vim ex-command handlers. Only one editor can own these at a
|
|
262
|
+
// time (last-set wins). When the focused editor is destroyed, its handlers are
|
|
263
|
+
// cleared so a stale closure doesn't fire against a torn-down view.
|
|
264
|
+
let vimSaveHandler = null;
|
|
265
|
+
let vimQuitHandler = null;
|
|
266
|
+
let vimIsDirtyHandler = null;
|
|
267
|
+
|
|
268
|
+
function showVimNotice(cm, msg) {
|
|
269
|
+
if (!cm || typeof cm.openNotification !== 'function') return;
|
|
270
|
+
const div = document.createElement('div');
|
|
271
|
+
div.className = 'cm-vim-message';
|
|
272
|
+
div.style.color = 'red';
|
|
273
|
+
div.style.whiteSpace = 'pre';
|
|
274
|
+
div.textContent = msg;
|
|
275
|
+
cm.openNotification(div, { bottom: true, duration: 4000 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function vimSaveThenQuit() {
|
|
279
|
+
if (!vimSaveHandler) {
|
|
280
|
+
if (vimQuitHandler) vimQuitHandler({ saved: true });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
Promise.resolve(vimSaveHandler()).then((ok) => {
|
|
284
|
+
if (ok === false) return;
|
|
285
|
+
if (vimQuitHandler) vimQuitHandler({ saved: true });
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
Vim.defineEx('write', 'w', () => { if (vimSaveHandler) vimSaveHandler(); });
|
|
290
|
+
Vim.defineEx('wq', 'wq', vimSaveThenQuit);
|
|
291
|
+
Vim.defineEx('x', 'x', vimSaveThenQuit);
|
|
292
|
+
Vim.defineEx('quit', 'q', (cm, params) => {
|
|
293
|
+
// Bang (`:q!`) is parsed into params.argString by codemirror-vim:
|
|
294
|
+
// commandName='q', argString='!'.
|
|
295
|
+
const argStr = String((params && params.argString) || '');
|
|
296
|
+
const force = argStr.indexOf('!') !== -1;
|
|
297
|
+
if (!force && vimIsDirtyHandler && vimIsDirtyHandler()) {
|
|
298
|
+
showVimNotice(cm, 'No write since last change (use :q! to discard, :wq to save and quit)');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (vimQuitHandler) vimQuitHandler({ saved: false });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
export function createEditor({ parent, doc, language, vim: useVim, onSave, onChange }) {
|
|
305
|
+
const langCompartment = new Compartment();
|
|
306
|
+
const vimCompartment = new Compartment();
|
|
307
|
+
|
|
308
|
+
const saveKeymap = keymap.of([
|
|
309
|
+
{
|
|
310
|
+
key: 'Mod-s',
|
|
311
|
+
preventDefault: true,
|
|
312
|
+
run: () => { if (onSave) onSave(); return true; },
|
|
313
|
+
},
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
const state = EditorState.create({
|
|
317
|
+
doc: doc || '',
|
|
318
|
+
extensions: [
|
|
319
|
+
vimCompartment.of(useVim ? vim() : []),
|
|
320
|
+
saveKeymap,
|
|
321
|
+
...baseExtensions(onChange),
|
|
322
|
+
langCompartment.of(languageExtension(language)),
|
|
323
|
+
],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const view = new EditorView({ state, parent });
|
|
327
|
+
|
|
328
|
+
let mySave = null, myQuit = null, myIsDirty = null;
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
view,
|
|
332
|
+
setVim(on) {
|
|
333
|
+
view.dispatch({ effects: vimCompartment.reconfigure(on ? vim() : []) });
|
|
334
|
+
},
|
|
335
|
+
setLanguage(name) {
|
|
336
|
+
view.dispatch({ effects: langCompartment.reconfigure(languageExtension(name)) });
|
|
337
|
+
},
|
|
338
|
+
setSaveHandler(fn) {
|
|
339
|
+
mySave = fn;
|
|
340
|
+
vimSaveHandler = fn;
|
|
341
|
+
},
|
|
342
|
+
setQuitHandler(quitFn, isDirtyFn) {
|
|
343
|
+
myQuit = quitFn;
|
|
344
|
+
myIsDirty = isDirtyFn;
|
|
345
|
+
vimQuitHandler = quitFn;
|
|
346
|
+
vimIsDirtyHandler = isDirtyFn;
|
|
347
|
+
},
|
|
348
|
+
getValue() {
|
|
349
|
+
return view.state.doc.toString();
|
|
350
|
+
},
|
|
351
|
+
setValue(text) {
|
|
352
|
+
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
|
|
353
|
+
},
|
|
354
|
+
focus() { view.focus(); },
|
|
355
|
+
destroy() {
|
|
356
|
+
if (vimSaveHandler === mySave) vimSaveHandler = null;
|
|
357
|
+
if (vimQuitHandler === myQuit) vimQuitHandler = null;
|
|
358
|
+
if (vimIsDirtyHandler === myIsDirty) vimIsDirtyHandler = null;
|
|
359
|
+
view.destroy();
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MarkdownServer
|
|
4
|
+
module CsvBrowser
|
|
5
|
+
# Holds one add-on's declarative definition: its `actions` block and a set of
|
|
6
|
+
# `on :action_id` handler blocks. Created via the DSL inside `register`.
|
|
7
|
+
class AddonDefinition
|
|
8
|
+
attr_reader :name
|
|
9
|
+
|
|
10
|
+
def initialize(name)
|
|
11
|
+
@name = name.to_sym
|
|
12
|
+
@actions_block = nil
|
|
13
|
+
@handlers = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def actions(&block)
|
|
17
|
+
@actions_block = block
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def on(action_id, &block)
|
|
21
|
+
@handlers[action_id.to_sym] = block
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns [{ id:, label:, enabled:, ... }, ...] for the given row context.
|
|
25
|
+
def actions_for(ctx)
|
|
26
|
+
return [] unless @actions_block
|
|
27
|
+
|
|
28
|
+
result = @actions_block.call(ctx)
|
|
29
|
+
Array(result).map do |entry|
|
|
30
|
+
h = entry.dup
|
|
31
|
+
h[:id] = h[:id].to_sym
|
|
32
|
+
h[:enabled] = h.key?(:enabled) ? !!h[:enabled] : true
|
|
33
|
+
h
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handler_for(action_id)
|
|
38
|
+
@handlers[action_id.to_sym]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Global registry of CSV add-ons.
|
|
43
|
+
#
|
|
44
|
+
# Lifecycle:
|
|
45
|
+
# 1. A Ruby file calls `MarkdownServer::CsvAddonRegistry.register(:name) { ... }`
|
|
46
|
+
# at load time. That block is `instance_exec`'d against a fresh
|
|
47
|
+
# `AddonDefinition`, populating its `actions` and `on` entries.
|
|
48
|
+
# 2. At startup, `load(addons_config, root_dir)` receives the `csv_addons`
|
|
49
|
+
# mapping from .markdownr.yml and `require`s each absolute path; missing
|
|
50
|
+
# files warn but don't halt startup.
|
|
51
|
+
# 3. `for_table(db, table)` returns the definitions a given table has
|
|
52
|
+
# attached via its `addons:` list; unknown add-on names warn and are
|
|
53
|
+
# dropped.
|
|
54
|
+
class CsvAddonRegistry
|
|
55
|
+
@definitions = {}
|
|
56
|
+
@loaded_config = {}
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
attr_reader :definitions, :loaded_config
|
|
60
|
+
|
|
61
|
+
def reset!
|
|
62
|
+
@definitions = {}
|
|
63
|
+
@loaded_config = {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def register(name, &block)
|
|
67
|
+
defn = AddonDefinition.new(name)
|
|
68
|
+
defn.instance_exec(&block) if block
|
|
69
|
+
@definitions[name.to_sym] = defn
|
|
70
|
+
defn
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def [](name)
|
|
74
|
+
@definitions[name.to_sym]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def load(addons_config, _root_dir = nil)
|
|
78
|
+
@loaded_config = (addons_config || {}).each_with_object({}) do |(k, v), h|
|
|
79
|
+
h[k.to_sym] = v
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@loaded_config.each do |name, path|
|
|
83
|
+
unless File.exist?(path.to_s)
|
|
84
|
+
$stderr.puts "\n\e[1;33mWarning: csv_addon '#{name}' file not found: #{path}\e[0m\n\n"
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
# Use Kernel#load (not require) so repeated calls re-evaluate the
|
|
88
|
+
# add-on file — important after `reset!` in tests and to let users
|
|
89
|
+
# refresh their add-on without restarting (the gate is the
|
|
90
|
+
# absolute path in .markdownr.yml).
|
|
91
|
+
Kernel.load(path.to_s)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns an array of AddonDefinition instances attached to the table.
|
|
96
|
+
# Unknown names (declared on the table but not registered) produce a
|
|
97
|
+
# one-time warning per (table, name) and are skipped.
|
|
98
|
+
def for_table(_database, table)
|
|
99
|
+
attached = Array(table.addons).map { |entry| normalize_attachment(entry) }
|
|
100
|
+
attached.filter_map do |att|
|
|
101
|
+
defn = @definitions[att[:name]]
|
|
102
|
+
unless defn
|
|
103
|
+
key = [table.key, att[:name]]
|
|
104
|
+
@warned_missing ||= {}
|
|
105
|
+
unless @warned_missing[key]
|
|
106
|
+
$stderr.puts "\n\e[1;33mWarning: table '#{table.key}' references unknown csv_addon '#{att[:name]}'\e[0m\n\n"
|
|
107
|
+
@warned_missing[key] = true
|
|
108
|
+
end
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
{ definition: defn, options: att[:options] }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def normalize_attachment(entry)
|
|
118
|
+
case entry
|
|
119
|
+
when String, Symbol
|
|
120
|
+
{ name: entry.to_sym, options: {} }
|
|
121
|
+
when Hash
|
|
122
|
+
sym = entry.transform_keys(&:to_sym)
|
|
123
|
+
opts = sym[:options] || {}
|
|
124
|
+
opts = opts.transform_keys(&:to_sym) if opts.is_a?(Hash)
|
|
125
|
+
{ name: (sym[:id] || sym[:name]).to_sym, options: opts }
|
|
126
|
+
else
|
|
127
|
+
{ name: :__invalid__, options: {} }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Top-level alias so add-on files can write:
|
|
135
|
+
# MarkdownServer::CsvAddonRegistry.register(:name) { ... }
|
|
136
|
+
CsvAddonRegistry = CsvBrowser::CsvAddonRegistry
|
|
137
|
+
end
|