living-documentation 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,5 +87,10 @@ document
87
87
  window.addEventListener("popstate", (e) => {
88
88
  const id =
89
89
  e.state?.docId || new URLSearchParams(location.search).get("doc");
90
- if (id) openDocument(id, true);
90
+ const anchor =
91
+ e.state?.anchor ||
92
+ (location.hash && location.hash.length > 1
93
+ ? location.hash.slice(1)
94
+ : null);
95
+ if (id) openDocument(id, true, false, anchor);
91
96
  });
@@ -43,17 +43,23 @@ async function refreshAnnotationCounts() {
43
43
  }
44
44
  }
45
45
 
46
- async function openDocument(id, skipHistory = false, fromLink = false) {
46
+ async function openDocument(id, skipHistory = false, fromLink = false, anchor = null) {
47
47
  // Track navigation history for breadcrumb trail
48
48
  // fromLink===true : forward navigation via in-doc link → push current to stack
49
+ // (unless target is already in the stack → rewind instead of loop)
49
50
  // fromLink==="restore" : back navigation via history breadcrumb → stack already trimmed, don't touch
50
51
  // fromLink===false : sidebar/direct navigation → reset stack
51
52
  if (fromLink === true && currentDocId && currentDocId !== id) {
52
- const prev = allDocs && allDocs.find((d) => d.id === currentDocId);
53
- navHistory.push({
54
- id: currentDocId,
55
- title: prev ? prev.title : currentDocId,
56
- });
53
+ const existingIdx = navHistory.findIndex((e) => e.id === id);
54
+ if (existingIdx !== -1) {
55
+ navHistory = navHistory.slice(0, existingIdx);
56
+ } else {
57
+ const prev = allDocs && allDocs.find((d) => d.id === currentDocId);
58
+ navHistory.push({
59
+ id: currentDocId,
60
+ title: prev ? prev.title : currentDocId,
61
+ });
62
+ }
57
63
  } else if (!fromLink) {
58
64
  navHistory = [];
59
65
  }
@@ -105,7 +111,8 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
105
111
  if (!skipHistory) {
106
112
  const url = new URL(location.href);
107
113
  url.searchParams.set("doc", id);
108
- history.pushState({ docId: id }, "", url);
114
+ url.hash = anchor ? `#${anchor}` : "";
115
+ history.pushState({ docId: id, anchor: anchor || null }, "", url);
109
116
  }
110
117
 
111
118
  document.getElementById("welcome").classList.add("hidden");
@@ -158,14 +165,16 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
158
165
  hljs.highlightElement(block);
159
166
  });
160
167
 
