markdownr 0.8.0 → 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/markdownr +64 -0
- data/bin/start-claude +2 -0
- data/lib/markdown_server/app.rb +253 -40
- data/lib/markdown_server/assets/editor-loader.js +362 -0
- data/lib/markdown_server/helpers/admin_helpers.rb +10 -0
- 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/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 +1436 -50
- data/views/layout.erb +122 -5
- data/views/popup_assets.erb +52 -26
- metadata +6 -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
|
+
}
|
|
@@ -21,7 +21,17 @@ module MarkdownServer
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
def admin_only_mode?
|
|
25
|
+
return true if settings.admin_only
|
|
26
|
+
setup_config["admin_only"] == true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def admin_only_public_route?(path)
|
|
30
|
+
path == "/admin/login" || path == "/admin/logout" || path == "/robots.txt"
|
|
31
|
+
end
|
|
32
|
+
|
|
24
33
|
def admin?
|
|
34
|
+
return true if settings.admin_all
|
|
25
35
|
return true if session[:admin]
|
|
26
36
|
|
|
27
37
|
adm = setup_config["admin"]
|
|
@@ -49,7 +49,9 @@ module MarkdownServer
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def inline_directory_html(dir_path, relative_dir)
|
|
52
|
-
entries = Dir.entries(dir_path).
|
|
52
|
+
entries = Dir.entries(dir_path).select do |e|
|
|
53
|
+
e != "." && e != ".." && entry_admitted?(dir_path, relative_dir, e)
|
|
54
|
+
end
|
|
53
55
|
items = entries.map do |name|
|
|
54
56
|
stat = File.stat(File.join(dir_path, name)) rescue next
|
|
55
57
|
is_dir = stat.directory?
|
|
@@ -73,7 +73,7 @@ module MarkdownServer
|
|
|
73
73
|
%(<sup id="fnref:#{name}" role="doc-noteref"><a href="#fn:#{name}" class="footnote" rel="footnote">#{h(name)}</a></sup>)
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
|
-
html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
|
|
76
|
+
html = html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
|
|
77
77
|
full_match = $&
|
|
78
78
|
name = $1
|
|
79
79
|
if name =~ /\A\d+\z/
|
|
@@ -82,6 +82,107 @@ module MarkdownServer
|
|
|
82
82
|
%(<li id="fn:#{name}"><p><strong>#{h(name)}:</strong> )
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
|
+
|
|
86
|
+
transform_callouts(html)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Obsidian-style callouts: `> [!TYPE] Optional Title` at the start of a
|
|
90
|
+
# blockquote becomes a styled .callout panel. `[!TYPE]+` makes it a
|
|
91
|
+
# foldable <details open>; `[!TYPE]-` makes it foldable and collapsed.
|
|
92
|
+
CALLOUT_TYPES = {
|
|
93
|
+
"note" => { label: "Note", color: "#448aff" },
|
|
94
|
+
"abstract" => { label: "Abstract", color: "#00b0ff" },
|
|
95
|
+
"summary" => { label: "Summary", color: "#00b0ff" },
|
|
96
|
+
"tldr" => { label: "TL;DR", color: "#00b0ff" },
|
|
97
|
+
"info" => { label: "Info", color: "#00b8d4" },
|
|
98
|
+
"todo" => { label: "Todo", color: "#00b8d4" },
|
|
99
|
+
"tip" => { label: "Tip", color: "#00bfa5" },
|
|
100
|
+
"hint" => { label: "Hint", color: "#00bfa5" },
|
|
101
|
+
"important"=> { label: "Important",color: "#00bfa5" },
|
|
102
|
+
"success" => { label: "Success", color: "#00c853" },
|
|
103
|
+
"check" => { label: "Check", color: "#00c853" },
|
|
104
|
+
"done" => { label: "Done", color: "#00c853" },
|
|
105
|
+
"question" => { label: "Question", color: "#64dd17" },
|
|
106
|
+
"help" => { label: "Help", color: "#64dd17" },
|
|
107
|
+
"faq" => { label: "FAQ", color: "#64dd17" },
|
|
108
|
+
"warning" => { label: "Warning", color: "#ff9100" },
|
|
109
|
+
"caution" => { label: "Caution", color: "#ff9100" },
|
|
110
|
+
"attention"=> { label: "Attention",color: "#ff9100" },
|
|
111
|
+
"failure" => { label: "Failure", color: "#ff5252" },
|
|
112
|
+
"fail" => { label: "Fail", color: "#ff5252" },
|
|
113
|
+
"missing" => { label: "Missing", color: "#ff5252" },
|
|
114
|
+
"danger" => { label: "Danger", color: "#ff1744" },
|
|
115
|
+
"error" => { label: "Error", color: "#ff1744" },
|
|
116
|
+
"bug" => { label: "Bug", color: "#f50057" },
|
|
117
|
+
"example" => { label: "Example", color: "#7c4dff" },
|
|
118
|
+
"quote" => { label: "Quote", color: "#9e9e9e" },
|
|
119
|
+
"cite" => { label: "Cite", color: "#9e9e9e" }
|
|
120
|
+
}.freeze
|
|
121
|
+
|
|
122
|
+
CALLOUT_ICONS = {
|
|
123
|
+
"note" => '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>',
|
|
124
|
+
"abstract" => '<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h6"/>',
|
|
125
|
+
"info" => '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
|
126
|
+
"todo" => '<circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10"/>',
|
|
127
|
+
"tip" => '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
|
128
|
+
"success" => '<polyline points="20 6 9 17 4 12"/>',
|
|
129
|
+
"question" => '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
|
130
|
+
"warning" => '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
|
131
|
+
"failure" => '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
|
|
132
|
+
"danger" => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
|
133
|
+
"bug" => '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="M19 7l-3 2M5 7l3 2M19 13h-3M8 13H5M19 19l-3-2M5 19l3-2M9 2l1 2M15 2l-1 2"/>',
|
|
134
|
+
"example" => '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
|
135
|
+
"quote" => '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>'
|
|
136
|
+
}.freeze
|
|
137
|
+
|
|
138
|
+
CALLOUT_ICON_ALIASES = {
|
|
139
|
+
"summary" => "abstract", "tldr" => "abstract",
|
|
140
|
+
"hint" => "tip", "important" => "tip",
|
|
141
|
+
"check" => "success", "done" => "success",
|
|
142
|
+
"help" => "question", "faq" => "question",
|
|
143
|
+
"caution" => "warning", "attention" => "warning",
|
|
144
|
+
"fail" => "failure", "missing" => "failure",
|
|
145
|
+
"error" => "danger",
|
|
146
|
+
"cite" => "quote"
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
def callout_meta(type)
|
|
150
|
+
CALLOUT_TYPES[type] || { label: type.capitalize, color: "#888888" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def callout_icon_svg(type)
|
|
154
|
+
key = CALLOUT_ICON_ALIASES[type] || type
|
|
155
|
+
body = CALLOUT_ICONS[key] || CALLOUT_ICONS["note"]
|
|
156
|
+
%(<svg class="callout-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">#{body}</svg>)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def transform_callouts(html)
|
|
160
|
+
html.gsub(
|
|
161
|
+
%r{<blockquote>\s*<p>\[!(\w+)\]([+-])?[ \t]*([^\n<]*?)[ \t]*(?:<br\s*/?>\n?|\n)?(.*?)</p>(.*?)</blockquote>}m
|
|
162
|
+
) do
|
|
163
|
+
type = $1.downcase
|
|
164
|
+
fold = $2
|
|
165
|
+
custom_title = $3.to_s.strip
|
|
166
|
+
first_p_rest = $4.to_s.strip
|
|
167
|
+
rest = $5.to_s.strip
|
|
168
|
+
|
|
169
|
+
meta = callout_meta(type)
|
|
170
|
+
title = custom_title.empty? ? meta[:label] : custom_title
|
|
171
|
+
|
|
172
|
+
body = +""
|
|
173
|
+
body << "<p>#{first_p_rest}</p>" unless first_p_rest.empty?
|
|
174
|
+
body << rest
|
|
175
|
+
|
|
176
|
+
icon = callout_icon_svg(type)
|
|
177
|
+
title_html = %(#{icon}<span class="callout-title-inner">#{h(title)}</span>)
|
|
178
|
+
|
|
179
|
+
if fold
|
|
180
|
+
open_attr = fold == "+" ? " open" : ""
|
|
181
|
+
%(<div class="callout callout-#{type}" data-callout="#{type}" style="--callout-color: #{meta[:color]}"><details#{open_attr}><summary class="callout-title">#{title_html}</summary><div class="callout-content">#{body}</div></details></div>)
|
|
182
|
+
else
|
|
183
|
+
%(<div class="callout callout-#{type}" data-callout="#{type}" style="--callout-color: #{meta[:color]}"><div class="callout-title">#{title_html}</div><div class="callout-content">#{body}</div></div>)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
85
186
|
end
|
|
86
187
|
|
|
87
188
|
def resolve_wiki_link(name)
|
|
@@ -101,8 +202,7 @@ module MarkdownServer
|
|
|
101
202
|
real = File.realpath(path) rescue next
|
|
102
203
|
next unless real.start_with?(base)
|
|
103
204
|
relative = real.sub("#{base}/", "")
|
|
104
|
-
|
|
105
|
-
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
205
|
+
next unless MarkdownServer::Unhide.visible?(relative.split("/"), Array(settings.unhide_rules))
|
|
106
206
|
if File.basename(real) == filename
|
|
107
207
|
local_exact = relative
|
|
108
208
|
break
|
|
@@ -124,8 +224,7 @@ module MarkdownServer
|
|
|
124
224
|
real = File.realpath(path) rescue next
|
|
125
225
|
next unless real.start_with?(base)
|
|
126
226
|
relative = real.sub("#{base}/", "")
|
|
127
|
-
|
|
128
|
-
next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
227
|
+
next unless MarkdownServer::Unhide.visible?(relative.split("/"), Array(settings.unhide_rules))
|
|
129
228
|
if File.basename(real) == filename
|
|
130
229
|
exact_match ||= relative
|
|
131
230
|
else
|
|
@@ -203,6 +302,34 @@ module MarkdownServer
|
|
|
203
302
|
formatter.format(lexer.lex(code))
|
|
204
303
|
end
|
|
205
304
|
|
|
305
|
+
def detect_source_language(real_path, content)
|
|
306
|
+
ext = File.extname(real_path).downcase
|
|
307
|
+
by_ext = case ext
|
|
308
|
+
when ".py" then "python"
|
|
309
|
+
when ".rb" then "ruby"
|
|
310
|
+
when ".csv" then "text"
|
|
311
|
+
when ".sh" then "bash"
|
|
312
|
+
when ".yaml", ".yml" then "yaml"
|
|
313
|
+
when ".erb" then "html"
|
|
314
|
+
when ".html" then "html"
|
|
315
|
+
when ".js" then "javascript"
|
|
316
|
+
when ".css" then "css"
|
|
317
|
+
when ".json" then "json"
|
|
318
|
+
end
|
|
319
|
+
return by_ext if by_ext
|
|
320
|
+
|
|
321
|
+
if content.is_a?(String) && content.start_with?("#!")
|
|
322
|
+
shebang = content.lines.first.to_s
|
|
323
|
+
return "ruby" if shebang.match?(/\bruby\b/)
|
|
324
|
+
return "python" if shebang.match?(/\bpython[\d.]*\b/)
|
|
325
|
+
return "bash" if shebang.match?(/\b(bash|zsh|dash|ksh|sh)\b/)
|
|
326
|
+
return "javascript" if shebang.match?(/\bnode\b/)
|
|
327
|
+
return "perl" if shebang.match?(/\bperl\b/)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
"text"
|
|
331
|
+
end
|
|
332
|
+
|
|
206
333
|
def extract_toc(html)
|
|
207
334
|
headings = []
|
|
208
335
|
html.scan(/<h([1-6])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/mi) do |level, id, text|
|
|
@@ -13,6 +13,27 @@ module MarkdownServer
|
|
|
13
13
|
URI.encode_www_form_component(str).gsub("+", "%20")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Returns the permitted base (root realpath or a followed-link target realpath)
|
|
17
|
+
# that contains `real`, or nil if no permitted base contains it.
|
|
18
|
+
def permitted_base_for(real)
|
|
19
|
+
MarkdownServer::PermittedBases.base_for(real, permitted_bases)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def permitted_bases
|
|
23
|
+
[File.realpath(root_dir), *Array(settings.followed_links)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns true if `real` is under a permitted base AND every restricted
|
|
27
|
+
# segment of the path under that base is admitted by the configured
|
|
28
|
+
# unhide rules (per Unhide.visible? algorithm).
|
|
29
|
+
def permitted_path?(real)
|
|
30
|
+
base = permitted_base_for(real)
|
|
31
|
+
return false unless base
|
|
32
|
+
return true if real == base
|
|
33
|
+
rel = real.sub("#{base}/", "")
|
|
34
|
+
MarkdownServer::Unhide.visible?(rel.split("/"), Array(settings.unhide_rules))
|
|
35
|
+
end
|
|
36
|
+
|
|
16
37
|
def safe_path(requested)
|
|
17
38
|
base = File.realpath(root_dir)
|
|
18
39
|
full = File.join(base, requested)
|
|
@@ -23,17 +44,45 @@ module MarkdownServer
|
|
|
23
44
|
halt 404, erb(:layout) { "<h1>Not Found</h1><p>#{h(requested)}</p>" }
|
|
24
45
|
end
|
|
25
46
|
|
|
26
|
-
unless
|
|
27
|
-
|
|
47
|
+
halt 403, erb(:layout) { "<h1>Forbidden</h1>" } unless permitted_path?(real)
|
|
48
|
+
real
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Name-gate check for an entry in a directory listing.
|
|
52
|
+
#
|
|
53
|
+
# Boundary gate (against served root) is NOT applied here so non-followed
|
|
54
|
+
# external non-dot symlinks still appear in listings — matching today's
|
|
55
|
+
# UX; clicking them produces a 403 via safe_path/permitted_path?.
|
|
56
|
+
#
|
|
57
|
+
# When the name gate admits a normally-restricted entry (dotfile or
|
|
58
|
+
# EXCLUDED) that is also a symlink, additionally require the symlink's
|
|
59
|
+
# realpath to be explicitly listed in --follow-link. Otherwise the
|
|
60
|
+
# unhide rule does not surface it. Keeps `unhide` (visibility by name)
|
|
61
|
+
# and `--follow-link` (which symlinks may be entered) as orthogonal
|
|
62
|
+
# opt-ins; prevents internal-aliased dotfile symlinks like
|
|
63
|
+
# `.claude → claude` from showing up as duplicates of their targets.
|
|
64
|
+
def entry_admitted?(parent_real, parent_relative_str, entry_name)
|
|
65
|
+
rules = Array(settings.unhide_rules)
|
|
66
|
+
parent_segs = parent_relative_str.to_s.empty? ? [] : parent_relative_str.split("/")
|
|
67
|
+
|
|
68
|
+
mode = :open
|
|
69
|
+
parent_segs.each_with_index do |seg, i|
|
|
70
|
+
ok, mode = MarkdownServer::Unhide.step(mode, parent_segs, i, seg, rules)
|
|
71
|
+
return false unless ok
|
|
28
72
|
end
|
|
29
73
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
74
|
+
ok, _ = MarkdownServer::Unhide.entry_step(mode, parent_segs, entry_name, rules)
|
|
75
|
+
return false unless ok
|
|
76
|
+
|
|
77
|
+
if MarkdownServer::Unhide.restricted?(entry_name)
|
|
78
|
+
full = File.join(parent_real, entry_name)
|
|
79
|
+
if File.symlink?(full)
|
|
80
|
+
real = File.realpath(full) rescue (return false)
|
|
81
|
+
return false unless Array(settings.followed_links).include?(real)
|
|
82
|
+
end
|
|
34
83
|
end
|
|
35
84
|
|
|
36
|
-
|
|
85
|
+
true
|
|
37
86
|
end
|
|
38
87
|
end
|
|
39
88
|
end
|