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.
@@ -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