161
- // Intercept inter-doc links (?doc=X) to stay in SPA and track origin
168
+ // Intercept inter-doc links (?doc=X[#anchor]) to stay in SPA and track origin
162
169
  contentEl.querySelectorAll("a[href]").forEach((a) => {
163
170
  const href = a.getAttribute("href");
164
171
  const m = href && href.match(/[?&]doc=([^&#]+)/);
165
172
  if (!m) return;
173
+ const hashIdx = href.indexOf("#");
174
+ const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
166
175
  a.addEventListener("click", (e) => {
167
176
  e.preventDefault();
168
- openDocument(decodeURIComponent(m[1]), false, true);
177
+ openDocument(decodeURIComponent(m[1]), false, true, anchor);
169
178
  });
170
179
  });
171
180
 
@@ -199,10 +208,11 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
199
208
 
200
209
  document.title = doc.title;
201
210
 
202
- // Scroll to anchor if present in URL
203
- const hash = window.location.hash;
204
- if (hash && hash.length > 1) {
205
- scrollToAnchor(hash.slice(1));
211
+ // Scroll to anchor if present (explicit param wins over URL hash)
212
+ const targetAnchor =
213
+ anchor || (window.location.hash ? window.location.hash.slice(1) : "");
214
+ if (targetAnchor) {
215
+ scrollToAnchor(targetAnchor);
206
216
  } else {
207
217
  document.getElementById("content-area").scrollTop = 0;
208
218
  }
@@ -331,9 +341,11 @@ async function saveDocument() {
331
341
  const href = a.getAttribute("href");
332
342
  const m = href && href.match(/[?&]doc=([^&#]+)/);
333
343
  if (!m) return;
344
+ const hashIdx = href.indexOf("#");
345
+ const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
334
346
  a.addEventListener("click", (e) => {
335
347
  e.preventDefault();
336
- openDocument(decodeURIComponent(m[1]), false, true);
348
+ openDocument(decodeURIComponent(m[1]), false, true, anchor);
337
349
  });
338
350
  });
339
351
 
@@ -152,6 +152,8 @@
152
152
  "snippet.link_anchor_label": "Anchor",
153
153
  "snippet.link_anchor_placeholder": "my-heading",
154
154
  "snippet.link_anchor_hint": "(without #, e.g. my-heading)",
155
+ "snippet.link_anchor_select_hint": "(pick a heading from the document)",
156
+ "snippet.link_anchor_no_headings": "No headings detected in this document",
155
157
  "snippet.link_target_doc_label": "Target document",
156
158
  "snippet.code_lang_label": "Language",
157
159
  "snippet.code_lang_hint": "(e.g. javascript, python, bash…)",
@@ -152,6 +152,8 @@
152
152
  "snippet.link_anchor_label": "Ancre",
153
153
  "snippet.link_anchor_placeholder": "mon-titre",
154
154
  "snippet.link_anchor_hint": "(sans #, ex : mon-titre)",
155
+ "snippet.link_anchor_select_hint": "(choisissez un titre du document)",
156
+ "snippet.link_anchor_no_headings": "Aucun titre détecté dans ce document",
155
157
  "snippet.link_target_doc_label": "Document cible",
156
158
  "snippet.code_lang_label": "Langage",
157
159
  "snippet.code_lang_hint": "(ex : javascript, python, bash…)",
@@ -1227,18 +1227,15 @@
1227
1227
  <label
1228
1228
  class="block text-xs font-medium text-gray-500 dark:text-gray-400"
1229
1229
  ><span data-i18n="snippet.link_anchor_label">Anchor</span>
1230
- <span data-i18n="snippet.link_anchor_hint" class="font-normal text-gray-400"
1231
- >(without #, e.g. my-heading)</span
1230
+ <span data-i18n="snippet.link_anchor_select_hint" class="font-normal text-gray-400"
1231
+ >(pick a heading from the document)</span
1232
1232
  ></label
1233
1233
  >
1234
- <input
1234
+ <select
1235
1235
  id="snip-anchor-id"
1236
- type="text"
1237
- data-i18n-placeholder="snippet.link_anchor_placeholder"
1238
- placeholder="my-heading"
1239
- oninput="snippetUpdatePreview()"
1236
+ onchange="snippetUpdatePreview()"
1240
1237
  class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
1241
- />
1238
+ ></select>
1242
1239
  </div>
1243
1240
  </div>
1244
1241
 
@@ -1252,7 +1249,7 @@
1252
1249
  >
1253
1250
  <select
1254
1251
  id="snip-anchor-doc-select"
1255
- onchange="snippetUpdatePreview()"
1252
+ onchange="snippetAnchorDocChanged()"
1256
1253
  class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
1257
1254
  ></select>
1258
1255
  </div>
@@ -1275,18 +1272,15 @@
1275
1272
  <label
1276
1273
  class="block text-xs font-medium text-gray-500 dark:text-gray-400"
1277
1274
  ><span data-i18n="snippet.link_anchor_label">Anchor</span>
1278
- <span data-i18n="snippet.link_anchor_hint" class="font-normal text-gray-400"
1279
- >(without #, e.g. my-heading)</span
1275
+ <span data-i18n="snippet.link_anchor_select_hint" class="font-normal text-gray-400"
1276
+ >(pick a heading from the document)</span
1280
1277
  ></label
1281
1278
  >
1282
- <input
1279
+ <select
1283
1280
  id="snip-anchor-doc-id"
1284
- type="text"
1285
- data-i18n-placeholder="snippet.link_anchor_placeholder"
1286
- placeholder="my-heading"
1287
- oninput="snippetUpdatePreview()"
1281
+ onchange="snippetUpdatePreview()"
1288
1282
  class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
1289
- />
1283
+ ></select>
1290
1284
  </div>
1291
1285
  </div>
1292
1286
 
@@ -58,6 +58,96 @@ function colorTextPickSwatch(btn) {
58
58
  snippetUpdatePreview();
59
59
  }
60
60
 
61
+ function _stripMdInline(s) {
62
+ return s
63
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
64
+ .replace(/`([^`]+)`/g, "$1")
65
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
66
+ .replace(/__([^_]+)__/g, "$1")
67
+ .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1$2")
68
+ .replace(/(^|[^_])_([^_]+)_(?!_)/g, "$1$2")
69
+ .trim();
70
+ }
71
+
72
+ function _slugifyHeading(text) {
73
+ return text
74
+ .toLowerCase()
75
+ .replace(/[^\w\s-]/g, "")
76
+ .trim()
77
+ .replace(/\s+/g, "-");
78
+ }
79
+
80
+ function _extractHeadingsFromMarkdown(content) {
81
+ const out = [];
82
+ const lines = (content || "").split("\n");
83
+ let inFence = false;
84
+ for (const line of lines) {
85
+ if (/^```/.test(line)) {
86
+ inFence = !inFence;
87
+ continue;
88
+ }
89
+ if (inFence) continue;
90
+ const m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
91
+ if (!m) continue;
92
+ const text = _stripMdInline(m[2]);
93
+ const slug = _slugifyHeading(text);
94
+ if (slug) out.push({ level: m[1].length, text, slug });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function _collectEditorHeadings() {
100
+ const editor = document.getElementById("doc-editor");
101
+ return _extractHeadingsFromMarkdown(editor ? editor.value : "");
102
+ }
103
+
104
+ function _renderAnchorOptions(sel, headings, emptyKey) {
105
+ if (!sel) return;
106
+ if (headings.length === 0) {
107
+ sel.innerHTML = `<option value="" disabled selected>${window.t(emptyKey)}</option>`;
108
+ return;
109
+ }
110
+ sel.innerHTML = headings
111
+ .map((h) => {
112
+ const indent = "· ".repeat(Math.max(0, h.level - 1));
113
+ return `<option value="${esc(h.slug)}">${esc(indent + h.text)}</option>`;
114
+ })
115
+ .join("");
116
+ }
117
+
118
+ function _populateAnchorSelect() {
119
+ _renderAnchorOptions(
120
+ document.getElementById("snip-anchor-id"),
121
+ _collectEditorHeadings(),
122
+ 'snippet.link_anchor_no_headings',
123
+ );
124
+ }
125
+
126
+ async function snippetAnchorDocChanged() {
127
+ const docSel = document.getElementById("snip-anchor-doc-select");
128
+ const anchorSel = document.getElementById("snip-anchor-doc-id");
129
+ if (!docSel || !anchorSel) return;
130
+ const docId = docSel.value;
131
+ if (!docId) {
132
+ _renderAnchorOptions(anchorSel, [], 'snippet.link_anchor_no_headings');
133
+ snippetUpdatePreview();
134
+ return;
135
+ }
136
+ anchorSel.innerHTML = `<option value="" disabled selected>${window.t('common.loading')}</option>`;
137
+ try {
138
+ const doc = await fetch("/api/documents/" + encodeURIComponent(docId))
139
+ .then((r) => {
140
+ if (!r.ok) throw new Error(r.statusText);
141
+ return r.json();
142
+ });
143
+ const headings = _extractHeadingsFromMarkdown(doc.content || "");
144
+ _renderAnchorOptions(anchorSel, headings, 'snippet.link_anchor_no_headings');
145
+ } catch {
146
+ _renderAnchorOptions(anchorSel, [], 'snippet.link_anchor_no_headings');
147
+ }
148
+ snippetUpdatePreview();
149
+ }
150
+
61
151
  function openSnippetsModal() {
62
152
  const editor = document.getElementById("doc-editor");
63
153
  _snippetSelStart = editor.selectionStart;
@@ -68,6 +158,8 @@ function openSnippetsModal() {
68
158
  .join("");
69
159
  document.getElementById("snip-doc-select").innerHTML = docOpts;
70
160
  document.getElementById("snip-anchor-doc-select").innerHTML = docOpts;
161
+ _populateAnchorSelect();
162
+ snippetAnchorDocChanged();
71
163
 
72
164
  const msgEl = document.getElementById("snippet-detect-msg");
73
165
  const selectedText = editor.value.slice(
@@ -443,7 +535,18 @@ function parseAndFillSnippet(text, type) {
443
535
  const m = t.match(/^\[([\s\S]*?)\]\(#([\s\S]*?)\)$/);
444
536
  if (m) {
445
537
  document.getElementById("snip-anchor-text").value = m[1];
446
- document.getElementById("snip-anchor-id").value = m[2];
538
+ const sel = document.getElementById("snip-anchor-id");
539
+ const wanted = m[2];
540
+ const hasOpt = Array.from(sel.options).some(
541
+ (o) => o.value === wanted,
542
+ );
543
+ if (!hasOpt) {
544
+ const opt = document.createElement("option");
545
+ opt.value = wanted;
546
+ opt.textContent = wanted;
547
+ sel.insertBefore(opt, sel.firstChild);
548
+ }
549
+ sel.value = wanted;
447
550
  }
448
551
  break;
449
552
  }
@@ -460,7 +563,22 @@ function parseAndFillSnippet(text, type) {
460
563
  }
461
564
  }
462
565
  document.getElementById("snip-anchor-doc-text").value = m[1];
463
- document.getElementById("snip-anchor-doc-id").value = m[3];
566
+ const wanted = m[3];
567
+ snippetAnchorDocChanged().then(() => {
568
+ const anchorSel = document.getElementById("snip-anchor-doc-id");
569
+ if (!anchorSel) return;
570
+ const hasOpt = Array.from(anchorSel.options).some(
571
+ (o) => o.value === wanted,
572
+ );
573
+ if (!hasOpt) {
574
+ const opt = document.createElement("option");
575
+ opt.value = wanted;
576
+ opt.textContent = wanted;
577
+ anchorSel.insertBefore(opt, anchorSel.firstChild);
578
+ }
579
+ anchorSel.value = wanted;
580
+ snippetUpdatePreview();
581
+ });
464
582
  }
465
583
  break;
466
584
  }
@@ -0,0 +1,6 @@
1
+ # tata
2
+ [vers tutu](?doc=2026_04_21_19_47_%255BGeneral%255D_tutu#tutu)
3
+
4
+ <br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/>
5
+
6
+ # tata-anchored
@@ -0,0 +1,11 @@
1
+ # tutu
2
+
3
+ [Vers Titi](?doc=2026_04_21_19_52_%255BGeneral%255D_titi#titi)
4
+
5
+ [Vers Tata](?doc=2026_04_21_19_47_%255BGeneral%255D_tata#tata)
6
+
7
+ [Vers tata anchored](?doc=2026_04_21_19_47_%255BGeneral%255D_tata#tata-anchored)
8
+
9
+ <br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/><br/>
10
+
11
+ # tutu-anchored
@@ -0,0 +1,5 @@
1
+ # Titi
2
+
3
+ [Tata](?doc=2026_04_21_19_47_%255BGeneral%255D_tata)
4
+
5
+ [Tata anchored](?doc=2026_04_21_19_47_%255BGeneral%255D_tata#tata-anchored)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "living-documentation",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "A CLI tool that serves a local Markdown documentation viewer",
5
5
  "main": "dist/src/server.js",
6
6
  "bin": {