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.
@@ -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).reject { |e| e.start_with?(".") || EXCLUDED.include?(e) }
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
- first_segment = relative.split("/").first
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
- first_segment = relative.split("/").first
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 real.start_with?(base)
27
- halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
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
- relative = real.sub("#{base}/", "")
31
- first_segment = relative.split("/").first
32
- if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
33
- halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
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
- real
85
+ true
37
86
  end
38
87
  end
39
88
  end