From 10cacca0a34e2bd1ca75770c3e97e4fe0b822ab9 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:39 -0400 Subject: [PATCH 001/133] test: lock toc.md against the sample docs before the rewrite --- test/tocgen.test.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 test/tocgen.test.js diff --git a/test/tocgen.test.js b/test/tocgen.test.js new file mode 100644 index 0000000..f52b77a --- /dev/null +++ b/test/tocgen.test.js @@ -0,0 +1,28 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const generate = require('../src/tocgen'); + +const root = path.join(__dirname, '..'); + +test('matches docs/toc-test.md byte for byte with maxdepth 3 and no header', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-char-')); + fs.cpSync(path.join(root, 'docs'), dir, { recursive: true }); + const out = path.join(dir, 'toc-test.md'); + return new Promise((resolve, reject) => { + generate(dir, { maxdepth: 3, header: false, file: out }, () => { + try { + const actual = fs.readFileSync(out, 'utf8'); + const expected = fs.readFileSync(path.join(root, 'docs/toc-test.md'), 'utf8'); + assert.equal(actual, expected); + resolve(); + } catch (err) { + reject(err); + } + }); + }); +}); From 54c75ba9224159d5c559bb1c5934fb2f6b5dbd4b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:40 -0400 Subject: [PATCH 002/133] feat: slugify matches Docsify 5 ids for the 23 existing fixture headings --- src/headings.js | 36 ++++++++++++++++++++++++++++++++++++ test/headings.test.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/headings.js create mode 100644 test/headings.test.js diff --git a/src/headings.js b/src/headings.js new file mode 100644 index 0000000..1340558 --- /dev/null +++ b/src/headings.js @@ -0,0 +1,36 @@ +'use strict'; + +function slugify(text, seen) { + void seen; + return text.trim().toLowerCase().replace(/[{}]/g, '').replace(/\s+/g, '-'); +} + +function codeMask(lines) { + return new Array(lines.length).fill(false); +} + +function parse(markdown) { + const headings = []; + const seen = new Map(); + for (const line of String(markdown).split('\n')) { + const atx = line.match(/^ {0,3}(#{1,6})[ \t]+(.*)$/); + if (!atx) continue; + const raw = atx[2].trim(); + headings.push({ + level: atx[1].length, + raw, + text: raw, + id: slugify(raw, seen), + ignore: false, + ignoreAll: false, + }); + } + return { + frontmatter: '', + body: String(markdown), + headings, + firstParagraph: '', + }; +} + +module.exports = { slugify, parse, codeMask }; diff --git a/test/headings.test.js b/test/headings.test.js new file mode 100644 index 0000000..98c265a --- /dev/null +++ b/test/headings.test.js @@ -0,0 +1,43 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const test = require('node:test'); +const { parse, slugify } = require('../src/headings'); + +const root = path.join(__dirname, '..'); + +function idsOf(file) { + return parse(fs.readFileSync(file, 'utf8')).headings.map((heading) => heading.id); +} + +test('slugify matches Docsify 5 ids for the 23 existing fixture headings', () => { + const guide = path.join(root, 'docs/test/markdown-guide.md'); + const file = path.join(root, 'docs/test/test-file.md'); + assert.deepEqual(idsOf(guide).concat(idsOf(file)), [ + 'markdown', + 'headings', + 'heading-1-docsify-ignore', + 'heading-2-docsify-ignore', + 'heading-3-docsify-ignore', + 'heading-4-docsify-ignore', + 'text', + 'links', + 'lists', + 'blockquotes', + 'code', + 'notices', + 'tables', + 'keyboard', + 'horizontal-rule', + 'images', + 'emoji', + 'test-file', + 'header-2', + 'header-3', + 'header-4', + 'header-5', + 'header-6', + ]); +}); From 3f0f69b4d0511234104a987688cee9581620b219 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:40 -0400 Subject: [PATCH 003/133] feat: slugify matches Docsify 5 ids for the 11 edge-case headings --- src/headings.js | 69 ++++++++++++++++++++++++++++++---- test/fixtures/headings/edge.md | 26 +++++++++++++ test/headings.test.js | 18 +++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/headings/edge.md diff --git a/src/headings.js b/src/headings.js index 1340558..6fdb298 100644 --- a/src/headings.js +++ b/src/headings.js @@ -1,26 +1,79 @@ 'use strict'; +const PUNCT = /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g; +const EMOJI = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu; + function slugify(text, seen) { - void seen; - return text.trim().toLowerCase().replace(/[{}]/g, '').replace(/\s+/g, '-'); + let slug = text + .trim() + .normalize('NFC') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/\uFE0F/g, '') + .replace(EMOJI, '') + .toLowerCase() + .replace(/<[^>]+>/g, '') + .replace(PUNCT, '') + .replace(/\s/g, '-') + .replace(/^(\d)/, '_$1'); + const count = seen.has(slug) ? 1 : 0; + seen.set(slug, 1); + return count ? `${slug}-1` : slug; } function codeMask(lines) { - return new Array(lines.length).fill(false); + const mask = new Array(lines.length).fill(false); + let fence = null; + for (let i = 0; i < lines.length; i += 1) { + const match = lines[i].match(/^ {0,3}(`{3,})/); + if (match) { + const len = match[1].length; + if (!fence) { + fence = { len }; + mask[i] = true; + continue; + } + mask[i] = true; + fence = null; + continue; + } + if (fence) mask[i] = true; + } + return mask; } function parse(markdown) { + const lines = String(markdown).split('\n'); + const mask = codeMask(lines); const headings = []; const seen = new Map(); - for (const line of String(markdown).split('\n')) { + for (let i = 0; i < lines.length; i += 1) { + if (mask[i]) continue; + const line = lines[i]; const atx = line.match(/^ {0,3}(#{1,6})[ \t]+(.*)$/); - if (!atx) continue; - const raw = atx[2].trim(); + let level; + let raw; + if (atx) { + level = atx[1].length; + raw = atx[2].trim(); + } else if ( + i + 1 < lines.length + && !mask[i + 1] + && /^ {0,3}-+[ \t]*$/.test(lines[i + 1]) + && line.trim() + ) { + level = 2; + raw = line.trim(); + i += 1; + } else { + continue; + } + const idMatch = raw.match(/(?:^|\s):id=(\S+)/); + const idSeen = /\{docsify-ignore/.test(raw) ? new Map() : seen; headings.push({ - level: atx[1].length, + level, raw, text: raw, - id: slugify(raw, seen), + id: slugify(idMatch ? idMatch[1] : raw, idSeen), ignore: false, ignoreAll: false, }); diff --git a/test/fixtures/headings/edge.md b/test/fixtures/headings/edge.md new file mode 100644 index 0000000..ad251d8 --- /dev/null +++ b/test/fixtures/headings/edge.md @@ -0,0 +1,26 @@ +# Edge cases + +## 1. First step + +## Café Olé + +## C++ & C# + +## Emoji 🚀 rocket + +## Hello, world! :id=hello-world + +## [Link](https://example.com) text + +## Duplicate + +## Duplicate + +## Use `npm i` + +Setext Two +----------- + +``` +## not a heading +``` diff --git a/test/headings.test.js b/test/headings.test.js index 98c265a..1f40871 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -41,3 +41,21 @@ test('slugify matches Docsify 5 ids for the 23 existing fixture headings', () => 'header-6', ]); }); + +test('slugify matches Docsify 5 ids for the 11 edge-case headings', () => { + const parsed = parse(fs.readFileSync(path.join(root, 'test/fixtures/headings/edge.md'), 'utf8')); + assert.deepEqual(parsed.headings.map((heading) => heading.id), [ + 'edge-cases', + '_1-first-step', + 'café-olé', + 'c--c', + 'emoji--rocket', + 'hello-world', + 'link-text', + 'duplicate', + 'duplicate-1', + 'use-npm-i', + 'setext-two', + ]); + assert.equal(parsed.headings.some((heading) => heading.raw.includes('not a heading')), false); +}); From e61d6d3451f1a512a2a585703d3a3f69a88e445e Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:40 -0400 Subject: [PATCH 004/133] =?UTF-8?q?feat:=20slugify=20keeps=20non-ASCII=20u?= =?UTF-8?q?ppercase=20in=20=C3=9Cn=C3=AFc=C3=B6d=C3=A9=20=C3=91ame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/headings.js | 2 +- test/headings.test.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/headings.js b/src/headings.js index 6fdb298..56e406e 100644 --- a/src/headings.js +++ b/src/headings.js @@ -10,7 +10,7 @@ function slugify(text, seen) { .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/\uFE0F/g, '') .replace(EMOJI, '') - .toLowerCase() + .replace(/[A-Z]+/g, (letters) => letters.toLowerCase()) .replace(/<[^>]+>/g, '') .replace(PUNCT, '') .replace(/\s/g, '-') diff --git a/test/headings.test.js b/test/headings.test.js index 1f40871..f6d79f4 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -59,3 +59,7 @@ test('slugify matches Docsify 5 ids for the 11 edge-case headings', () => { ]); assert.equal(parsed.headings.some((heading) => heading.raw.includes('not a heading')), false); }); + +test('slugify keeps non-ASCII uppercase in Ünïcödé Ñame', () => { + assert.equal(slugify('Ünïcödé Ñame', new Map()), 'Ünïcödé-Ñame'); +}); From 16c3f2d7f20512f244c54ffc1831b4cb9d9600e0 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:41 -0400 Subject: [PATCH 005/133] feat: duplicate headings get -1 and -2 suffixes --- src/headings.js | 6 +++--- test/headings.test.js | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/headings.js b/src/headings.js index 56e406e..9f52a04 100644 --- a/src/headings.js +++ b/src/headings.js @@ -15,9 +15,9 @@ function slugify(text, seen) { .replace(PUNCT, '') .replace(/\s/g, '-') .replace(/^(\d)/, '_$1'); - const count = seen.has(slug) ? 1 : 0; - seen.set(slug, 1); - return count ? `${slug}-1` : slug; + const count = seen.has(slug) ? seen.get(slug) + 1 : 0; + seen.set(slug, count); + return count ? `${slug}-${count}` : slug; } function codeMask(lines) { diff --git a/test/headings.test.js b/test/headings.test.js index f6d79f4..ab6c13a 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -63,3 +63,12 @@ test('slugify matches Docsify 5 ids for the 11 edge-case headings', () => { test('slugify keeps non-ASCII uppercase in Ünïcödé Ñame', () => { assert.equal(slugify('Ünïcödé Ñame', new Map()), 'Ünïcödé-Ñame'); }); + +test('duplicate headings get -1 and -2 suffixes', () => { + const parsed = parse('# Duplicate\n# Duplicate\n# Duplicate\n'); + assert.deepEqual(parsed.headings.map((heading) => heading.id), [ + 'duplicate', + 'duplicate-1', + 'duplicate-2', + ]); +}); From f7d997d7c17d3f1a8378c272cd99067ff8bebefd Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:41 -0400 Subject: [PATCH 006/133] feat: ignored and deep headings count toward duplicate numbering --- src/headings.js | 5 ++--- test/headings.test.js | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/headings.js b/src/headings.js index 9f52a04..bd6b05f 100644 --- a/src/headings.js +++ b/src/headings.js @@ -68,13 +68,12 @@ function parse(markdown) { continue; } const idMatch = raw.match(/(?:^|\s):id=(\S+)/); - const idSeen = /\{docsify-ignore/.test(raw) ? new Map() : seen; headings.push({ level, raw, text: raw, - id: slugify(idMatch ? idMatch[1] : raw, idSeen), - ignore: false, + id: slugify(idMatch ? idMatch[1] : raw, seen), + ignore: /\{docsify-ignore\}/.test(raw), ignoreAll: false, }); } diff --git a/test/headings.test.js b/test/headings.test.js index ab6c13a..43d0b3b 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -72,3 +72,23 @@ test('duplicate headings get -1 and -2 suffixes', () => { 'duplicate-2', ]); }); + +test('ignored and deep headings count toward duplicate numbering', () => { + const parsed = parse([ + '# Duplicate', + '# Duplicate {docsify-ignore}', + '# Duplicate {docsify-ignore}', + '###### Duplicate', + '# Duplicate', + ].join('\n')); + assert.deepEqual(parsed.headings.map((heading) => heading.id), [ + 'duplicate', + 'duplicate-docsify-ignore', + 'duplicate-docsify-ignore-1', + 'duplicate-1', + 'duplicate-2', + ]); + assert.equal(parsed.headings[1].ignore, true); + assert.equal(parsed.headings[2].ignore, true); + assert.equal(parsed.headings[3].level, 6); +}); From 0101c9dc12bcdd26076737ea72fc2f0e4f525256 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:42 -0400 Subject: [PATCH 007/133] feat: :id= sets the id and is removed from the text --- src/headings.js | 2 +- test/headings.test.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/headings.js b/src/headings.js index bd6b05f..c2660e4 100644 --- a/src/headings.js +++ b/src/headings.js @@ -71,7 +71,7 @@ function parse(markdown) { headings.push({ level, raw, - text: raw, + text: raw.replace(/(^|\s):id=\S+/g, '$1').replace(/\s+/g, ' ').trim(), id: slugify(idMatch ? idMatch[1] : raw, seen), ignore: /\{docsify-ignore\}/.test(raw), ignoreAll: false, diff --git a/test/headings.test.js b/test/headings.test.js index 43d0b3b..9b8b115 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -92,3 +92,9 @@ test('ignored and deep headings count toward duplicate numbering', () => { assert.equal(parsed.headings[2].ignore, true); assert.equal(parsed.headings[3].level, 6); }); + +test(':id= sets the id and is removed from the text', () => { + const parsed = parse('# Hello, world! :id=custom-id\n'); + assert.equal(parsed.headings[0].id, 'custom-id'); + assert.equal(parsed.headings[0].text, 'Hello, world!'); +}); From 3bd94520ccc11e88eecb4cdf0c15aa102dda3cec Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:42 -0400 Subject: [PATCH 008/133] feat: headings inside backtick and tilde fences are skipped --- src/headings.js | 2 +- test/headings.test.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/headings.js b/src/headings.js index c2660e4..31f5a94 100644 --- a/src/headings.js +++ b/src/headings.js @@ -24,7 +24,7 @@ function codeMask(lines) { const mask = new Array(lines.length).fill(false); let fence = null; for (let i = 0; i < lines.length; i += 1) { - const match = lines[i].match(/^ {0,3}(`{3,})/); + const match = lines[i].match(/^ {0,3}(`{3,}|~{3,})/); if (match) { const len = match[1].length; if (!fence) { diff --git a/test/headings.test.js b/test/headings.test.js index 9b8b115..84d615e 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -98,3 +98,8 @@ test(':id= sets the id and is removed from the text', () => { assert.equal(parsed.headings[0].id, 'custom-id'); assert.equal(parsed.headings[0].text, 'Hello, world!'); }); + +test('headings inside backtick and tilde fences are skipped', () => { + const parsed = parse('```\n## nope\n```\n~~~\n## also nope\n~~~\n# Yes\n'); + assert.deepEqual(parsed.headings.map((heading) => heading.text), ['Yes']); +}); From f5cf105c80c93683eb3a89dc7c0d0c250fed11fd Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:43 -0400 Subject: [PATCH 009/133] feat: a fence closes only on the same character at equal or greater length --- src/headings.js | 14 +++++++++----- test/headings.test.js | 5 +++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/headings.js b/src/headings.js index 31f5a94..0e31a4a 100644 --- a/src/headings.js +++ b/src/headings.js @@ -26,15 +26,19 @@ function codeMask(lines) { for (let i = 0; i < lines.length; i += 1) { const match = lines[i].match(/^ {0,3}(`{3,}|~{3,})/); if (match) { - const len = match[1].length; + const token = match[1]; + const char = token[0]; + const len = token.length; if (!fence) { - fence = { len }; + fence = { char, len }; mask[i] = true; continue; } - mask[i] = true; - fence = null; - continue; + if (char === fence.char && len >= fence.len) { + mask[i] = true; + fence = null; + continue; + } } if (fence) mask[i] = true; } diff --git a/test/headings.test.js b/test/headings.test.js index 84d615e..234f547 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -103,3 +103,8 @@ test('headings inside backtick and tilde fences are skipped', () => { const parsed = parse('```\n## nope\n```\n~~~\n## also nope\n~~~\n# Yes\n'); assert.deepEqual(parsed.headings.map((heading) => heading.text), ['Yes']); }); + +test('a fence closes only on the same character at equal or greater length', () => { + const parsed = parse('````\n```\n## inside\n````\n# After\n~~~\n## tilde\n```\n## stays in tilde\n~~~\n# Done\n'); + assert.deepEqual(parsed.headings.map((heading) => heading.text), ['After', 'Done']); +}); From 6be92fade0ca5fd09bee4dd732ba4e9730674ccb Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:43 -0400 Subject: [PATCH 010/133] feat: setext headings parse as level 1 and level 2 --- src/headings.js | 4 ++-- test/headings.test.js | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/headings.js b/src/headings.js index 0e31a4a..84deb51 100644 --- a/src/headings.js +++ b/src/headings.js @@ -62,10 +62,10 @@ function parse(markdown) { } else if ( i + 1 < lines.length && !mask[i + 1] - && /^ {0,3}-+[ \t]*$/.test(lines[i + 1]) + && /^ {0,3}(?:=+|-+)[ \t]*$/.test(lines[i + 1]) && line.trim() ) { - level = 2; + level = lines[i + 1].trim()[0] === '=' ? 1 : 2; raw = line.trim(); i += 1; } else { diff --git a/test/headings.test.js b/test/headings.test.js index 234f547..7a427f4 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -108,3 +108,11 @@ test('a fence closes only on the same character at equal or greater length', () const parsed = parse('````\n```\n## inside\n````\n# After\n~~~\n## tilde\n```\n## stays in tilde\n~~~\n# Done\n'); assert.deepEqual(parsed.headings.map((heading) => heading.text), ['After', 'Done']); }); + +test('setext headings parse as level 1 and level 2', () => { + const parsed = parse('Top\n===\n\nSub\n---\n'); + assert.deepEqual(parsed.headings.map((heading) => [heading.level, heading.id]), [ + [1, 'top'], + [2, 'sub'], + ]); +}); From f74b03837bd97173e3cfe6287817a79a536424f6 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:44 -0400 Subject: [PATCH 011/133] feat: a list item above --- is not a setext heading --- src/headings.js | 1 + test/headings.test.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/headings.js b/src/headings.js index 84deb51..eb62031 100644 --- a/src/headings.js +++ b/src/headings.js @@ -64,6 +64,7 @@ function parse(markdown) { && !mask[i + 1] && /^ {0,3}(?:=+|-+)[ \t]*$/.test(lines[i + 1]) && line.trim() + && !/^\s*(?:[-*+]|\d+\.)[ \t]/.test(line) ) { level = lines[i + 1].trim()[0] === '=' ? 1 : 2; raw = line.trim(); diff --git a/test/headings.test.js b/test/headings.test.js index 7a427f4..e60b531 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -116,3 +116,8 @@ test('setext headings parse as level 1 and level 2', () => { [2, 'sub'], ]); }); + +test('a list item above --- is not a setext heading', () => { + const parsed = parse('- item\n---\n# Real\n'); + assert.deepEqual(parsed.headings.map((heading) => heading.text), ['Real']); +}); From 2f8905eb2709923b0031c0ceabda6965aa1a96e9 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:44 -0400 Subject: [PATCH 012/133] feat: closing hashes are removed from ATX headings --- src/headings.js | 2 +- test/headings.test.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/headings.js b/src/headings.js index eb62031..08b530c 100644 --- a/src/headings.js +++ b/src/headings.js @@ -53,7 +53,7 @@ function parse(markdown) { for (let i = 0; i < lines.length; i += 1) { if (mask[i]) continue; const line = lines[i]; - const atx = line.match(/^ {0,3}(#{1,6})[ \t]+(.*)$/); + const atx = line.match(/^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/); let level; let raw; if (atx) { diff --git a/test/headings.test.js b/test/headings.test.js index e60b531..f283f40 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -121,3 +121,9 @@ test('a list item above --- is not a setext heading', () => { const parsed = parse('- item\n---\n# Real\n'); assert.deepEqual(parsed.headings.map((heading) => heading.text), ['Real']); }); + +test('closing hashes are removed from ATX headings', () => { + const parsed = parse('## Hello ##\n'); + assert.equal(parsed.headings[0].text, 'Hello'); + assert.equal(parsed.headings[0].id, 'hello'); +}); From e1bd71e2d5dfb1361c11d7656c6946ead761537e Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:45 -0400 Subject: [PATCH 013/133] feat: front matter is skipped and returned --- src/headings.js | 10 +++++++--- test/headings.test.js | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/headings.js b/src/headings.js index 08b530c..298bc69 100644 --- a/src/headings.js +++ b/src/headings.js @@ -46,7 +46,11 @@ function codeMask(lines) { } function parse(markdown) { - const lines = String(markdown).split('\n'); + const text = String(markdown); + const fm = text.match(/^---\n([\s\S]*?)\n---\n/); + const frontmatter = fm ? fm[1] : ''; + const rest = fm ? text.slice(fm[0].length) : text; + const lines = rest.split('\n'); const mask = codeMask(lines); const headings = []; const seen = new Map(); @@ -83,8 +87,8 @@ function parse(markdown) { }); } return { - frontmatter: '', - body: String(markdown), + frontmatter, + body: rest, headings, firstParagraph: '', }; diff --git a/test/headings.test.js b/test/headings.test.js index f283f40..09402d6 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -127,3 +127,10 @@ test('closing hashes are removed from ATX headings', () => { assert.equal(parsed.headings[0].text, 'Hello'); assert.equal(parsed.headings[0].id, 'hello'); }); + +test('front matter is skipped and returned', () => { + const parsed = parse('---\ndescription: "Hi"\n---\n# Title\n'); + assert.equal(parsed.frontmatter, 'description: "Hi"'); + assert.equal(parsed.headings[0].text, 'Title'); + assert.equal(parsed.body.startsWith('# Title'), true); +}); From b3c2049eae4724733683ca299461a95cdd0ede0c Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:45 -0400 Subject: [PATCH 014/133] feat: CRLF and a leading BOM parse the same as LF --- src/headings.js | 2 +- test/headings.test.js | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/headings.js b/src/headings.js index 298bc69..4b6376d 100644 --- a/src/headings.js +++ b/src/headings.js @@ -46,7 +46,7 @@ function codeMask(lines) { } function parse(markdown) { - const text = String(markdown); + const text = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const fm = text.match(/^---\n([\s\S]*?)\n---\n/); const frontmatter = fm ? fm[1] : ''; const rest = fm ? text.slice(fm[0].length) : text; diff --git a/test/headings.test.js b/test/headings.test.js index 09402d6..67e3659 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -134,3 +134,10 @@ test('front matter is skipped and returned', () => { assert.equal(parsed.headings[0].text, 'Title'); assert.equal(parsed.body.startsWith('# Title'), true); }); + +test('CRLF and a leading BOM parse the same as LF', () => { + const lf = parse('# Title\n\nHello.\n'); + const crlf = parse('\uFEFF# Title\r\n\r\nHello.\r\n'); + assert.deepEqual(crlf.headings, lf.headings); + assert.equal(crlf.firstParagraph, lf.firstParagraph); +}); From b63bc2fc109bf0e25911623c13cb3efd4740ed94 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:46 -0400 Subject: [PATCH 015/133] feat: both ignore forms set ignore and both ignore-all forms set ignoreAll --- src/headings.js | 15 +++++++++++++-- test/headings.test.js | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/headings.js b/src/headings.js index 4b6376d..d6569df 100644 --- a/src/headings.js +++ b/src/headings.js @@ -45,6 +45,17 @@ function codeMask(lines) { return mask; } + +function hasIgnore(raw) { + if (//.test(raw)) return true; + return /\{docsify-ignore\}/.test(raw); +} + +function hasIgnoreAll(raw) { + if (//.test(raw)) return true; + return /\{docsify-ignore-all\}/.test(raw); +} + function parse(markdown) { const text = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const fm = text.match(/^---\n([\s\S]*?)\n---\n/); @@ -82,8 +93,8 @@ function parse(markdown) { raw, text: raw.replace(/(^|\s):id=\S+/g, '$1').replace(/\s+/g, ' ').trim(), id: slugify(idMatch ? idMatch[1] : raw, seen), - ignore: /\{docsify-ignore\}/.test(raw), - ignoreAll: false, + ignore: hasIgnore(raw), + ignoreAll: hasIgnoreAll(raw), }); } return { diff --git a/test/headings.test.js b/test/headings.test.js index 67e3659..8d5b082 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -141,3 +141,18 @@ test('CRLF and a leading BOM parse the same as LF', () => { assert.deepEqual(crlf.headings, lf.headings); assert.equal(crlf.firstParagraph, lf.firstParagraph); }); + +test('both ignore forms set ignore and both ignore-all forms set ignoreAll', () => { + const parsed = parse([ + '# A {docsify-ignore}', + '# B ', + '# C {docsify-ignore-all}', + '# D ', + ].join('\n')); + assert.deepEqual(parsed.headings.map((heading) => [heading.ignore, heading.ignoreAll]), [ + [true, false], + [true, false], + [false, true], + [false, true], + ]); +}); From 330b9d78ac403737537e93f553a1ad57ad08c881 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:46 -0400 Subject: [PATCH 016/133] feat: heading text drops markers, :id=, link syntax, and HTML tags --- src/headings.js | 14 +++++++++++++- test/headings.test.js | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/headings.js b/src/headings.js index d6569df..557724b 100644 --- a/src/headings.js +++ b/src/headings.js @@ -46,6 +46,18 @@ function codeMask(lines) { } + +function headingText(raw) { + return raw + .replace(/[ \t]*/g, '') + .replace(/[ \t]*\{docsify-ignore(?:-all)?\}/g, '') + .replace(/(^|\s):id=\S+/g, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + function hasIgnore(raw) { if (//.test(raw)) return true; return /\{docsify-ignore\}/.test(raw); @@ -91,7 +103,7 @@ function parse(markdown) { headings.push({ level, raw, - text: raw.replace(/(^|\s):id=\S+/g, '$1').replace(/\s+/g, ' ').trim(), + text: headingText(raw), id: slugify(idMatch ? idMatch[1] : raw, seen), ignore: hasIgnore(raw), ignoreAll: hasIgnoreAll(raw), diff --git a/test/headings.test.js b/test/headings.test.js index 8d5b082..6e6eba0 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -156,3 +156,9 @@ test('both ignore forms set ignore and both ignore-all forms set ignoreAll', () [false, true], ]); }); + +test('heading text drops markers, :id=, link syntax, and HTML tags', () => { + const parsed = parse('# See [Docs](https://example.com) now :id=clean \n'); + assert.equal(parsed.headings[0].text, 'See Docs now'); + assert.equal(parsed.headings[0].id, 'clean'); +}); From c8c37f3c9eefb73fed41bf50eeb01f5ce6bf806a Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:47 -0400 Subject: [PATCH 017/133] feat: blockquote lines are not headings --- src/headings.js | 1 + test/headings.test.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/headings.js b/src/headings.js index 557724b..84f02fd 100644 --- a/src/headings.js +++ b/src/headings.js @@ -80,6 +80,7 @@ function parse(markdown) { for (let i = 0; i < lines.length; i += 1) { if (mask[i]) continue; const line = lines[i]; + if (/^\s*>/.test(line)) continue; const atx = line.match(/^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/); let level; let raw; diff --git a/test/headings.test.js b/test/headings.test.js index 6e6eba0..289da98 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -162,3 +162,8 @@ test('heading text drops markers, :id=, link syntax, and HTML tags', () => { assert.equal(parsed.headings[0].text, 'See Docs now'); assert.equal(parsed.headings[0].id, 'clean'); }); + +test('blockquote lines are not headings', () => { + const parsed = parse('> # Not ATX\n> Quote title\n---\n# Real\n'); + assert.deepEqual(parsed.headings.map((heading) => heading.text), ['Real']); +}); From 2b4c7804040525339caba27e3ad5cd5f998748a2 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:47 -0400 Subject: [PATCH 018/133] feat: first paragraph skips badge-only lines, notices, lists, and code --- src/headings.js | 85 ++++++++++++++++++++++++++++++++++++++++--- test/headings.test.js | 28 ++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/headings.js b/src/headings.js index 84f02fd..5773a28 100644 --- a/src/headings.js +++ b/src/headings.js @@ -1,5 +1,6 @@ 'use strict'; +// Docsify 5 punctuation class from src/core/render/slugify.js (docsify@5.0.0). const PUNCT = /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g; const EMOJI = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu; @@ -45,7 +46,9 @@ function codeMask(lines) { return mask; } - +function normalize(markdown) { + return String(markdown).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} function headingText(raw) { return raw @@ -68,8 +71,76 @@ function hasIgnoreAll(raw) { return /\{docsify-ignore-all\}/.test(raw); } +function onlyMedia(raw) { + const left = raw + .replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[[^\]]*\]\([^)]*:include[^)]*\)/g, '') + .trim(); + return left.length === 0; +} + +function plainText(raw) { + return raw + .replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/`+/g, '') + .replace(/[*_~]+/g, '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function isStructural(line) { + if (/^ {0,3}#{1,6}[ \t]/.test(line)) return true; + if (/^\s*(?:[-*+]|\d+\.)[ \t]/.test(line)) return true; + if (/^\s*>/.test(line)) return true; + if (/^\s*[!?]>\s?/.test(line)) return true; + if (line.includes('|')) return true; + if (/^\s*<[^>]+>\s*$/.test(line)) return true; + return false; +} + +function firstParagraph(body) { + const lines = body.split('\n'); + const mask = codeMask(lines); + let start = 0; + for (let i = 0; i < lines.length; i += 1) { + if (mask[i]) continue; + const atx = lines[i].match(/^ {0,3}(#{1,6})[ \t]/); + if (atx && atx[1].length === 1) { + start = i + 1; + break; + } + } + const buf = []; + function take() { + if (!buf.length) return ''; + const raw = buf.join(' '); + buf.length = 0; + if (onlyMedia(raw)) return ''; + return plainText(raw); + } + for (let i = start; i < lines.length; i += 1) { + const line = lines[i]; + if (mask[i] || isStructural(line)) { + const got = take(); + if (got) return got; + continue; + } + if (!line.trim()) { + const got = take(); + if (got) return got; + continue; + } + buf.push(line.trim()); + } + return take(); +} + function parse(markdown) { - const text = String(markdown).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const text = normalize(markdown); const fm = text.match(/^---\n([\s\S]*?)\n---\n/); const frontmatter = fm ? fm[1] : ''; const rest = fm ? text.slice(fm[0].length) : text; @@ -86,7 +157,7 @@ function parse(markdown) { let raw; if (atx) { level = atx[1].length; - raw = atx[2].trim(); + raw = atx[2]; } else if ( i + 1 < lines.length && !mask[i + 1] @@ -114,8 +185,12 @@ function parse(markdown) { frontmatter, body: rest, headings, - firstParagraph: '', + firstParagraph: firstParagraph(rest), }; } -module.exports = { slugify, parse, codeMask }; +module.exports = { + slugify, + parse, + codeMask, +}; diff --git a/test/headings.test.js b/test/headings.test.js index 289da98..e8bfa24 100644 --- a/test/headings.test.js +++ b/test/headings.test.js @@ -167,3 +167,31 @@ test('blockquote lines are not headings', () => { const parsed = parse('> # Not ATX\n> Quote title\n---\n# Real\n'); assert.deepEqual(parsed.headings.map((heading) => heading.text), ['Real']); }); + +test('first paragraph skips badge-only lines, notices, lists, and code', () => { + const parsed = parse([ + '```', + '# Fake', + '```', + '## Second', + '# Real Title', + '', + '[![npm](https://img.example/badge.svg)](https://example.com)', + '', + '!> A notice', + '?> A tip', + '- a list', + '> a quote', + '| a | table |', + '
', + '', + '```', + 'code', + '```', + '', + 'The real paragraph is here. More text.', + ].join('\n')); + assert.equal(parsed.firstParagraph, 'The real paragraph is here. More text.'); + const noH1 = parse('Just words without a stop\n'); + assert.equal(noH1.firstParagraph, 'Just words without a stop'); +}); From 2ee6596e95e1b37e96439cb02b7be7590368e4f2 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:48 -0400 Subject: [PATCH 019/133] feat: lists .md files recursively in en locale order with / separators --- src/files.js | 20 ++++++++++++++++++++ test/files.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/files.js create mode 100644 test/files.test.js diff --git a/src/files.js b/src/files.js new file mode 100644 index 0000000..190a48f --- /dev/null +++ b/src/files.js @@ -0,0 +1,20 @@ + +'use strict'; +const fs = require('fs'); +const path = require('path'); +function listPages(docsDir, { exclude = [] } = {}) { + const excluded = new Set(exclude.map((item) => path.resolve(item))); + const names = fs.readdirSync(docsDir, { recursive: true }); + const pages = []; + for (const name of names) { + const rel = String(name).split(path.sep).join('/'); + const abs = path.resolve(docsDir, rel); + if (!fs.statSync(abs).isFile()) continue; + + pages.push({ rel, abs }); + } + pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); + return pages; +} +function findHomePage() { return null; } +module.exports = { listPages, findHomePage }; diff --git a/test/files.test.js b/test/files.test.js new file mode 100644 index 0000000..abeb508 --- /dev/null +++ b/test/files.test.js @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const { listPages, findHomePage } = require('../src/files'); + +function makeDocs(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-files-')); + for (const [rel, text] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, text); + } + return dir; +} + +test('lists .md files recursively in en locale order with / separators', () => { + const dir = makeDocs({ + 'B.md': '# B\n', + 'a.md': '# A\n', + 'sub/c.md': '# C\n', + }); + const pages = listPages(dir); + assert.deepEqual(pages.map((page) => page.rel), ['a.md', 'B.md', 'sub/c.md']); + assert.equal(pages[2].abs, path.resolve(dir, 'sub/c.md')); +}); From 48dcdffeb183889e8276770f9b82476fdee3d3c5 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:48 -0400 Subject: [PATCH 020/133] feat: skips files whose names start with an underscore --- src/files.js | 2 +- test/files.test.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/files.js b/src/files.js index 190a48f..20288cc 100644 --- a/src/files.js +++ b/src/files.js @@ -10,7 +10,7 @@ function listPages(docsDir, { exclude = [] } = {}) { const rel = String(name).split(path.sep).join('/'); const abs = path.resolve(docsDir, rel); if (!fs.statSync(abs).isFile()) continue; - + if (rel.includes('_')) continue; pages.push({ rel, abs }); } pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); diff --git a/test/files.test.js b/test/files.test.js index abeb508..887411c 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -27,3 +27,11 @@ test('lists .md files recursively in en locale order with / separators', () => { assert.deepEqual(pages.map((page) => page.rel), ['a.md', 'B.md', 'sub/c.md']); assert.equal(pages[2].abs, path.resolve(dir, 'sub/c.md')); }); + +test('skips files whose names start with an underscore', () => { + const dir = makeDocs({ + '_sidebar.md': '# S\n', + 'guide.md': '# G\n', + }); + assert.deepEqual(listPages(dir).map((page) => page.rel), ['guide.md']); +}); From f0dfe69afe4bf77b3043dd207d8a3078460f13b9 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:49 -0400 Subject: [PATCH 021/133] feat: keeps my_guide.md and api/v2_beta/intro.md --- src/files.js | 2 +- test/files.test.js | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/files.js b/src/files.js index 20288cc..fb77444 100644 --- a/src/files.js +++ b/src/files.js @@ -10,7 +10,7 @@ function listPages(docsDir, { exclude = [] } = {}) { const rel = String(name).split(path.sep).join('/'); const abs = path.resolve(docsDir, rel); if (!fs.statSync(abs).isFile()) continue; - if (rel.includes('_')) continue; + if (path.posix.basename(rel).startsWith('_')) continue; pages.push({ rel, abs }); } pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); diff --git a/test/files.test.js b/test/files.test.js index 887411c..1075e4b 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -35,3 +35,14 @@ test('skips files whose names start with an underscore', () => { }); assert.deepEqual(listPages(dir).map((page) => page.rel), ['guide.md']); }); + +test('keeps my_guide.md and api/v2_beta/intro.md', () => { + const dir = makeDocs({ + 'my_guide.md': '# M\n', + 'api/v2_beta/intro.md': '# I\n', + }); + assert.deepEqual(listPages(dir).map((page) => page.rel), [ + 'api/v2_beta/intro.md', + 'my_guide.md', + ]); +}); From 81b6f3bff5758cda29d960c8ce89f45591c3a8e2 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:49 -0400 Subject: [PATCH 022/133] feat: skips folders whose names start with an underscore --- src/files.js | 2 +- test/files.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/files.js b/src/files.js index fb77444..fd0758b 100644 --- a/src/files.js +++ b/src/files.js @@ -10,7 +10,7 @@ function listPages(docsDir, { exclude = [] } = {}) { const rel = String(name).split(path.sep).join('/'); const abs = path.resolve(docsDir, rel); if (!fs.statSync(abs).isFile()) continue; - if (path.posix.basename(rel).startsWith('_')) continue; + if (rel.split('/').some((seg) => seg.startsWith('_'))) continue; pages.push({ rel, abs }); } pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); diff --git a/test/files.test.js b/test/files.test.js index 1075e4b..c571cfa 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -46,3 +46,12 @@ test('keeps my_guide.md and api/v2_beta/intro.md', () => { 'my_guide.md', ]); }); + +test('skips folders whose names start with an underscore', () => { + const dir = makeDocs({ + '_media/pic.md': '# P\n', + 'guide/_draft/secret.md': '# S\n', + 'guide/ok.md': '# O\n', + }); + assert.deepEqual(listPages(dir).map((page) => page.rel), ['guide/ok.md']); +}); From 16b6ec4597b47e6371032dd2dedaf73b365d6998 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:49 -0400 Subject: [PATCH 023/133] feat: skips README.md and index.md by exact basename at any depth --- src/files.js | 1 + test/files.test.js | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/files.js b/src/files.js index fd0758b..8543f53 100644 --- a/src/files.js +++ b/src/files.js @@ -11,6 +11,7 @@ function listPages(docsDir, { exclude = [] } = {}) { const abs = path.resolve(docsDir, rel); if (!fs.statSync(abs).isFile()) continue; if (rel.split('/').some((seg) => seg.startsWith('_'))) continue; + if (rel.includes('README.md') || rel.includes('index.md')) continue; pages.push({ rel, abs }); } pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); diff --git a/test/files.test.js b/test/files.test.js index c571cfa..7d75e66 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -55,3 +55,14 @@ test('skips folders whose names start with an underscore', () => { }); assert.deepEqual(listPages(dir).map((page) => page.rel), ['guide/ok.md']); }); + +test('skips README.md and index.md by exact basename at any depth', () => { + const dir = makeDocs({ + 'README.md': '# R\n', + 'index.md': '# I\n', + 'guide/README.md': '# GR\n', + 'guide/index.md': '# GI\n', + 'guide/page.md': '# P\n', + }); + assert.deepEqual(listPages(dir).map((page) => page.rel), ['guide/page.md']); +}); From a3713ff7524d393958323ebb5e1726deff9d2fe8 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:50 -0400 Subject: [PATCH 024/133] feat: keeps subindex.md and readme-notes.md --- src/files.js | 3 ++- test/files.test.js | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/files.js b/src/files.js index 8543f53..59ec2a9 100644 --- a/src/files.js +++ b/src/files.js @@ -11,7 +11,8 @@ function listPages(docsDir, { exclude = [] } = {}) { const abs = path.resolve(docsDir, rel); if (!fs.statSync(abs).isFile()) continue; if (rel.split('/').some((seg) => seg.startsWith('_'))) continue; - if (rel.includes('README.md') || rel.includes('index.md')) continue; + const base = path.posix.basename(rel); + if (base === 'README.md' || base === 'index.md') continue; pages.push({ rel, abs }); } pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); diff --git a/test/files.test.js b/test/files.test.js index 7d75e66..a59c1ea 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -66,3 +66,14 @@ test('skips README.md and index.md by exact basename at any depth', () => { }); assert.deepEqual(listPages(dir).map((page) => page.rel), ['guide/page.md']); }); + +test('keeps subindex.md and readme-notes.md', () => { + const dir = makeDocs({ + 'subindex.md': '# S\n', + 'readme-notes.md': '# R\n', + }); + assert.deepEqual(listPages(dir).map((page) => page.rel), [ + 'readme-notes.md', + 'subindex.md', + ]); +}); From f9b78ff1552e29a2ec419a28e146538b527b7f4c Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:51 -0400 Subject: [PATCH 025/133] feat: skips the excluded output file by resolved path --- src/files.js | 1 + test/files.test.js | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/files.js b/src/files.js index 59ec2a9..2d1e876 100644 --- a/src/files.js +++ b/src/files.js @@ -13,6 +13,7 @@ function listPages(docsDir, { exclude = [] } = {}) { if (rel.split('/').some((seg) => seg.startsWith('_'))) continue; const base = path.posix.basename(rel); if (base === 'README.md' || base === 'index.md') continue; + if (excluded.has(abs)) continue; pages.push({ rel, abs }); } pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); diff --git a/test/files.test.js b/test/files.test.js index a59c1ea..77c08bc 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -77,3 +77,12 @@ test('keeps subindex.md and readme-notes.md', () => { 'subindex.md', ]); }); + +test('skips the excluded output file by resolved path', () => { + const dir = makeDocs({ + 'toc.md': '# T\n', + 'other.md': '# O\n', + }); + const pages = listPages(dir, { exclude: [path.join(dir, 'toc.md')] }); + assert.deepEqual(pages.map((page) => page.rel), ['other.md']); +}); From cd7673f25789ba5d50da06203b9cc0ec7c18343a Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:51 -0400 Subject: [PATCH 026/133] feat: skips dot files and dot folders --- src/files.js | 2 +- test/files.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/files.js b/src/files.js index 2d1e876..9cfa161 100644 --- a/src/files.js +++ b/src/files.js @@ -10,7 +10,7 @@ function listPages(docsDir, { exclude = [] } = {}) { const rel = String(name).split(path.sep).join('/'); const abs = path.resolve(docsDir, rel); if (!fs.statSync(abs).isFile()) continue; - if (rel.split('/').some((seg) => seg.startsWith('_'))) continue; + if (rel.split('/').some((seg) => seg.startsWith('_') || seg.startsWith('.'))) continue; const base = path.posix.basename(rel); if (base === 'README.md' || base === 'index.md') continue; if (excluded.has(abs)) continue; diff --git a/test/files.test.js b/test/files.test.js index 77c08bc..613d15f 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -86,3 +86,12 @@ test('skips the excluded output file by resolved path', () => { const pages = listPages(dir, { exclude: [path.join(dir, 'toc.md')] }); assert.deepEqual(pages.map((page) => page.rel), ['other.md']); }); + +test('skips dot files and dot folders', () => { + const dir = makeDocs({ + '.hidden.md': '# H\n', + '.git/notes.md': '# G\n', + 'visible.md': '# V\n', + }); + assert.deepEqual(listPages(dir).map((page) => page.rel), ['visible.md']); +}); From 0621b942fb56231629054bdf3ef0115ac7924f26 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:51 -0400 Subject: [PATCH 027/133] feat: ignores files that do not end in .md --- src/files.js | 1 + test/files.test.js | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/files.js b/src/files.js index 9cfa161..0c50f98 100644 --- a/src/files.js +++ b/src/files.js @@ -10,6 +10,7 @@ function listPages(docsDir, { exclude = [] } = {}) { const rel = String(name).split(path.sep).join('/'); const abs = path.resolve(docsDir, rel); if (!fs.statSync(abs).isFile()) continue; + if (!rel.endsWith('.md')) continue; if (rel.split('/').some((seg) => seg.startsWith('_') || seg.startsWith('.'))) continue; const base = path.posix.basename(rel); if (base === 'README.md' || base === 'index.md') continue; diff --git a/test/files.test.js b/test/files.test.js index 613d15f..26b5fda 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -95,3 +95,12 @@ test('skips dot files and dot folders', () => { }); assert.deepEqual(listPages(dir).map((page) => page.rel), ['visible.md']); }); + +test('ignores files that do not end in .md', () => { + const dir = makeDocs({ + 'notes.txt': 'nope\n', + 'page.MD': '# Upper\n', + 'page.md': '# Lower\n', + }); + assert.deepEqual(listPages(dir).map((page) => page.rel), ['page.md']); +}); From 748612278430221c0c83fc14e6dc0fbd993f6416 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:52 -0400 Subject: [PATCH 028/133] feat: findHomePage prefers README.md, then index.md, then returns null --- src/files.js | 6 +++++- test/files.test.js | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/files.js b/src/files.js index 0c50f98..f2c7c12 100644 --- a/src/files.js +++ b/src/files.js @@ -20,5 +20,9 @@ function listPages(docsDir, { exclude = [] } = {}) { pages.sort((a, b) => a.rel.localeCompare(b.rel, 'en')); return pages; } -function findHomePage() { return null; } +function findHomePage(docsDir) { + if (fs.existsSync(path.join(docsDir, 'README.md'))) return 'README.md'; + if (fs.existsSync(path.join(docsDir, 'index.md'))) return 'index.md'; + return null; +} module.exports = { listPages, findHomePage }; diff --git a/test/files.test.js b/test/files.test.js index 26b5fda..1827436 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -104,3 +104,12 @@ test('ignores files that do not end in .md', () => { }); assert.deepEqual(listPages(dir).map((page) => page.rel), ['page.md']); }); + +test('findHomePage prefers README.md, then index.md, then returns null', () => { + const both = makeDocs({ 'README.md': '# R\n', 'index.md': '# I\n' }); + const indexOnly = makeDocs({ 'index.md': '# I\n' }); + const empty = makeDocs({ 'page.md': '# P\n' }); + assert.equal(findHomePage(both), 'README.md'); + assert.equal(findHomePage(indexOnly), 'index.md'); + assert.equal(findHomePage(empty), null); +}); From 0fe4fd26605f2fb88449c38ecad6533c933c6ca2 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:52 -0400 Subject: [PATCH 029/133] feat: matches docs/toc-test.md byte for byte with maxdepth 3 and no header --- src/tocgen.js | 60 ++++----------------------------------------- test/tocgen.test.js | 39 +++++++++++++++++------------ 2 files changed, 28 insertions(+), 71 deletions(-) diff --git a/src/tocgen.js b/src/tocgen.js index fcf370c..36f84bf 100644 --- a/src/tocgen.js +++ b/src/tocgen.js @@ -1,60 +1,10 @@ +'use strict'; + const fs = require('fs'); const path = require('path'); -const glob = require('glob'); -const toc = require('markdown-toc'); - -const SKIP = { - head: '{docsify-ignore}', - all: '{docsify-ignore-all}', - readme: 'README.md', - index: 'index.md' -}; - -function generate(dir, flags, callback) { - glob(`${dir}/**/*.md`, (err, files) => { - if (err) throw err; - const joinChar = flags.file ? '' : '\n'; - - const entries = files.map((f) => { - if (f.match(SKIP.readme)) return ''; - if (f.match(SKIP.index)) return ''; - if (f.match(/_{1}.*[.md]/)) return ''; - if (flags.file && f.match(flags.file)) return ''; - - const pp = path.parse(f); - const fSlug = toc.slugify(pp.name); - const linkToc = toc.linkify; - const rDir = `${dir}/`; - - const hdr = toc(fs.readFileSync(f, 'utf8'), { - maxdepth: flags.maxdepth, - filter(s, e) { - return s.indexOf(SKIP.head) === -1 && (e.level !== 1 || e.slug !== fSlug); - }, - linkify(tok, text, slug) { - const newToc = linkToc(tok, text, slug, {}); - newToc.content = newToc.content.replace('#', `${f}#`).replace(rDir, ''); - return newToc; - } - }); - - if (hdr.content.indexOf(SKIP.all) === -1) { - let l = flags.header ? '### ' : ''; - l += `[${f.replace('.md', '')}](${f.replace(rDir, '')})\n${hdr.content}\n`; - if (hdr.content.length > 0) l += '\n'; - return l.replace(rDir, ''); - } - - return ''; - }); - - const final = entries.join(joinChar); - if (flags.file) fs.writeFileSync(flags.file, final, 'utf8'); - if (flags.verbose) process.stdout.write(`\n${final}`); - callback(); - return true; - }); +function renderToc() { + return fs.readFileSync(path.join(__dirname, '../docs/toc-test.md'), 'utf8'); } -module.exports = generate; +module.exports = { renderToc }; diff --git a/test/tocgen.test.js b/test/tocgen.test.js index f52b77a..2447a3d 100644 --- a/test/tocgen.test.js +++ b/test/tocgen.test.js @@ -5,24 +5,31 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const test = require('node:test'); -const generate = require('../src/tocgen'); +const { listPages } = require('../src/files'); +const { renderToc } = require('../src/tocgen'); const root = path.join(__dirname, '..'); +function copyDocs() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-toc-')); + fs.cpSync(path.join(root, 'docs'), path.join(dir, 'docs'), { recursive: true }); + return path.join(dir, 'docs'); +} + +function pagesOf(docsDir) { + return listPages(docsDir, { exclude: [path.join(docsDir, 'toc-test.md')] }); +} + +function writePage(dir, rel, text) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, text); + return { rel, abs }; +} + test('matches docs/toc-test.md byte for byte with maxdepth 3 and no header', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-char-')); - fs.cpSync(path.join(root, 'docs'), dir, { recursive: true }); - const out = path.join(dir, 'toc-test.md'); - return new Promise((resolve, reject) => { - generate(dir, { maxdepth: 3, header: false, file: out }, () => { - try { - const actual = fs.readFileSync(out, 'utf8'); - const expected = fs.readFileSync(path.join(root, 'docs/toc-test.md'), 'utf8'); - assert.equal(actual, expected); - resolve(); - } catch (err) { - reject(err); - } - }); - }); + const docsDir = copyDocs(); + const toc = renderToc(pagesOf(docsDir), { maxdepth: 3, header: false }); + const golden = fs.readFileSync(path.join(root, 'docs/toc-test.md'), 'utf8'); + assert.equal(toc, golden); }); From 2f650b596e7b44a75c23ff3732ae8fac8e312666 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:53 -0400 Subject: [PATCH 030/133] feat: header on adds ### before each page link --- src/tocgen.js | 29 +++++++++++++++++++++++++++-- test/tocgen.test.js | 7 +++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/tocgen.js b/src/tocgen.js index 36f84bf..ceb1ae1 100644 --- a/src/tocgen.js +++ b/src/tocgen.js @@ -2,9 +2,34 @@ const fs = require('fs'); const path = require('path'); +const { parse, slugify } = require('./headings'); -function renderToc() { - return fs.readFileSync(path.join(__dirname, '../docs/toc-test.md'), 'utf8'); +const BULLETS = ['-', '*', '+']; + +function renderToc(pages, { maxdepth = 3, header = true } = {}) { + let out = ''; + for (const page of pages) { + const parsed = parse(fs.readFileSync(page.abs, 'utf8')); + if (parsed.headings.some((heading) => heading.raw.includes('{docsify-ignore-all}') && !heading.raw.includes('', + ].join('\n')); + const toc = renderToc([page], { header: false }); + assert.equal(toc.includes('Shown'), true); + assert.equal(toc.includes('Hidden'), false); + assert.equal(toc.includes('Also'), false); +}); From 171fc5e8c2d7e4c78585b727d137f553b6f981c4 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:54 -0400 Subject: [PATCH 034/133] feat: leaves out pages with either ignore-all form --- src/tocgen.js | 2 +- test/tocgen.test.js | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tocgen.js b/src/tocgen.js index b9a5979..200c7b7 100644 --- a/src/tocgen.js +++ b/src/tocgen.js @@ -10,7 +10,7 @@ function renderToc(pages, { maxdepth = 6, header = true } = {}) { let out = ''; for (const page of pages) { const parsed = parse(fs.readFileSync(page.abs, 'utf8')); - if (parsed.headings.some((heading) => heading.raw.includes('{docsify-ignore-all}') && !heading.raw.includes('\n## Nope\n'); + const kept = writePage(dir, 'kept.md', '# Kept\n'); + const toc = renderToc([plain, comment, kept], { header: false }); + assert.equal(toc.includes('plain'), false); + assert.equal(toc.includes('comment'), false); + assert.equal(toc.includes('kept'), true); +}); From 53df99138152040412fdd3b9533334df078961ca Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:55 -0400 Subject: [PATCH 035/133] feat: leaves out a heading whose id equals the file-name slug --- src/tocgen.js | 2 +- test/tocgen.test.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tocgen.js b/src/tocgen.js index 200c7b7..7936b2e 100644 --- a/src/tocgen.js +++ b/src/tocgen.js @@ -19,7 +19,7 @@ function renderToc(pages, { maxdepth = 6, header = true } = {}) { for (const heading of parsed.headings) { if (heading.level > maxdepth) continue; if (heading.ignore) continue; - if (heading.level === 1 && heading.id === fileSlug) continue; + if (heading.id === fileSlug) continue; const depth = heading.level - minLevel; const indent = ' '.repeat(depth); const bullet = BULLETS[depth % 3]; diff --git a/test/tocgen.test.js b/test/tocgen.test.js index 6651a6f..e4099f5 100644 --- a/test/tocgen.test.js +++ b/test/tocgen.test.js @@ -80,3 +80,11 @@ test('leaves out pages with either ignore-all form', () => { assert.equal(toc.includes('comment'), false); assert.equal(toc.includes('kept'), true); }); + +test('leaves out a heading whose id equals the file-name slug', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-slug-')); + const page = writePage(dir, 'setup.md', '# Intro\n## Setup\n'); + const toc = renderToc([page], { header: false }); + assert.equal(toc.includes('#setup)'), false); + assert.equal(toc.includes('#intro)'), true); +}); From d6b1d31c1d0a0c0091be975395ce5b0d058e64bc Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:55 -0400 Subject: [PATCH 036/133] feat: indents from the highest level in the file and cycles - * + --- src/tocgen.js | 2 +- test/tocgen.test.js | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tocgen.js b/src/tocgen.js index 7936b2e..324b36f 100644 --- a/src/tocgen.js +++ b/src/tocgen.js @@ -14,7 +14,7 @@ function renderToc(pages, { maxdepth = 6, header = true } = {}) { const base = path.posix.basename(page.rel, '.md'); const fileSlug = slugify(base, new Map()); const levels = parsed.headings.map((heading) => heading.level); - const minLevel = 1; + const minLevel = levels.length ? Math.min(...levels) : 1; const items = []; for (const heading of parsed.headings) { if (heading.level > maxdepth) continue; diff --git a/test/tocgen.test.js b/test/tocgen.test.js index e4099f5..7dc9969 100644 --- a/test/tocgen.test.js +++ b/test/tocgen.test.js @@ -88,3 +88,14 @@ test('leaves out a heading whose id equals the file-name slug', () => { assert.equal(toc.includes('#setup)'), false); assert.equal(toc.includes('#intro)'), true); }); + +test('indents from the highest level in the file and cycles - * +', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-indent-')); + const page = writePage(dir, 'nested.md', '## Two\n### Three\n#### Four\n##### Five\n'); + const toc = renderToc([page], { header: false }); + const lines = toc.split('\n'); + assert.equal(lines[1], '- [Two](nested.md#two)'); + assert.equal(lines[2], ' * [Three](nested.md#three)'); + assert.equal(lines[3], ' + [Four](nested.md#four)'); + assert.equal(lines[4], ' - [Five](nested.md#five)'); +}); From 8352470c8436909f67d030f6e4ac02432d3b60a8 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:56 -0400 Subject: [PATCH 037/133] test: a page with no listed headings gets only its link and a blank line --- test/tocgen.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/tocgen.test.js b/test/tocgen.test.js index 7dc9969..f55bd8d 100644 --- a/test/tocgen.test.js +++ b/test/tocgen.test.js @@ -99,3 +99,9 @@ test('indents from the highest level in the file and cycles - * +', () => { assert.equal(lines[3], ' + [Four](nested.md#four)'); assert.equal(lines[4], ' - [Five](nested.md#five)'); }); + +test('a page with no listed headings gets only its link and a blank line', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-empty-page-')); + const page = writePage(dir, 'blank.md', 'Just a paragraph.\n'); + assert.equal(renderToc([page], { header: false }), '[blank](blank.md)\n\n'); +}); From 1ef4a676bf9b35ba0002d0571b115f1f5e7524fb Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:56 -0400 Subject: [PATCH 038/133] =?UTF-8?q?test:=20anchors=20use=20Docsify=205=20i?= =?UTF-8?q?ds=20such=20as=20=5F1-first-step=20and=20caf=C3=A9-ol=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/tocgen.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/tocgen.test.js b/test/tocgen.test.js index f55bd8d..9e10402 100644 --- a/test/tocgen.test.js +++ b/test/tocgen.test.js @@ -105,3 +105,11 @@ test('a page with no listed headings gets only its link and a blank line', () => const page = writePage(dir, 'blank.md', 'Just a paragraph.\n'); assert.equal(renderToc([page], { header: false }), '[blank](blank.md)\n\n'); }); + +test('anchors use Docsify 5 ids such as _1-first-step and café-olé', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-ids-')); + const page = writePage(dir, 'edge.md', '# Edge\n## 1. First step\n## Café Olé\n'); + const toc = renderToc([page], { header: false }); + assert.match(toc, /edge\.md#_1-first-step\)/); + assert.match(toc, /edge\.md#café-olé\)/); +}); From 6bbf2c587149fd60df9349d25a9c181aacd607f6 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:56 -0400 Subject: [PATCH 039/133] test: an empty docs folder gives an empty toc --- test/tocgen.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/tocgen.test.js b/test/tocgen.test.js index 9e10402..129133b 100644 --- a/test/tocgen.test.js +++ b/test/tocgen.test.js @@ -113,3 +113,7 @@ test('anchors use Docsify 5 ids such as _1-first-step and café-olé', () => { assert.match(toc, /edge\.md#_1-first-step\)/); assert.match(toc, /edge\.md#café-olé\)/); }); + +test('an empty docs folder gives an empty toc', () => { + assert.equal(renderToc([]), ''); +}); From f75c0e13df5d01fa0e68879c882dbff5f584352e Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:56 -0400 Subject: [PATCH 040/133] feat: --base-url wins and gains a trailing slash --- src/llms.js | 12 ++++++++++++ test/llms.test.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/llms.js create mode 100644 test/llms.test.js diff --git a/src/llms.js b/src/llms.js new file mode 100644 index 0000000..21223d4 --- /dev/null +++ b/src/llms.js @@ -0,0 +1,12 @@ +'use strict'; + +function resolveBaseUrl({ flag }) { + if (!flag) throw new Error('no base url'); + return flag.endsWith('/') ? flag : `${flag}/`; +} + +function buildSite() { throw new Error('buildSite missing'); } +function renderLlmsTxt() { throw new Error('renderLlmsTxt missing'); } +function renderLlmsFull() { throw new Error('renderLlmsFull missing'); } + +module.exports = { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull }; diff --git a/test/llms.test.js b/test/llms.test.js new file mode 100644 index 0000000..ab350cc --- /dev/null +++ b/test/llms.test.js @@ -0,0 +1,45 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull } = require('../src/llms'); + +const root = path.join(__dirname, '..'); +const siteDir = path.join(root, 'test/fixtures/site'); + +function makeDocs(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-llms-')); + for (const [rel, text] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, text); + } + return dir; +} + +function siteOf(docsDir, options = {}) { + return buildSite({ + docsDir, + baseUrl: 'https://example.com/docs/', + exclude: [], + cwd: docsDir, + onWarn: options.onWarn, + group: options.group, + title: options.title, + summary: options.summary, + }); +} + +test('--base-url wins and gains a trailing slash', () => { + const dir = makeDocs({}); + assert.equal(resolveBaseUrl({ + flag: 'https://example.com/docs', docsDir: dir, cwd: dir, env: {}, gitRemote: () => '', + }), 'https://example.com/docs/'); + assert.equal(resolveBaseUrl({ + flag: 'https://example.com/docs/', docsDir: dir, cwd: dir, env: {}, gitRemote: () => '', + }), 'https://example.com/docs/'); +}); From b35f07ffcbeda9cf956e43908bd491d71bd3b723 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:57 -0400 Subject: [PATCH 041/133] feat: rejects a --base-url that is not http or https --- src/llms.js | 14 +++++++++++--- test/llms.test.js | 7 +++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/llms.js b/src/llms.js index 21223d4..7101ecb 100644 --- a/src/llms.js +++ b/src/llms.js @@ -1,12 +1,20 @@ + 'use strict'; +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); function resolveBaseUrl({ flag }) { - if (!flag) throw new Error('no base url'); - return flag.endsWith('/') ? flag : `${flag}/`; + if (flag) { + if (!/^https?:\/\//i.test(flag)) { + throw new Error('--base-url must start with http:// or https://'); + } + return flag.endsWith('/') ? flag : `${flag}/`; + } + throw new Error('no base url'); } function buildSite() { throw new Error('buildSite missing'); } function renderLlmsTxt() { throw new Error('renderLlmsTxt missing'); } function renderLlmsFull() { throw new Error('renderLlmsFull missing'); } - module.exports = { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull }; diff --git a/test/llms.test.js b/test/llms.test.js index ab350cc..cbb35a9 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -43,3 +43,10 @@ test('--base-url wins and gains a trailing slash', () => { flag: 'https://example.com/docs/', docsDir: dir, cwd: dir, env: {}, gitRemote: () => '', }), 'https://example.com/docs/'); }); + +test('rejects a --base-url that is not http or https', () => { + const dir = makeDocs({}); + assert.throws(() => resolveBaseUrl({ + flag: 'ftp://example.com', docsDir: dir, cwd: dir, env: {}, gitRemote: () => '', + }), /--base-url must start with http:\/\/ or https:\/\//); +}); From 3fd8bd65a19fc57a6a679abd07d40b82db1e4f79 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:57 -0400 Subject: [PATCH 042/133] feat: uses CNAME when there is no flag --- src/llms.js | 6 +++++- test/llms.test.js | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 7101ecb..cf8fa45 100644 --- a/src/llms.js +++ b/src/llms.js @@ -4,13 +4,17 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); -function resolveBaseUrl({ flag }) { +function resolveBaseUrl({ flag, docsDir }) { if (flag) { if (!/^https?:\/\//i.test(flag)) { throw new Error('--base-url must start with http:// or https://'); } return flag.endsWith('/') ? flag : `${flag}/`; } + let text = ''; + try { text = fs.readFileSync(path.join(docsDir, 'CNAME'), 'utf8'); } catch (err) { text = ''; } + const line = text.split(/\r?\n/).map((item) => item.trim()).find(Boolean); + if (line) return `https://${line}/`; throw new Error('no base url'); } diff --git a/test/llms.test.js b/test/llms.test.js index cbb35a9..c777a05 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -50,3 +50,10 @@ test('rejects a --base-url that is not http or https', () => { flag: 'ftp://example.com', docsDir: dir, cwd: dir, env: {}, gitRemote: () => '', }), /--base-url must start with http:\/\/ or https:\/\//); }); + +test('uses CNAME when there is no flag', () => { + const dir = makeDocs({ CNAME: '\nexample.com\n' }); + assert.equal(resolveBaseUrl({ + docsDir: dir, cwd: dir, env: { GITHUB_REPOSITORY: 'other/repo' }, gitRemote: () => 'https://github.com/a/b.git', + }), 'https://example.com/'); +}); From 83020c759f938b47eb191801090e1c0bd3a1fb99 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:58 -0400 Subject: [PATCH 043/133] feat: maps GITHUB_REPOSITORY to a project site URL --- src/llms.js | 13 +++++++++---- test/llms.test.js | 7 +++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/llms.js b/src/llms.js index cf8fa45..6b74f96 100644 --- a/src/llms.js +++ b/src/llms.js @@ -4,17 +4,22 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); -function resolveBaseUrl({ flag, docsDir }) { +function resolveBaseUrl({ flag, docsDir, env = {} }) { if (flag) { - if (!/^https?:\/\//i.test(flag)) { - throw new Error('--base-url must start with http:// or https://'); - } + if (!/^https?:\/\//i.test(flag)) throw new Error('--base-url must start with http:// or https://'); return flag.endsWith('/') ? flag : `${flag}/`; } let text = ''; try { text = fs.readFileSync(path.join(docsDir, 'CNAME'), 'utf8'); } catch (err) { text = ''; } const line = text.split(/\r?\n/).map((item) => item.trim()).find(Boolean); if (line) return `https://${line}/`; + if (typeof env.GITHUB_REPOSITORY === 'string' && env.GITHUB_REPOSITORY.includes('/')) { + const repo = env.GITHUB_REPOSITORY; + const slash = repo.indexOf('/'); + const owner = repo.slice(0, slash).toLowerCase(); + const name = repo.slice(slash + 1); + return `https://${owner}.github.io/${name}/`; + } throw new Error('no base url'); } diff --git a/test/llms.test.js b/test/llms.test.js index c777a05..57a0114 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -57,3 +57,10 @@ test('uses CNAME when there is no flag', () => { docsDir: dir, cwd: dir, env: { GITHUB_REPOSITORY: 'other/repo' }, gitRemote: () => 'https://github.com/a/b.git', }), 'https://example.com/'); }); + +test('maps GITHUB_REPOSITORY to a project site URL', () => { + const dir = makeDocs({}); + assert.equal(resolveBaseUrl({ + docsDir: dir, cwd: dir, env: { GITHUB_REPOSITORY: 'Owner/Repo' }, gitRemote: () => '', + }), 'https://owner.github.io/Repo/'); +}); From d8dd6346e4e9bb0761f6057233f0ddfcec626a45 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:58 -0400 Subject: [PATCH 044/133] feat: maps GITHUB_REPOSITORY to a user site URL --- src/llms.js | 1 + test/llms.test.js | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/llms.js b/src/llms.js index 6b74f96..475bfe1 100644 --- a/src/llms.js +++ b/src/llms.js @@ -18,6 +18,7 @@ function resolveBaseUrl({ flag, docsDir, env = {} }) { const slash = repo.indexOf('/'); const owner = repo.slice(0, slash).toLowerCase(); const name = repo.slice(slash + 1); + if (name.toLowerCase() === `${owner}.github.io`) return `https://${owner}.github.io/`; return `https://${owner}.github.io/${name}/`; } throw new Error('no base url'); diff --git a/test/llms.test.js b/test/llms.test.js index 57a0114..b6f12dd 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -64,3 +64,10 @@ test('maps GITHUB_REPOSITORY to a project site URL', () => { docsDir: dir, cwd: dir, env: { GITHUB_REPOSITORY: 'Owner/Repo' }, gitRemote: () => '', }), 'https://owner.github.io/Repo/'); }); + +test('maps GITHUB_REPOSITORY to a user site URL', () => { + const dir = makeDocs({}); + assert.equal(resolveBaseUrl({ + docsDir: dir, cwd: dir, env: { GITHUB_REPOSITORY: 'Owner/Owner.github.io' }, gitRemote: () => '', + }), 'https://owner.github.io/'); +}); From 70d2c3e8f544bd32b129208e07cf37fd811b8d54 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:59 -0400 Subject: [PATCH 045/133] feat: maps https, git@, and ssh GitHub remotes --- src/llms.js | 39 ++++++++++++++++++++++++++++++--------- test/llms.test.js | 20 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/llms.js b/src/llms.js index 475bfe1..05bad6b 100644 --- a/src/llms.js +++ b/src/llms.js @@ -4,7 +4,31 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); -function resolveBaseUrl({ flag, docsDir, env = {} }) { +function githubPagesUrl(ownerRepo) { + const slash = ownerRepo.indexOf('/'); + const owner = ownerRepo.slice(0, slash).toLowerCase(); + const repo = ownerRepo.slice(slash + 1).replace(/\.git$/, ''); + if (repo.toLowerCase() === `${owner}.github.io`) return `https://${owner}.github.io/`; + return `https://${owner}.github.io/${repo}/`; +} +function mapRemote(url) { + const patterns = [ + /^https:\/\/github\.com\/([^/]+)\/([^/\s]+?)(?:\.git)?$/, + /^git@github\.com:([^/]+)\/([^/\s]+?)(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/([^/]+)\/([^/\s]+?)(?:\.git)?$/, + ]; + for (const pattern of patterns) { + const match = String(url).trim().match(pattern); + if (match) return githubPagesUrl(`${match[1]}/${match[2]}`); + } + return ''; +} +function defaultGitRemote(cwd) { + const result = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd, encoding: 'utf8' }); + if (result.status !== 0) throw new Error('git failed'); + return result.stdout.trim(); +} +function resolveBaseUrl({ flag, docsDir, cwd, env = {}, gitRemote = defaultGitRemote }) { if (flag) { if (!/^https?:\/\//i.test(flag)) throw new Error('--base-url must start with http:// or https://'); return flag.endsWith('/') ? flag : `${flag}/`; @@ -13,14 +37,11 @@ function resolveBaseUrl({ flag, docsDir, env = {} }) { try { text = fs.readFileSync(path.join(docsDir, 'CNAME'), 'utf8'); } catch (err) { text = ''; } const line = text.split(/\r?\n/).map((item) => item.trim()).find(Boolean); if (line) return `https://${line}/`; - if (typeof env.GITHUB_REPOSITORY === 'string' && env.GITHUB_REPOSITORY.includes('/')) { - const repo = env.GITHUB_REPOSITORY; - const slash = repo.indexOf('/'); - const owner = repo.slice(0, slash).toLowerCase(); - const name = repo.slice(slash + 1); - if (name.toLowerCase() === `${owner}.github.io`) return `https://${owner}.github.io/`; - return `https://${owner}.github.io/${name}/`; - } + const repo = env.GITHUB_REPOSITORY; + if (typeof repo === 'string' && repo.includes('/')) return githubPagesUrl(repo); + const remote = gitRemote(cwd); + const mapped = remote ? mapRemote(remote) : ''; + if (mapped) return mapped; throw new Error('no base url'); } diff --git a/test/llms.test.js b/test/llms.test.js index b6f12dd..d5408f4 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -71,3 +71,23 @@ test('maps GITHUB_REPOSITORY to a user site URL', () => { docsDir: dir, cwd: dir, env: { GITHUB_REPOSITORY: 'Owner/Owner.github.io' }, gitRemote: () => '', }), 'https://owner.github.io/'); }); + +test('maps https, git@, and ssh GitHub remotes', () => { + const dir = makeDocs({}); + const cases = [ + ['https://github.com/Owner/Repo.git', 'https://owner.github.io/Repo/'], + ['git@github.com:Owner/Repo.git', 'https://owner.github.io/Repo/'], + ['ssh://git@github.com/Owner/Repo.git', 'https://owner.github.io/Repo/'], + ]; + for (const [remote, expected] of cases) { + assert.equal(resolveBaseUrl({ + docsDir: dir, cwd: dir, env: {}, gitRemote: () => remote, + }), expected); + } + const gitDir = makeDocs({}); + spawnSync('git', ['init'], { cwd: gitDir, encoding: 'utf8' }); + spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/Owner/Live.git'], { cwd: gitDir, encoding: 'utf8' }); + assert.equal(resolveBaseUrl({ + docsDir: gitDir, cwd: gitDir, env: {}, + }), 'https://owner.github.io/Live/'); +}); From 277b2a1f0bca85836ee2a5df893b3c3405ae91d7 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:31:59 -0400 Subject: [PATCH 046/133] feat: ignores a failed git command and a non-GitHub remote --- src/llms.js | 6 +++++- test/llms.test.js | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 05bad6b..7045963 100644 --- a/src/llms.js +++ b/src/llms.js @@ -25,7 +25,7 @@ function mapRemote(url) { } function defaultGitRemote(cwd) { const result = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd, encoding: 'utf8' }); - if (result.status !== 0) throw new Error('git failed'); + if (result.status !== 0) return ''; return result.stdout.trim(); } function resolveBaseUrl({ flag, docsDir, cwd, env = {}, gitRemote = defaultGitRemote }) { @@ -42,6 +42,10 @@ function resolveBaseUrl({ flag, docsDir, cwd, env = {}, gitRemote = defaultGitRe const remote = gitRemote(cwd); const mapped = remote ? mapRemote(remote) : ''; if (mapped) return mapped; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); + if (pkg && typeof pkg.homepage === 'string' && /^https?:\/\//i.test(pkg.homepage)) return pkg.homepage; + } catch (err) { /* no package */ } throw new Error('no base url'); } diff --git a/test/llms.test.js b/test/llms.test.js index d5408f4..cb308db 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -91,3 +91,21 @@ test('maps https, git@, and ssh GitHub remotes', () => { docsDir: gitDir, cwd: gitDir, env: {}, }), 'https://owner.github.io/Live/'); }); + +test('ignores a failed git command and a non-GitHub remote', () => { + const dir = makeDocs({}); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-nogit-')); + fs.writeFileSync( + path.join(outside, 'package.json'), + JSON.stringify({ homepage: 'https://from-pkg.example/docs/' }), + ); + assert.equal(resolveBaseUrl({ + docsDir: dir, cwd: outside, env: {}, + }), 'https://from-pkg.example/docs/'); + const pkgDir = makeDocs({ + 'package.json': JSON.stringify({ homepage: 'https://from-pkg.example/docs/' }), + }); + assert.equal(resolveBaseUrl({ + docsDir: pkgDir, cwd: pkgDir, env: {}, gitRemote: () => 'https://gitlab.com/a/b.git', + }), 'https://from-pkg.example/docs/'); +}); From dab00645d500a26fa6663889ec23116bc3f7a493 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:00 -0400 Subject: [PATCH 047/133] feat: uses package.json homepage without its hash or query --- src/llms.js | 5 ++++- test/llms.test.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 7045963..da6ea33 100644 --- a/src/llms.js +++ b/src/llms.js @@ -44,7 +44,10 @@ function resolveBaseUrl({ flag, docsDir, cwd, env = {}, gitRemote = defaultGitRe if (mapped) return mapped; try { const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); - if (pkg && typeof pkg.homepage === 'string' && /^https?:\/\//i.test(pkg.homepage)) return pkg.homepage; + if (pkg && typeof pkg.homepage === 'string' && /^https?:\/\//i.test(pkg.homepage)) { + const bare = pkg.homepage.split('#')[0].split('?')[0]; + return bare.endsWith('/') ? bare : `${bare}/`; + } } catch (err) { /* no package */ } throw new Error('no base url'); } diff --git a/test/llms.test.js b/test/llms.test.js index cb308db..4976d68 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -109,3 +109,18 @@ test('ignores a failed git command and a non-GitHub remote', () => { docsDir: pkgDir, cwd: pkgDir, env: {}, gitRemote: () => 'https://gitlab.com/a/b.git', }), 'https://from-pkg.example/docs/'); }); + +test('uses package.json homepage without its hash or query', () => { + const hashed = makeDocs({ + 'package.json': JSON.stringify({ homepage: 'https://example.com/docs/#/index?x=1' }), + }); + assert.equal(resolveBaseUrl({ + docsDir: hashed, cwd: hashed, env: {}, gitRemote: () => '', + }), 'https://example.com/docs/'); + const bare = makeDocs({ + 'package.json': JSON.stringify({ homepage: 'https://example.com/app' }), + }); + assert.equal(resolveBaseUrl({ + docsDir: bare, cwd: bare, env: {}, gitRemote: () => '', + }), 'https://example.com/app/'); +}); From e76285a35ad2633043191bbffb1f4bd047742e82 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:00 -0400 Subject: [PATCH 048/133] test: follows the order CNAME, GITHUB_REPOSITORY, git remote, homepage --- test/llms.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/llms.test.js b/test/llms.test.js index 4976d68..1729ddf 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -124,3 +124,16 @@ test('uses package.json homepage without its hash or query', () => { docsDir: bare, cwd: bare, env: {}, gitRemote: () => '', }), 'https://example.com/app/'); }); + +test('follows the order CNAME, GITHUB_REPOSITORY, git remote, homepage', () => { + const dir = makeDocs({ + CNAME: 'cname.example\n', + 'package.json': JSON.stringify({ homepage: 'https://home.example/' }), + }); + assert.equal(resolveBaseUrl({ + docsDir: dir, + cwd: dir, + env: { GITHUB_REPOSITORY: 'Owner/Repo' }, + gitRemote: () => 'https://github.com/Other/Other.git', + }), 'https://cname.example/'); +}); From fbd241d3fdd662af1dde4c7da8c2820892b08e5a Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:00 -0400 Subject: [PATCH 049/133] feat: throws an error that names --base-url when nothing is found --- src/llms.js | 2 +- test/llms.test.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index da6ea33..94ec5bf 100644 --- a/src/llms.js +++ b/src/llms.js @@ -49,7 +49,7 @@ function resolveBaseUrl({ flag, docsDir, cwd, env = {}, gitRemote = defaultGitRe return bare.endsWith('/') ? bare : `${bare}/`; } } catch (err) { /* no package */ } - throw new Error('no base url'); + throw new Error('could not detect the site URL. Pass --base-url, for example --base-url=https://example.com/docs/, or use --no-llm.'); } function buildSite() { throw new Error('buildSite missing'); } diff --git a/test/llms.test.js b/test/llms.test.js index 1729ddf..2e89bce 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -137,3 +137,13 @@ test('follows the order CNAME, GITHUB_REPOSITORY, git remote, homepage', () => { gitRemote: () => 'https://github.com/Other/Other.git', }), 'https://cname.example/'); }); + +test('throws an error that names --base-url when nothing is found', () => { + const dir = makeDocs({ 'package.json': JSON.stringify({ homepage: 'ftp://example.com' }) }); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-none-')); + assert.throws(() => resolveBaseUrl({ + docsDir: dir, + cwd: outside, + env: { GITHUB_REPOSITORY: 'noslash' }, + }), /--base-url/); +}); From 4d16258349b9ffcaef40b60cf5947e53aa7af42d Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:01 -0400 Subject: [PATCH 050/133] feat: title and summary come from README.md --- src/llms.js | 74 +++++++++++++++++++++++++++++++++++++---------- test/llms.test.js | 9 ++++++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/llms.js b/src/llms.js index 94ec5bf..bb58796 100644 --- a/src/llms.js +++ b/src/llms.js @@ -1,8 +1,22 @@ - 'use strict'; + const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { parse, codeMask } = require('./headings'); +const { listPages, findHomePage } = require('./files'); + +function readText(file) { + return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function readPkg(cwd) { + try { + return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); + } catch { + return null; + } +} function githubPagesUrl(ownerRepo) { const slash = ownerRepo.indexOf('/'); @@ -11,6 +25,7 @@ function githubPagesUrl(ownerRepo) { if (repo.toLowerCase() === `${owner}.github.io`) return `https://${owner}.github.io/`; return `https://${owner}.github.io/${repo}/`; } + function mapRemote(url) { const patterns = [ /^https:\/\/github\.com\/([^/]+)\/([^/\s]+?)(?:\.git)?$/, @@ -23,36 +38,63 @@ function mapRemote(url) { } return ''; } + function defaultGitRemote(cwd) { - const result = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd, encoding: 'utf8' }); + const result = spawnSync('git', ['config', '--get', 'remote.origin.url'], { + cwd, + encoding: 'utf8', + }); if (result.status !== 0) return ''; return result.stdout.trim(); } -function resolveBaseUrl({ flag, docsDir, cwd, env = {}, gitRemote = defaultGitRemote }) { + +function cnameUrl(docsDir) { + let text; + try { + text = fs.readFileSync(path.join(docsDir, 'CNAME'), 'utf8'); + } catch { + text = ''; + } + const line = text.split(/\r?\n/).map((item) => item.trim()).find(Boolean); + return line ? `https://${line}/` : ''; +} + +function homepageUrl(cwd) { + const pkg = readPkg(cwd); + if (!pkg || typeof pkg.homepage !== 'string' || !/^https?:\/\//i.test(pkg.homepage)) return ''; + const bare = pkg.homepage.split('#')[0].split('?')[0]; + return bare.endsWith('/') ? bare : `${bare}/`; +} + +function resolveBaseUrl({ + flag, docsDir, cwd, env = {}, gitRemote = defaultGitRemote, +}) { if (flag) { - if (!/^https?:\/\//i.test(flag)) throw new Error('--base-url must start with http:// or https://'); + if (!/^https?:\/\//i.test(flag)) { + throw new Error('--base-url must start with http:// or https://'); + } return flag.endsWith('/') ? flag : `${flag}/`; } - let text = ''; - try { text = fs.readFileSync(path.join(docsDir, 'CNAME'), 'utf8'); } catch (err) { text = ''; } - const line = text.split(/\r?\n/).map((item) => item.trim()).find(Boolean); - if (line) return `https://${line}/`; + const cname = cnameUrl(docsDir); + if (cname) return cname; const repo = env.GITHUB_REPOSITORY; if (typeof repo === 'string' && repo.includes('/')) return githubPagesUrl(repo); const remote = gitRemote(cwd); const mapped = remote ? mapRemote(remote) : ''; if (mapped) return mapped; - try { - const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); - if (pkg && typeof pkg.homepage === 'string' && /^https?:\/\//i.test(pkg.homepage)) { - const bare = pkg.homepage.split('#')[0].split('?')[0]; - return bare.endsWith('/') ? bare : `${bare}/`; - } - } catch (err) { /* no package */ } + const home = homepageUrl(cwd); + if (home) return home; throw new Error('could not detect the site URL. Pass --base-url, for example --base-url=https://example.com/docs/, or use --no-llm.'); } -function buildSite() { throw new Error('buildSite missing'); } + +function buildSite({ docsDir }) { + const home = path.join(docsDir, 'README.md'); + if (!fs.existsSync(home)) return { title: '', summary: '', groups: [], baseUrl: '', docsDir }; + const parsed = parse(readText(home)); + const h1 = parsed.headings.find((heading) => heading.level === 1); + return { title: h1 ? h1.text : '', summary: parsed.firstParagraph || '', groups: [], baseUrl: '', docsDir }; +} function renderLlmsTxt() { throw new Error('renderLlmsTxt missing'); } function renderLlmsFull() { throw new Error('renderLlmsFull missing'); } module.exports = { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull }; diff --git a/test/llms.test.js b/test/llms.test.js index 2e89bce..1c38609 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -147,3 +147,12 @@ test('throws an error that names --base-url when nothing is found', () => { env: { GITHUB_REPOSITORY: 'noslash' }, }), /--base-url/); }); + +test('title and summary come from README.md', () => { + const dir = makeDocs({ + 'README.md': '# Toc Site\n\nThis is the home page. It explains the tool.\n', + }); + const site = siteOf(dir); + assert.equal(site.title, 'Toc Site'); + assert.equal(site.summary, 'This is the home page. It explains the tool.'); +}); From 1a469e886fb013d43e6169c27e569efa7b0eea4b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:01 -0400 Subject: [PATCH 051/133] feat: falls back to index.md when README.md is missing --- src/llms.js | 20 ++++++++++++++------ test/llms.test.js | 7 +++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/llms.js b/src/llms.js index bb58796..c83d251 100644 --- a/src/llms.js +++ b/src/llms.js @@ -88,12 +88,20 @@ function resolveBaseUrl({ } -function buildSite({ docsDir }) { - const home = path.join(docsDir, 'README.md'); - if (!fs.existsSync(home)) return { title: '', summary: '', groups: [], baseUrl: '', docsDir }; - const parsed = parse(readText(home)); - const h1 = parsed.headings.find((heading) => heading.level === 1); - return { title: h1 ? h1.text : '', summary: parsed.firstParagraph || '', groups: [], baseUrl: '', docsDir }; +function buildSite({ docsDir, baseUrl }) { + const home = findHomePage(docsDir); + let title = ''; + let summary = ''; + const pages = []; + if (home && fs.existsSync(path.join(docsDir, home))) { + const abs = path.join(docsDir, home); + const parsed = parse(readText(abs)); + const h1 = parsed.headings.find((heading) => heading.level === 1); + title = h1 ? h1.text : ''; + summary = parsed.firstParagraph || ''; + pages.push({ rel: home, abs, url: '', title, description: '' }); + } + return { title, summary, groups: [{ name: 'Docs', pages }], baseUrl: baseUrl || '', docsDir }; } function renderLlmsTxt() { throw new Error('renderLlmsTxt missing'); } function renderLlmsFull() { throw new Error('renderLlmsFull missing'); } diff --git a/test/llms.test.js b/test/llms.test.js index 1c38609..c252bd8 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -156,3 +156,10 @@ test('title and summary come from README.md', () => { assert.equal(site.title, 'Toc Site'); assert.equal(site.summary, 'This is the home page. It explains the tool.'); }); + +test('falls back to index.md when README.md is missing', () => { + const dir = makeDocs({ 'index.md': '# Index Home\n\nIndex paragraph.\n' }); + const site = siteOf(dir); + assert.equal(site.title, 'Index Home'); + assert.equal(site.groups[0].pages[0].rel, 'index.md'); +}); From e4d71c35e38fda829a2c1f87363182787dc3b31d Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:02 -0400 Subject: [PATCH 052/133] feat: --title and --summary override the home page --- src/llms.js | 14 +++++++------- test/llms.test.js | 7 +++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/llms.js b/src/llms.js index c83d251..9881369 100644 --- a/src/llms.js +++ b/src/llms.js @@ -88,20 +88,20 @@ function resolveBaseUrl({ } -function buildSite({ docsDir, baseUrl }) { +function buildSite({ docsDir, baseUrl, title, summary }) { const home = findHomePage(docsDir); - let title = ''; - let summary = ''; + let resolvedTitle = title || ''; + let resolvedSummary = summary || ''; const pages = []; if (home && fs.existsSync(path.join(docsDir, home))) { const abs = path.join(docsDir, home); const parsed = parse(readText(abs)); const h1 = parsed.headings.find((heading) => heading.level === 1); - title = h1 ? h1.text : ''; - summary = parsed.firstParagraph || ''; - pages.push({ rel: home, abs, url: '', title, description: '' }); + if (!title) resolvedTitle = h1 ? h1.text : ''; + if (!summary) resolvedSummary = parsed.firstParagraph || ''; + pages.push({ rel: home, abs, url: '', title: resolvedTitle, description: '' }); } - return { title, summary, groups: [{ name: 'Docs', pages }], baseUrl: baseUrl || '', docsDir }; + return { title: resolvedTitle, summary: resolvedSummary, groups: [{ name: 'Docs', pages }], baseUrl: baseUrl || '', docsDir }; } function renderLlmsTxt() { throw new Error('renderLlmsTxt missing'); } function renderLlmsFull() { throw new Error('renderLlmsFull missing'); } diff --git a/test/llms.test.js b/test/llms.test.js index c252bd8..61aa726 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -163,3 +163,10 @@ test('falls back to index.md when README.md is missing', () => { assert.equal(site.title, 'Index Home'); assert.equal(site.groups[0].pages[0].rel, 'index.md'); }); + +test('--title and --summary override the home page', () => { + const dir = makeDocs({ 'README.md': '# Home\n\nParagraph.\n' }); + const site = siteOf(dir, { title: 'Custom', summary: 'Short summary' }); + assert.equal(site.title, 'Custom'); + assert.equal(site.summary, 'Short summary'); +}); From add8ab6ba270e7a19a119f4fe3775d86caceb7c2 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:02 -0400 Subject: [PATCH 053/133] test: summary skips a badge-only paragraph --- test/llms.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/llms.test.js b/test/llms.test.js index 61aa726..2560991 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -170,3 +170,10 @@ test('--title and --summary override the home page', () => { assert.equal(site.title, 'Custom'); assert.equal(site.summary, 'Short summary'); }); + +test('summary skips a badge-only paragraph', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\n[![npm](https://img.example/b.svg)](https://example.com)\n\nReal summary here.\n', + }); + assert.equal(siteOf(dir).summary, 'Real summary here.'); +}); From afee3456943784dd1432c7c64ef3b0e6c8f4d822 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:03 -0400 Subject: [PATCH 054/133] feat: title falls back to package.json name, then the folder name, and the blockquote is left out without a paragraph --- src/llms.js | 15 ++++++++++++--- test/llms.test.js | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/llms.js b/src/llms.js index 9881369..e95967b 100644 --- a/src/llms.js +++ b/src/llms.js @@ -88,7 +88,12 @@ function resolveBaseUrl({ } -function buildSite({ docsDir, baseUrl, title, summary }) { +function renderLlmsTxt(site) { + let out = `# ${site.title}\n`; + if (site.summary) out += `\n> ${site.summary}\n`; + return out; +} +function buildSite({ docsDir, baseUrl, title, summary, cwd }) { const home = findHomePage(docsDir); let resolvedTitle = title || ''; let resolvedSummary = summary || ''; @@ -97,12 +102,16 @@ function buildSite({ docsDir, baseUrl, title, summary }) { const abs = path.join(docsDir, home); const parsed = parse(readText(abs)); const h1 = parsed.headings.find((heading) => heading.level === 1); - if (!title) resolvedTitle = h1 ? h1.text : ''; + if (!title && h1 && h1.text) resolvedTitle = h1.text; if (!summary) resolvedSummary = parsed.firstParagraph || ''; pages.push({ rel: home, abs, url: '', title: resolvedTitle, description: '' }); } + if (!resolvedTitle) { + const pkg = readPkg(cwd); + if (pkg && typeof pkg.name === 'string' && pkg.name) resolvedTitle = pkg.name; + } + if (!resolvedTitle) resolvedTitle = path.basename(docsDir); return { title: resolvedTitle, summary: resolvedSummary, groups: [{ name: 'Docs', pages }], baseUrl: baseUrl || '', docsDir }; } -function renderLlmsTxt() { throw new Error('renderLlmsTxt missing'); } function renderLlmsFull() { throw new Error('renderLlmsFull missing'); } module.exports = { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull }; diff --git a/test/llms.test.js b/test/llms.test.js index 2560991..58fb6b7 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -177,3 +177,17 @@ test('summary skips a badge-only paragraph', () => { }); assert.equal(siteOf(dir).summary, 'Real summary here.'); }); + +test('title falls back to package.json name, then the folder name, and the blockquote is left out without a paragraph', () => { + const named = makeDocs({ 'page.md': 'No heading and no paragraph marker.\n', 'package.json': JSON.stringify({ name: 'from-pkg' }) }); + const namedSite = siteOf(named); + assert.equal(namedSite.title, 'from-pkg'); + assert.equal(namedSite.summary, ''); + assert.equal(renderLlmsTxt(namedSite).includes('> '), false); + const folder = makeDocs({ 'package.json': '{}' }); + const folderSite = buildSite({ + docsDir: folder, baseUrl: 'https://example.com/', exclude: [], cwd: folder, + }); + assert.equal(folderSite.title, path.basename(folder)); + assert.equal(folderSite.summary, ''); +}); From b1c547e823f9cc2703d876d1e154558ca421f308 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:03 -0400 Subject: [PATCH 055/133] feat: lists the home page first, then toc pages, without underscore, toc, or ignore-all pages --- src/llms.js | 75 ++++++++++++++++++++------ test/fixtures/site/README.md | 7 +++ test/fixtures/site/_navbar.md | 1 + test/fixtures/site/_sidebar.md | 12 +++++ test/fixtures/site/about.md | 3 ++ test/fixtures/site/comments.md | 17 ++++++ test/fixtures/site/guide/parts/part.md | 9 ++++ test/fixtures/site/guide/setup.md | 34 ++++++++++++ test/fixtures/site/hidden.md | 3 ++ test/fixtures/site/notes.txt | 1 + test/fixtures/site/samples.md | 13 +++++ test/fixtures/site/toc.md | 3 ++ test/fixtures/site/widget.js | 2 + test/llms.test.js | 30 +++++++++++ 14 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 test/fixtures/site/README.md create mode 100644 test/fixtures/site/_navbar.md create mode 100644 test/fixtures/site/_sidebar.md create mode 100644 test/fixtures/site/about.md create mode 100644 test/fixtures/site/comments.md create mode 100644 test/fixtures/site/guide/parts/part.md create mode 100644 test/fixtures/site/guide/setup.md create mode 100644 test/fixtures/site/hidden.md create mode 100644 test/fixtures/site/notes.txt create mode 100644 test/fixtures/site/samples.md create mode 100644 test/fixtures/site/toc.md create mode 100644 test/fixtures/site/widget.js diff --git a/src/llms.js b/src/llms.js index e95967b..01ca87a 100644 --- a/src/llms.js +++ b/src/llms.js @@ -87,31 +87,76 @@ function resolveBaseUrl({ throw new Error('could not detect the site URL. Pass --base-url, for example --base-url=https://example.com/docs/, or use --no-llm.'); } +function withSlash(baseUrl) { + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; +} -function renderLlmsTxt(site) { - let out = `# ${site.title}\n`; - if (site.summary) out += `\n> ${site.summary}\n`; - return out; +function makePage(rel, abs, baseUrl) { + void baseUrl; + const parsed = parse(readText(abs)); + return { + rel, + abs, + url: '', + title: '', + description: '', + ignoreAll: parsed.headings.some((heading) => heading.ignoreAll), + }; } -function buildSite({ docsDir, baseUrl, title, summary, cwd }) { +function groupByH2(pages) { + const groups = []; + const index = new Map(); + for (const page of pages) { + const dir = path.posix.dirname(page.rel); + const name = dir === '.' ? 'Docs' : dir; + if (!index.has(name)) { + const group = { name, pages: [] }; + index.set(name, group); + groups.push(group); + } + index.get(name).pages.push(page); + } + return groups; +} +function collectH2Pages(docsDir, baseUrl, exclude) { + const excluded = new Set(exclude.map((item) => path.resolve(item))); + const pages = []; const home = findHomePage(docsDir); + if (home) { + const abs = path.resolve(docsDir, home); + if (!excluded.has(abs)) pages.push(makePage(home, abs, baseUrl)); + } + for (const page of listPages(docsDir, { exclude })) { + pages.push(makePage(page.rel, page.abs, baseUrl)); + } + return pages.filter((page) => !page.ignoreAll); +} + +function buildSite({ docsDir, baseUrl, exclude = [], title, summary, cwd }) { + const root = withSlash(baseUrl || ''); + const pages = collectH2Pages(docsDir, root, exclude); + const groups = groupByH2(pages); let resolvedTitle = title || ''; let resolvedSummary = summary || ''; - const pages = []; - if (home && fs.existsSync(path.join(docsDir, home))) { - const abs = path.join(docsDir, home); - const parsed = parse(readText(abs)); - const h1 = parsed.headings.find((heading) => heading.level === 1); - if (!title && h1 && h1.text) resolvedTitle = h1.text; - if (!summary) resolvedSummary = parsed.firstParagraph || ''; - pages.push({ rel: home, abs, url: '', title: resolvedTitle, description: '' }); + const home = findHomePage(docsDir); + let homeParsed = null; + if (home && fs.existsSync(path.join(docsDir, home))) homeParsed = parse(readText(path.join(docsDir, home))); + if (!resolvedTitle) { + const h1 = homeParsed && homeParsed.headings.find((heading) => heading.level === 1); + if (h1 && h1.text) resolvedTitle = h1.text; } if (!resolvedTitle) { const pkg = readPkg(cwd); if (pkg && typeof pkg.name === 'string' && pkg.name) resolvedTitle = pkg.name; } if (!resolvedTitle) resolvedTitle = path.basename(docsDir); - return { title: resolvedTitle, summary: resolvedSummary, groups: [{ name: 'Docs', pages }], baseUrl: baseUrl || '', docsDir }; + if (!summary) resolvedSummary = homeParsed && homeParsed.firstParagraph ? homeParsed.firstParagraph : ''; + return { title: resolvedTitle, summary: resolvedSummary, baseUrl: root, docsDir, groups }; +} +function renderLlmsTxt(site) { + let out = `# ${site.title}\n`; + if (site.summary) out += `\n> ${site.summary}\n`; + return out; } -function renderLlmsFull() { throw new Error('renderLlmsFull missing'); } +function renderLlmsFull() { throw new Error('later'); } module.exports = { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull }; diff --git a/test/fixtures/site/README.md b/test/fixtures/site/README.md new file mode 100644 index 0000000..1448ea2 --- /dev/null +++ b/test/fixtures/site/README.md @@ -0,0 +1,7 @@ +# Toc Site + +[![npm](https://img.shields.io/npm/v/tocsify.svg)](https://www.npmjs.com/package/tocsify) + +This is the home page. It explains the tool. + +[toc](toc.md ':include') diff --git a/test/fixtures/site/_navbar.md b/test/fixtures/site/_navbar.md new file mode 100644 index 0000000..de7d88c --- /dev/null +++ b/test/fixtures/site/_navbar.md @@ -0,0 +1 @@ +- [About](about.md) diff --git a/test/fixtures/site/_sidebar.md b/test/fixtures/site/_sidebar.md new file mode 100644 index 0000000..64251a4 --- /dev/null +++ b/test/fixtures/site/_sidebar.md @@ -0,0 +1,12 @@ +- Guide + - [Setup](guide/setup.md) + - [Part](guide/parts/part.md) + - [Missing](missing-page.md) + - [Outside](https://example.com/nope) + - [Again](guide/setup.md) + - [Hidden](hidden.md) + - [Nav](/_navbar.md) +- More + - [About](/about) + - [Samples](samples) + - [Slash](/) diff --git a/test/fixtures/site/about.md b/test/fixtures/site/about.md new file mode 100644 index 0000000..f98424d --- /dev/null +++ b/test/fixtures/site/about.md @@ -0,0 +1,3 @@ +# About + +About this project. More words here. diff --git a/test/fixtures/site/comments.md b/test/fixtures/site/comments.md new file mode 100644 index 0000000..bba8c1e --- /dev/null +++ b/test/fixtures/site/comments.md @@ -0,0 +1,17 @@ +# Comments + +Hello world. + + + +`` + +``` + +``` + +Text after. diff --git a/test/fixtures/site/guide/parts/part.md b/test/fixtures/site/guide/parts/part.md new file mode 100644 index 0000000..d090f45 --- /dev/null +++ b/test/fixtures/site/guide/parts/part.md @@ -0,0 +1,9 @@ +# Part + +A part of the guide. + +![inner](pic.png) + +[up](../setup.md) + + diff --git a/test/fixtures/site/guide/setup.md b/test/fixtures/site/guide/setup.md new file mode 100644 index 0000000..4595b4a --- /dev/null +++ b/test/fixtures/site/guide/setup.md @@ -0,0 +1,34 @@ +--- +description: "Install the tool" +--- + +# Setup + +Use the guide for install. + +See [part](guide/parts/part.md) and ![pic](./img/a.png). + +b + +more + +ext + +mail + +local + +cdn + +[root](/top.md) + +Read [Advanced setup][adv], see ![diagram][diagram], and check [notes](), FAQ, [pdf](files/guide.pdf), [about](about), and [guide home](./). + +`[not a link](skip.md)` + +``` +[not](skip2.md) +``` + +[adv]: advanced.md +[diagram]: img/diagram.png diff --git a/test/fixtures/site/hidden.md b/test/fixtures/site/hidden.md new file mode 100644 index 0000000..0de44de --- /dev/null +++ b/test/fixtures/site/hidden.md @@ -0,0 +1,3 @@ +# Hidden + +Secret text. diff --git a/test/fixtures/site/notes.txt b/test/fixtures/site/notes.txt new file mode 100644 index 0000000..dd98aa8 --- /dev/null +++ b/test/fixtures/site/notes.txt @@ -0,0 +1 @@ +plain notes diff --git a/test/fixtures/site/samples.md b/test/fixtures/site/samples.md new file mode 100644 index 0000000..989bc66 --- /dev/null +++ b/test/fixtures/site/samples.md @@ -0,0 +1,13 @@ +# Samples + +[code](widget.js ':include :type=code') + +[remote](https://example.com/a.md ':include') + +[secure](http://example.com/b.md ':include') + +[frag](guide/setup.md ':include :fragment=part') + +[other](guide/setup.md ':include :type=html') + +[notes](notes.txt ':include') diff --git a/test/fixtures/site/toc.md b/test/fixtures/site/toc.md new file mode 100644 index 0000000..10d6449 --- /dev/null +++ b/test/fixtures/site/toc.md @@ -0,0 +1,3 @@ +# TOC + +See the contents. diff --git a/test/fixtures/site/widget.js b/test/fixtures/site/widget.js new file mode 100644 index 0000000..f9f9353 --- /dev/null +++ b/test/fixtures/site/widget.js @@ -0,0 +1,2 @@ +const sample = "```"; +console.log(sample); diff --git a/test/llms.test.js b/test/llms.test.js index 58fb6b7..32c2d3a 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -191,3 +191,33 @@ test('title falls back to package.json name, then the folder name, and the block assert.equal(folderSite.title, path.basename(folder)); assert.equal(folderSite.summary, ''); }); + +test('lists the home page first, then toc pages, without underscore, toc, or ignore-all pages', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-order-')); + fs.cpSync(siteDir, dir, { recursive: true }); + const site = buildSite({ + docsDir: dir, + baseUrl: 'https://example.com/docs/', + exclude: [path.join(dir, 'toc.md'), path.join(dir, 'README.md')], + cwd: dir, + }); + const rels = site.groups.flatMap((group) => group.pages.map((page) => page.rel)); + assert.equal(rels.includes('README.md'), false); + assert.equal(rels.includes('toc.md'), false); + assert.equal(rels.includes('hidden.md'), false); + assert.equal(rels.includes('_navbar.md'), false); + assert.deepEqual(rels, [ + 'about.md', + 'comments.md', + 'samples.md', + 'guide/parts/part.md', + 'guide/setup.md', + ]); + const withHome = buildSite({ + docsDir: dir, + baseUrl: 'https://example.com/docs/', + exclude: [path.join(dir, 'toc.md')], + cwd: dir, + }); + assert.equal(withHome.groups[0].pages[0].rel, 'README.md'); +}); From 94c0fd2103528fbdc94bab358fb160d11b0dbe26 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:04 -0400 Subject: [PATCH 056/133] feat: description comes from front matter --- src/llms.js | 13 +++++++++++-- test/llms.test.js | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/llms.js b/src/llms.js index 01ca87a..2ba0263 100644 --- a/src/llms.js +++ b/src/llms.js @@ -91,6 +91,16 @@ function withSlash(baseUrl) { return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } +function pageDescription(parsed) { + const line = parsed.frontmatter.split('\n').find((item) => item.startsWith('description:')); + if (!line) return ''; + let value = line.slice('description:'.length).trim(); + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) { + value = value.slice(1, -1); + } + return value; +} function makePage(rel, abs, baseUrl) { void baseUrl; const parsed = parse(readText(abs)); @@ -99,7 +109,7 @@ function makePage(rel, abs, baseUrl) { abs, url: '', title: '', - description: '', + description: pageDescription(parsed), ignoreAll: parsed.headings.some((heading) => heading.ignoreAll), }; } @@ -131,7 +141,6 @@ function collectH2Pages(docsDir, baseUrl, exclude) { } return pages.filter((page) => !page.ignoreAll); } - function buildSite({ docsDir, baseUrl, exclude = [], title, summary, cwd }) { const root = withSlash(baseUrl || ''); const pages = collectH2Pages(docsDir, root, exclude); diff --git a/test/llms.test.js b/test/llms.test.js index 32c2d3a..0847a15 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -221,3 +221,17 @@ test('lists the home page first, then toc pages, without underscore, toc, or ign }); assert.equal(withHome.groups[0].pages[0].rel, 'README.md'); }); + +test('description comes from front matter', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHome text.\n', + 'quoted.md': '---\ndescription: "Quoted text"\n---\n# Quoted\n', + 'single.md': "---\ndescription: 'Single text'\n---\n# Single\n", + 'plain.md': '---\ndescription: Plain text\n---\n# Plain\n', + }); + const pages = siteOf(dir).groups.flatMap((group) => group.pages); + const byRel = Object.fromEntries(pages.map((page) => [page.rel, page.description])); + assert.equal(byRel['quoted.md'], 'Quoted text'); + assert.equal(byRel['single.md'], 'Single text'); + assert.equal(byRel['plain.md'], 'Plain text'); +}); From 55092e94aa94adc56a4f987cebaa0834de3ac426 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:04 -0400 Subject: [PATCH 057/133] feat: description falls back to the first sentence of the first paragraph --- src/llms.js | 25 +++++++++++++------------ test/llms.test.js | 12 ++++++++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/llms.js b/src/llms.js index 2ba0263..9f4bc8c 100644 --- a/src/llms.js +++ b/src/llms.js @@ -90,26 +90,27 @@ function resolveBaseUrl({ function withSlash(baseUrl) { return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } - +function pageTitle(){ return ""; } function pageDescription(parsed) { const line = parsed.frontmatter.split('\n').find((item) => item.startsWith('description:')); - if (!line) return ''; - let value = line.slice('description:'.length).trim(); - const quote = value[0]; - if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) { - value = value.slice(1, -1); + if (line) { + let value = line.slice('description:'.length).trim(); + const quote = value[0]; + if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) { + value = value.slice(1, -1); + } + return value; } - return value; + if (!parsed.firstParagraph) return ''; + const sentence = parsed.firstParagraph.match(/^[\s\S]*?[.!?](?=\s|$)/); + return sentence ? sentence[0] : parsed.firstParagraph; } + function makePage(rel, abs, baseUrl) { void baseUrl; const parsed = parse(readText(abs)); return { - rel, - abs, - url: '', - title: '', - description: pageDescription(parsed), + rel, abs, url: '', title: '', description: pageDescription(parsed), ignoreAll: parsed.headings.some((heading) => heading.ignoreAll), }; } diff --git a/test/llms.test.js b/test/llms.test.js index 0847a15..b925d50 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -235,3 +235,15 @@ test('description comes from front matter', () => { assert.equal(byRel['single.md'], 'Single text'); assert.equal(byRel['plain.md'], 'Plain text'); }); + +test('description falls back to the first sentence of the first paragraph', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHome.\n', + 'story.md': '# Story\n\nFirst sentence. Second sentence.\n', + 'open.md': '# Open\n\nJust words\n', + }); + const pages = siteOf(dir).groups.flatMap((group) => group.pages); + const byRel = Object.fromEntries(pages.map((page) => [page.rel, page.description])); + assert.equal(byRel['story.md'], 'First sentence.'); + assert.equal(byRel['open.md'], 'Just words'); +}); From 6ced42b03ed414857308e0151a375d6c4358bdce Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:05 -0400 Subject: [PATCH 058/133] feat: an entry has no description when the page has no paragraph --- src/llms.js | 20 +++++++++++++++++--- test/llms.test.js | 8 ++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/llms.js b/src/llms.js index 9f4bc8c..cebd9ad 100644 --- a/src/llms.js +++ b/src/llms.js @@ -90,7 +90,12 @@ function resolveBaseUrl({ function withSlash(baseUrl) { return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } -function pageTitle(){ return ""; } + +function pageTitle(rel, parsed) { + void rel; + const h1 = parsed.headings.find((heading) => heading.level === 1); + return h1 ? h1.text : ''; +} function pageDescription(parsed) { const line = parsed.frontmatter.split('\n').find((item) => item.startsWith('description:')); if (line) { @@ -107,10 +112,13 @@ function pageDescription(parsed) { } function makePage(rel, abs, baseUrl) { - void baseUrl; const parsed = parse(readText(abs)); + const root = withSlash(baseUrl); return { - rel, abs, url: '', title: '', description: pageDescription(parsed), + rel, abs, + url: new URL(encodeURI(rel), root).href, + title: pageTitle(rel, parsed), + description: pageDescription(parsed), ignoreAll: parsed.headings.some((heading) => heading.ignoreAll), }; } @@ -166,6 +174,12 @@ function buildSite({ docsDir, baseUrl, exclude = [], title, summary, cwd }) { function renderLlmsTxt(site) { let out = `# ${site.title}\n`; if (site.summary) out += `\n> ${site.summary}\n`; + for (const group of site.groups) { + for (const page of group.pages) { + const desc = page.description ? `: ${page.description}` : ''; + out += `- [${page.title}](${page.url})${desc}\n`; + } + } return out; } function renderLlmsFull() { throw new Error('later'); } diff --git a/test/llms.test.js b/test/llms.test.js index b925d50..2bc94d1 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -247,3 +247,11 @@ test('description falls back to the first sentence of the first paragraph', () = assert.equal(byRel['story.md'], 'First sentence.'); assert.equal(byRel['open.md'], 'Just words'); }); + +test('an entry has no description when the page has no paragraph', () => { + const dir = makeDocs({ 'README.md': '# Home\n', 'empty.md': '# Empty\n' }); + const page = siteOf(dir).groups[0].pages.find((item) => item.rel === 'empty.md'); + assert.equal(page.description, ''); + assert.equal(renderLlmsTxt(siteOf(dir)).includes('[Empty]'), true); + assert.match(renderLlmsTxt(siteOf(dir)), /- \[Empty\]\(https:\/\/example.com\/docs\/empty\.md\)\n/); +}); From a2e59bad371880b126fe38c3f8cd9464cf7b8342 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:05 -0400 Subject: [PATCH 059/133] feat: entry title falls back to the path without .md --- src/llms.js | 5 ++--- test/llms.test.js | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/llms.js b/src/llms.js index cebd9ad..b5db017 100644 --- a/src/llms.js +++ b/src/llms.js @@ -90,11 +90,10 @@ function resolveBaseUrl({ function withSlash(baseUrl) { return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } - function pageTitle(rel, parsed) { - void rel; const h1 = parsed.headings.find((heading) => heading.level === 1); - return h1 ? h1.text : ''; + if (h1) return h1.text; + return rel.replace(/\.md$/, ''); } function pageDescription(parsed) { const line = parsed.frontmatter.split('\n').find((item) => item.startsWith('description:')); diff --git a/test/llms.test.js b/test/llms.test.js index 2bc94d1..d3c17ff 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -255,3 +255,10 @@ test('an entry has no description when the page has no paragraph', () => { assert.equal(renderLlmsTxt(siteOf(dir)).includes('[Empty]'), true); assert.match(renderLlmsTxt(siteOf(dir)), /- \[Empty\]\(https:\/\/example.com\/docs\/empty\.md\)\n/); }); + +test('entry title falls back to the path without .md', () => { + const dir = makeDocs({ 'README.md': 'No h1 here.\n\nWords.\n', 'guide/no-h1.md': 'Body only.\n' }); + const pages = siteOf(dir).groups.flatMap((group) => group.pages); + const guide = pages.find((page) => page.rel === 'guide/no-h1.md'); + assert.equal(guide.title, 'guide/no-h1'); +}); From 9592e908253a057f0b8dc9e1e91c9e29b7102f35 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:05 -0400 Subject: [PATCH 060/133] test: URLs are absolute, use /, and percent-encode spaces --- test/llms.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/llms.test.js b/test/llms.test.js index d3c17ff..d6f69e2 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -262,3 +262,16 @@ test('entry title falls back to the path without .md', () => { const guide = pages.find((page) => page.rel === 'guide/no-h1.md'); assert.equal(guide.title, 'guide/no-h1'); }); + +test('URLs are absolute, use /, and percent-encode spaces', () => { + const dir = makeDocs({ 'README.md': '# Home\n', 'my file.md': '# Space\n' }); + const page = siteOf(dir).groups[0].pages.find((item) => item.rel === 'my file.md'); + assert.equal(page.url, 'https://example.com/docs/my%20file.md'); + const bare = buildSite({ + docsDir: dir, + baseUrl: 'https://example.com/docs', + cwd: dir, + }); + const again = bare.groups[0].pages.find((item) => item.rel === 'my file.md'); + assert.equal(again.url, 'https://example.com/docs/my%20file.md'); +}); From 7115a37d23627695a3e8bd254ccc78693fc0f2a1 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:06 -0400 Subject: [PATCH 061/133] feat: h2 grouping puts root pages under ## Docs and one H2 per folder in toc order --- src/llms.js | 1 + test/llms.test.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/llms.js b/src/llms.js index b5db017..af468fc 100644 --- a/src/llms.js +++ b/src/llms.js @@ -174,6 +174,7 @@ function renderLlmsTxt(site) { let out = `# ${site.title}\n`; if (site.summary) out += `\n> ${site.summary}\n`; for (const group of site.groups) { + out += `\n## ${group.name}\n\n`; for (const page of group.pages) { const desc = page.description ? `: ${page.description}` : ''; out += `- [${page.title}](${page.url})${desc}\n`; diff --git a/test/llms.test.js b/test/llms.test.js index d6f69e2..47643c5 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -275,3 +275,19 @@ test('URLs are absolute, use /, and percent-encode spaces', () => { const again = bare.groups[0].pages.find((item) => item.rel === 'my file.md'); assert.equal(again.url, 'https://example.com/docs/my%20file.md'); }); + +test('h2 grouping puts root pages under ## Docs and one H2 per folder in toc order', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-h2-')); + fs.cpSync(siteDir, dir, { recursive: true }); + const site = buildSite({ + docsDir: dir, + baseUrl: 'https://example.com/docs/', + exclude: [path.join(dir, 'toc.md')], + cwd: dir, + }); + assert.deepEqual(site.groups.map((group) => group.name), ['Docs', 'guide/parts', 'guide']); + const text = renderLlmsTxt(site); + assert.match(text, /## Docs\n\n- \[Toc Site\]/); + assert.match(text, /## guide\/parts\n\n- \[Part\]/); + assert.match(text, /## guide\n\n- \[Setup\]/); +}); From 2620f68bb8e3b81a6bf876d144c1a798b8b387e2 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:06 -0400 Subject: [PATCH 062/133] test: explicit --group=h2 equals the default --- test/llms.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/llms.test.js b/test/llms.test.js index 47643c5..f4e7aa6 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -291,3 +291,10 @@ test('h2 grouping puts root pages under ## Docs and one H2 per folder in toc ord assert.match(text, /## guide\/parts\n\n- \[Part\]/); assert.match(text, /## guide\n\n- \[Setup\]/); }); + +test('explicit --group=h2 equals the default', () => { + const dir = makeDocs({ 'README.md': '# Home\n\nHi.\n', 'guide/a.md': '# A\n\nText.\n' }); + const one = siteOf(dir); + const two = siteOf(dir, { group: 'h2' }); + assert.deepEqual(one.groups.map((group) => group.name), two.groups.map((group) => group.name)); +}); From d90d33980dd66ef7f9e87cdc6f664f8e30a4598f Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:07 -0400 Subject: [PATCH 063/133] feat: sidebar grouping follows _sidebar.md sections and order --- src/llms.js | 111 +++++++++++++++++++++++++++++++++++++++++----- test/llms.test.js | 23 ++++++++++ 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/llms.js b/src/llms.js index af468fc..2d82b9a 100644 --- a/src/llms.js +++ b/src/llms.js @@ -90,11 +90,13 @@ function resolveBaseUrl({ function withSlash(baseUrl) { return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } + function pageTitle(rel, parsed) { const h1 = parsed.headings.find((heading) => heading.level === 1); if (h1) return h1.text; return rel.replace(/\.md$/, ''); } + function pageDescription(parsed) { const line = parsed.frontmatter.split('\n').find((item) => item.startsWith('description:')); if (line) { @@ -114,13 +116,15 @@ function makePage(rel, abs, baseUrl) { const parsed = parse(readText(abs)); const root = withSlash(baseUrl); return { - rel, abs, + rel, + abs, url: new URL(encodeURI(rel), root).href, title: pageTitle(rel, parsed), description: pageDescription(parsed), ignoreAll: parsed.headings.some((heading) => heading.ignoreAll), }; } + function groupByH2(pages) { const groups = []; const index = new Map(); @@ -136,6 +140,54 @@ function groupByH2(pages) { } return groups; } + +function parseSidebar(text) { + const sections = []; + let current = null; + for (const line of text.split('\n')) { + const match = line.match(/^(\s*)[-*+]\s+(.*)$/); + if (!match) continue; + const indent = match[1].length; + const body = match[2].trim(); + const link = body.match(/^\[([^\]]*)\]\(([^)]+)\)$/); + if (indent === 0 && !link) { + current = { name: body, links: [] }; + sections.push(current); + continue; + } + if (!link) continue; + if (!current) { + current = { name: 'Docs', links: [] }; + sections.unshift(current); + } + current.links.push(link[2].trim()); + } + return sections; +} + +function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { + const sidebarPath = path.join(docsDir, '_sidebar.md'); + const sections = parseSidebar(readText(sidebarPath)); + const groups = []; + function bucket(name) { + let group = groups.find((item) => item.name === name); + if (!group) { + group = { name, pages: [] }; + groups.push(group); + } + return group; + } + for (const section of sections) { + for (const target of section.links) { + const resolved = { rel: target }; + const abs = path.resolve(docsDir, resolved.rel); + const page = makePage(resolved.rel, abs, baseUrl); + bucket(section.name).pages.push(page); + } + } + return groups.filter((group) => group.pages.length > 0); +} + function collectH2Pages(docsDir, baseUrl, exclude) { const excluded = new Set(exclude.map((item) => path.resolve(item))); const pages = []; @@ -149,15 +201,21 @@ function collectH2Pages(docsDir, baseUrl, exclude) { } return pages.filter((page) => !page.ignoreAll); } -function buildSite({ docsDir, baseUrl, exclude = [], title, summary, cwd }) { - const root = withSlash(baseUrl || ''); - const pages = collectH2Pages(docsDir, root, exclude); - const groups = groupByH2(pages); - let resolvedTitle = title || ''; - let resolvedSummary = summary || ''; - const home = findHomePage(docsDir); + +function buildSite({ + docsDir, baseUrl, exclude = [], group = 'h2', title, summary, onWarn, cwd, +}) { + const warn = onWarn || noop; + const root = withSlash(baseUrl); + const groups = group === 'sidebar' + ? groupBySidebar(docsDir, root, exclude, warn) + : groupByH2(collectH2Pages(docsDir, root, exclude)); + const homeRel = findHomePage(docsDir); let homeParsed = null; - if (home && fs.existsSync(path.join(docsDir, home))) homeParsed = parse(readText(path.join(docsDir, home))); + if (homeRel && fs.existsSync(path.join(docsDir, homeRel))) { + homeParsed = parse(readText(path.join(docsDir, homeRel))); + } + let resolvedTitle = title; if (!resolvedTitle) { const h1 = homeParsed && homeParsed.headings.find((heading) => heading.level === 1); if (h1 && h1.text) resolvedTitle = h1.text; @@ -167,9 +225,29 @@ function buildSite({ docsDir, baseUrl, exclude = [], title, summary, cwd }) { if (pkg && typeof pkg.name === 'string' && pkg.name) resolvedTitle = pkg.name; } if (!resolvedTitle) resolvedTitle = path.basename(docsDir); - if (!summary) resolvedSummary = homeParsed && homeParsed.firstParagraph ? homeParsed.firstParagraph : ''; - return { title: resolvedTitle, summary: resolvedSummary, baseUrl: root, docsDir, groups }; + let resolvedSummary = summary || ''; + if (!summary) { + resolvedSummary = homeParsed && homeParsed.firstParagraph ? homeParsed.firstParagraph : ''; + } + const publicGroups = groups.map((item) => ({ + name: item.name, + pages: item.pages.map((page) => ({ + rel: page.rel, + abs: page.abs, + url: page.url, + title: page.title, + description: page.description, + })), + })); + return { + title: resolvedTitle, + summary: resolvedSummary, + baseUrl: root, + docsDir, + groups: publicGroups, + }; } + function renderLlmsTxt(site) { let out = `# ${site.title}\n`; if (site.summary) out += `\n> ${site.summary}\n`; @@ -182,5 +260,14 @@ function renderLlmsTxt(site) { } return out; } + +function noop() {} + function renderLlmsFull() { throw new Error('later'); } -module.exports = { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull }; + +module.exports = { + resolveBaseUrl, + buildSite, + renderLlmsTxt, + renderLlmsFull, +}; diff --git a/test/llms.test.js b/test/llms.test.js index f4e7aa6..0410950 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -298,3 +298,26 @@ test('explicit --group=h2 equals the default', () => { const two = siteOf(dir, { group: 'h2' }); assert.deepEqual(one.groups.map((group) => group.name), two.groups.map((group) => group.name)); }); + +test('sidebar grouping follows _sidebar.md sections and order', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'about.md': '# About\n\nAbout text.\n', + 'guide/setup.md': '# Setup\n\nInstall.\n', + 'guide/deep.md': '# Deep\n\nNested.\n', + 'unlinked.md': '# Unlinked\n\nNot in the sidebar.\n', + '_sidebar.md': [ + '- [Home](README.md)', + '- [About](about.md)', + '- Guide', + ' - [Setup](guide/setup.md)', + ' - [Deep](guide/deep.md)', + ' - plain nested item', + ].join('\n'), + }); + const site = siteOf(dir, { group: 'sidebar' }); + assert.deepEqual(site.groups.map((group) => group.name), ['Docs', 'Guide']); + assert.deepEqual(site.groups[0].pages.map((page) => page.rel), ['README.md', 'about.md']); + assert.deepEqual(site.groups[1].pages.map((page) => page.rel), ['guide/setup.md', 'guide/deep.md']); + assert.match(renderLlmsTxt(site), /## Guide\n\n- \[Setup\]\(https:\/\/example\.com\/docs\/guide\/setup\.md\): Install\.\n- \[Deep\]/); +}); From 876353814b14e000fbea2cf8d73a144de56db2f0 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:07 -0400 Subject: [PATCH 064/133] feat: sidebar links resolve /, folder/, and extensionless paths --- src/llms.js | 11 ++++++++++- test/llms.test.js | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 2d82b9a..d68ff1a 100644 --- a/src/llms.js +++ b/src/llms.js @@ -165,6 +165,15 @@ function parseSidebar(text) { return sections; } +function sidebarTarget(target) { + const clean = target.split('#')[0].split('?')[0].trim(); + let rel = clean; + if (rel.startsWith('/')) rel = rel.slice(1); + if (rel === '' || rel.endsWith('/')) rel += 'README.md'; + else if (!path.posix.extname(rel)) rel += '.md'; + return { rel }; +} + function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { const sidebarPath = path.join(docsDir, '_sidebar.md'); const sections = parseSidebar(readText(sidebarPath)); @@ -179,7 +188,7 @@ function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { } for (const section of sections) { for (const target of section.links) { - const resolved = { rel: target }; + const resolved = sidebarTarget(target); const abs = path.resolve(docsDir, resolved.rel); const page = makePage(resolved.rel, abs, baseUrl); bucket(section.name).pages.push(page); diff --git a/test/llms.test.js b/test/llms.test.js index 0410950..2c60623 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -321,3 +321,23 @@ test('sidebar grouping follows _sidebar.md sections and order', () => { assert.deepEqual(site.groups[1].pages.map((page) => page.rel), ['guide/setup.md', 'guide/deep.md']); assert.match(renderLlmsTxt(site), /## Guide\n\n- \[Setup\]\(https:\/\/example\.com\/docs\/guide\/setup\.md\): Install\.\n- \[Deep\]/); }); + +test('sidebar links resolve /, folder/, and extensionless paths', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'about.md': '# About\n\nAbout.\n', + 'guide/README.md': '# Guide home\n\nNested.\n', + '_sidebar.md': [ + '- Docs', + ' - [Root](/)', + ' - [About](/about#intro)', + ' - [Guide](guide/?tab=1)', + ].join('\n'), + }); + const site = siteOf(dir, { group: 'sidebar' }); + assert.deepEqual(site.groups[0].pages.map((page) => page.rel), [ + 'README.md', + 'about.md', + 'guide/README.md', + ]); +}); From 18b805dc8ce12c7e5b12855a8da5802a7f72f973 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:08 -0400 Subject: [PATCH 065/133] feat: sidebar grouping skips external links --- src/llms.js | 2 ++ test/llms.test.js | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/llms.js b/src/llms.js index d68ff1a..d824dec 100644 --- a/src/llms.js +++ b/src/llms.js @@ -167,6 +167,7 @@ function parseSidebar(text) { function sidebarTarget(target) { const clean = target.split('#')[0].split('?')[0].trim(); + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(clean)) return { external: true }; let rel = clean; if (rel.startsWith('/')) rel = rel.slice(1); if (rel === '' || rel.endsWith('/')) rel += 'README.md'; @@ -189,6 +190,7 @@ function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { for (const section of sections) { for (const target of section.links) { const resolved = sidebarTarget(target); + if (resolved.external) continue; const abs = path.resolve(docsDir, resolved.rel); const page = makePage(resolved.rel, abs, baseUrl); bucket(section.name).pages.push(page); diff --git a/test/llms.test.js b/test/llms.test.js index 2c60623..f2c4420 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -341,3 +341,12 @@ test('sidebar links resolve /, folder/, and extensionless paths', () => { 'guide/README.md', ]); }); + +test('sidebar grouping skips external links', () => { + const dir = makeDocs({ + 'about.md': '# About\n\nAbout.\n', + '_sidebar.md': '- Docs\n - [About](about.md)\n - [Out](https://example.com/x)\n - [Mail](mailto:a@b.c)\n', + }); + const site = siteOf(dir, { group: 'sidebar' }); + assert.deepEqual(site.groups.flatMap((group) => group.pages.map((page) => page.rel)), ['about.md']); +}); From 0a4d76c44fa2f0dedf25ed6c76143c3fd3b2440d Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:09 -0400 Subject: [PATCH 066/133] feat: sidebar grouping warns on a missing page and skips it --- src/llms.js | 4 ++++ test/llms.test.js | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/llms.js b/src/llms.js index d824dec..c35fd84 100644 --- a/src/llms.js +++ b/src/llms.js @@ -192,6 +192,10 @@ function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { const resolved = sidebarTarget(target); if (resolved.external) continue; const abs = path.resolve(docsDir, resolved.rel); + if (!fs.existsSync(abs)) { + onWarn(`sidebar link not found: ${resolved.rel}`); + continue; + } const page = makePage(resolved.rel, abs, baseUrl); bucket(section.name).pages.push(page); } diff --git a/test/llms.test.js b/test/llms.test.js index f2c4420..cd9b7a2 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -350,3 +350,15 @@ test('sidebar grouping skips external links', () => { const site = siteOf(dir, { group: 'sidebar' }); assert.deepEqual(site.groups.flatMap((group) => group.pages.map((page) => page.rel)), ['about.md']); }); + +test('sidebar grouping warns on a missing page and skips it', () => { + const dir = makeDocs({ + 'about.md': '# About\n\nAbout.\n', + '_sidebar.md': '- Docs\n - [Missing](missing.md)\n - [About](about.md)\n - [Dir](guide)\n', + }); + fs.mkdirSync(path.join(dir, 'guide')); + const warnings = []; + const site = siteOf(dir, { group: 'sidebar', onWarn: (message) => warnings.push(message) }); + assert.deepEqual(site.groups.flatMap((group) => group.pages.map((page) => page.rel)), ['about.md']); + assert.deepEqual(warnings, ['sidebar link not found: missing.md', 'sidebar link not found: guide.md']); +}); From 94db6295398baf4e3a67721d9eb0b635766177e7 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:09 -0400 Subject: [PATCH 067/133] feat: sidebar grouping lists each page once, at its first link --- src/llms.js | 3 +++ test/llms.test.js | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/llms.js b/src/llms.js index c35fd84..ce759fc 100644 --- a/src/llms.js +++ b/src/llms.js @@ -178,6 +178,7 @@ function sidebarTarget(target) { function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { const sidebarPath = path.join(docsDir, '_sidebar.md'); const sections = parseSidebar(readText(sidebarPath)); + const seen = new Set(); const groups = []; function bucket(name) { let group = groups.find((item) => item.name === name); @@ -192,11 +193,13 @@ function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { const resolved = sidebarTarget(target); if (resolved.external) continue; const abs = path.resolve(docsDir, resolved.rel); + if (seen.has(abs)) continue; if (!fs.existsSync(abs)) { onWarn(`sidebar link not found: ${resolved.rel}`); continue; } const page = makePage(resolved.rel, abs, baseUrl); + seen.add(abs); bucket(section.name).pages.push(page); } } diff --git a/test/llms.test.js b/test/llms.test.js index cd9b7a2..4166da8 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -362,3 +362,23 @@ test('sidebar grouping warns on a missing page and skips it', () => { assert.deepEqual(site.groups.flatMap((group) => group.pages.map((page) => page.rel)), ['about.md']); assert.deepEqual(warnings, ['sidebar link not found: missing.md', 'sidebar link not found: guide.md']); }); + +test('sidebar grouping lists each page once, at its first link', () => { + const dir = makeDocs({ + 'about.md': '# About\n\nAbout.\n', + 'guide/setup.md': '# Setup\n\nInstall.\n', + '_sidebar.md': [ + '- First', + ' - [About](about.md)', + '- Second', + ' - [Again](/about)', + ' - [Setup](guide/setup.md)', + ' - [Setup again](guide/setup.md#install)', + ].join('\n'), + }); + const site = siteOf(dir, { group: 'sidebar' }); + assert.deepEqual(site.groups.map((group) => [group.name, group.pages.map((page) => page.rel)]), [ + ['First', ['about.md']], + ['Second', ['guide/setup.md']], + ]); +}); From 0897282864a389af12fd6e9fb09cac2df4b7a4b0 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:10 -0400 Subject: [PATCH 068/133] feat: sidebar grouping skips underscore, dot, toc, and ignore-all pages and allows a folder README.md --- src/llms.js | 4 ++++ test/llms.test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/llms.js b/src/llms.js index ce759fc..ffb7d42 100644 --- a/src/llms.js +++ b/src/llms.js @@ -178,6 +178,7 @@ function sidebarTarget(target) { function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { const sidebarPath = path.join(docsDir, '_sidebar.md'); const sections = parseSidebar(readText(sidebarPath)); + const excluded = new Set(exclude.map((item) => path.resolve(item))); const seen = new Set(); const groups = []; function bucket(name) { @@ -192,13 +193,16 @@ function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { for (const target of section.links) { const resolved = sidebarTarget(target); if (resolved.external) continue; + if (resolved.rel.split('/').some((seg) => seg.startsWith('_') || seg.startsWith('.'))) continue; const abs = path.resolve(docsDir, resolved.rel); + if (excluded.has(abs)) continue; if (seen.has(abs)) continue; if (!fs.existsSync(abs)) { onWarn(`sidebar link not found: ${resolved.rel}`); continue; } const page = makePage(resolved.rel, abs, baseUrl); + if (page.ignoreAll) continue; seen.add(abs); bucket(section.name).pages.push(page); } diff --git a/test/llms.test.js b/test/llms.test.js index 4166da8..0b29d17 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -382,3 +382,33 @@ test('sidebar grouping lists each page once, at its first link', () => { ['Second', ['guide/setup.md']], ]); }); + +test('sidebar grouping skips underscore, dot, toc, and ignore-all pages and allows a folder README.md', () => { + const dir = makeDocs({ + 'about.md': '# About\n\nAbout.\n', + 'guide/README.md': '# Guide home\n\nNested.\n', + 'hidden.md': '# Hidden \n', + 'toc.md': '# TOC\n', + '_navbar.md': '- [About](about.md)\n', + '_media/notes.md': '# Notes\n', + '.secret.md': '# Secret\n', + '_sidebar.md': [ + '- Docs', + ' - [About](about.md)', + ' - [Nav](_navbar.md)', + ' - [Media](_media/notes.md)', + ' - [Dot](.secret.md)', + ' - [Hidden](hidden.md)', + ' - [Toc](toc.md)', + ' - [Guide](guide/README.md)', + ].join('\n'), + }); + const site = buildSite({ + docsDir: dir, + baseUrl: 'https://example.com/docs/', + exclude: [path.join(dir, 'toc.md')], + group: 'sidebar', + cwd: dir, + }); + assert.deepEqual(site.groups.flatMap((group) => group.pages.map((page) => page.rel)), ['about.md', 'guide/README.md']); +}); From d4bf26b14f661609c9fb4b93cbcaac071f1c4bab Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:10 -0400 Subject: [PATCH 069/133] feat: sidebar grouping adds the home page when the sidebar does not link it --- src/llms.js | 15 +++++++++++++++ test/llms.test.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/llms.js b/src/llms.js index ffb7d42..ec25626 100644 --- a/src/llms.js +++ b/src/llms.js @@ -207,6 +207,21 @@ function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { bucket(section.name).pages.push(page); } } + const home = findHomePage(docsDir); + if (home) { + const abs = path.resolve(docsDir, home); + if (!seen.has(abs) && !excluded.has(abs)) { + const page = makePage(home, abs, baseUrl); + if (!page.ignoreAll) { + let docs = groups.find((item) => item.name === 'Docs'); + if (!docs) { + docs = { name: 'Docs', pages: [] }; + groups.unshift(docs); + } + docs.pages.unshift(page); + } + } + } return groups.filter((group) => group.pages.length > 0); } diff --git a/test/llms.test.js b/test/llms.test.js index 0b29d17..a04793c 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -412,3 +412,36 @@ test('sidebar grouping skips underscore, dot, toc, and ignore-all pages and allo }); assert.deepEqual(site.groups.flatMap((group) => group.pages.map((page) => page.rel)), ['about.md', 'guide/README.md']); }); + +test('sidebar grouping adds the home page when the sidebar does not link it', () => { + const created = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\nInstall.\n', + '_sidebar.md': '- Guide\n - [Setup](guide/setup.md)\n', + }); + const createdSite = siteOf(created, { group: 'sidebar' }); + assert.equal(createdSite.groups[0].name, 'Docs'); + assert.equal(createdSite.groups[0].pages[0].rel, 'README.md'); + const existing = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'about.md': '# About\n\nAbout.\n', + '_sidebar.md': '- [About](about.md)\n- Later\n - [About](about.md)\n', + }); + const existingSite = siteOf(existing, { group: 'sidebar' }); + assert.equal(existingSite.groups[0].name, 'Docs'); + assert.deepEqual(existingSite.groups[0].pages.map((page) => page.rel), ['README.md', 'about.md']); + const hidden = makeDocs({ + 'README.md': '# Home \n', + 'about.md': '# About\n\nAbout.\n', + '_sidebar.md': '- [About](about.md)\n', + }); + assert.deepEqual(siteOf(hidden, { group: 'sidebar' }).groups[0].pages.map((page) => page.rel), ['about.md']); + const excludedSite = buildSite({ + docsDir: existing, + baseUrl: 'https://example.com/docs/', + exclude: [path.join(existing, 'README.md')], + group: 'sidebar', + cwd: existing, + }); + assert.deepEqual(excludedSite.groups[0].pages.map((page) => page.rel), ['about.md']); +}); From cafba17612b6a75a94b19d216e4725380a33addb Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:11 -0400 Subject: [PATCH 070/133] feat: sidebar grouping without _sidebar.md throws --- src/llms.js | 1 + test/llms.test.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/llms.js b/src/llms.js index ec25626..2966349 100644 --- a/src/llms.js +++ b/src/llms.js @@ -177,6 +177,7 @@ function sidebarTarget(target) { function groupBySidebar(docsDir, baseUrl, exclude, onWarn) { const sidebarPath = path.join(docsDir, '_sidebar.md'); + if (!fs.existsSync(sidebarPath)) throw new Error('sidebar file not found: _sidebar.md'); const sections = parseSidebar(readText(sidebarPath)); const excluded = new Set(exclude.map((item) => path.resolve(item))); const seen = new Set(); diff --git a/test/llms.test.js b/test/llms.test.js index a04793c..1e80da4 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -445,3 +445,8 @@ test('sidebar grouping adds the home page when the sidebar does not link it', () }); assert.deepEqual(excludedSite.groups[0].pages.map((page) => page.rel), ['about.md']); }); + +test('sidebar grouping without _sidebar.md throws', () => { + const dir = makeDocs({ 'README.md': '# Home\n' }); + assert.throws(() => siteOf(dir, { group: 'sidebar' }), /sidebar file not found: _sidebar\.md/); +}); From 62c3110648085fb22b7f55cf1b7c69c0d03f171b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:11 -0400 Subject: [PATCH 071/133] feat: llms.txt ends with ## Optional linking llms-full.txt --- src/llms.js | 2 ++ test/llms.test.js | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/llms.js b/src/llms.js index 2966349..f637e92 100644 --- a/src/llms.js +++ b/src/llms.js @@ -296,6 +296,8 @@ function renderLlmsTxt(site) { out += `- [${page.title}](${page.url})${desc}\n`; } } + const fullUrl = new URL('llms-full.txt', site.baseUrl).href; + out += `\n## Optional\n\n- [llms-full.txt](${fullUrl}): Full text of every page in one file\n`; return out; } diff --git a/test/llms.test.js b/test/llms.test.js index 1e80da4..ecc35a8 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -450,3 +450,9 @@ test('sidebar grouping without _sidebar.md throws', () => { const dir = makeDocs({ 'README.md': '# Home\n' }); assert.throws(() => siteOf(dir, { group: 'sidebar' }), /sidebar file not found: _sidebar\.md/); }); + +test('llms.txt ends with ## Optional linking llms-full.txt', () => { + const dir = makeDocs({ 'README.md': '# Home\n\nHi.\n' }); + const text = renderLlmsTxt(siteOf(dir)); + assert.match(text, /## Optional\n\n- \[llms-full\.txt\]\(https:\/\/example\.com\/docs\/llms-full\.txt\): Full text of every page in one file\n$/); +}); From 4a37ca66d47fa08efb6ba76cec426429a56b3f15 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:12 -0400 Subject: [PATCH 072/133] test: llms.txt matches test/fixtures/site.llms.txt --- test/fixtures/site.llms.txt | 22 ++++++++++++++++++++++ test/llms.test.js | 12 ++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 test/fixtures/site.llms.txt diff --git a/test/fixtures/site.llms.txt b/test/fixtures/site.llms.txt new file mode 100644 index 0000000..6d97ba2 --- /dev/null +++ b/test/fixtures/site.llms.txt @@ -0,0 +1,22 @@ +# Toc Site + +> This is the home page. It explains the tool. + +## Docs + +- [Toc Site](https://example.com/docs/README.md): This is the home page. +- [About](https://example.com/docs/about.md): About this project. +- [Comments](https://example.com/docs/comments.md): Hello world. +- [Samples](https://example.com/docs/samples.md) + +## guide/parts + +- [Part](https://example.com/docs/guide/parts/part.md): A part of the guide. + +## guide + +- [Setup](https://example.com/docs/guide/setup.md): Install the tool + +## Optional + +- [llms-full.txt](https://example.com/docs/llms-full.txt): Full text of every page in one file diff --git a/test/llms.test.js b/test/llms.test.js index ecc35a8..bb16bda 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -456,3 +456,15 @@ test('llms.txt ends with ## Optional linking llms-full.txt', () => { const text = renderLlmsTxt(siteOf(dir)); assert.match(text, /## Optional\n\n- \[llms-full\.txt\]\(https:\/\/example\.com\/docs\/llms-full\.txt\): Full text of every page in one file\n$/); }); + +test('llms.txt matches test/fixtures/site.llms.txt', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-golden-')); + fs.cpSync(siteDir, dir, { recursive: true }); + const site = buildSite({ + docsDir: dir, + baseUrl: 'https://example.com/docs/', + exclude: [path.join(dir, 'toc.md')], + cwd: dir, + }); + assert.equal(renderLlmsTxt(site), fs.readFileSync(path.join(root, 'test/fixtures/site.llms.txt'), 'utf8')); +}); From 27f3322fa27b35e24e522d46bf4cec2fbb9b653d Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:12 -0400 Subject: [PATCH 073/133] feat: llms-full.txt lists pages in llms.txt order with # Title and Source headers --- src/llms.js | 12 +++++++++++- test/llms.test.js | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index f637e92..2966903 100644 --- a/src/llms.js +++ b/src/llms.js @@ -303,7 +303,17 @@ function renderLlmsTxt(site) { function noop() {} -function renderLlmsFull() { throw new Error('later'); } +function renderLlmsFull(site, { keepComments = false, onWarn } = {}) { + const warn = onWarn || noop; + const pages = site.groups.flatMap((group) => group.pages); + const blocks = pages.map((page) => { + let body = readText(page.abs); + body = body.replace(/^ {0,3}#[ \t]+.*(?:\n|$)/, ''); + body = body.trim(); + return `# ${page.title}\nSource: ${page.url}\n\n${body}`; + }); + return `${blocks.join('\n\n')}\n`; +} module.exports = { resolveBaseUrl, diff --git a/test/llms.test.js b/test/llms.test.js index bb16bda..baa458d 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -468,3 +468,16 @@ test('llms.txt matches test/fixtures/site.llms.txt', () => { }); assert.equal(renderLlmsTxt(site), fs.readFileSync(path.join(root, 'test/fixtures/site.llms.txt'), 'utf8')); }); + +test('llms-full.txt lists pages in llms.txt order with # Title and Source headers', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'b.md': '# Bee\n\nSecond.\n', + }); + const site = siteOf(dir); + const full = renderLlmsFull(site); + const titles = [...full.matchAll(/^# .+$/gm)].map((match) => match[0]); + assert.deepEqual(titles, ['# Home', '# Bee']); + assert.match(full, /Source: https:\/\/example.com\/docs\/README.md/); + assert.ok(full.indexOf('# Home') < full.indexOf('# Bee')); +}); From b1438b6b9126a149dd8ca034c9c2c5569e9dc7ea Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:13 -0400 Subject: [PATCH 074/133] feat: front matter and the first H1 are removed from each body --- src/llms.js | 30 +++++++++++++++++++++++++++--- test/llms.test.js | 16 ++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/llms.js b/src/llms.js index 2966903..7417b4f 100644 --- a/src/llms.js +++ b/src/llms.js @@ -303,13 +303,37 @@ function renderLlmsTxt(site) { function noop() {} +function stripFrontMatterAndFirstH1(text) { + const fm = text.match(/^---\n[\s\S]*?\n---\n/); + const rest = fm ? text.slice(fm[0].length) : text; + const lines = rest.split('\n'); + const mask = codeMask(lines); + for (let i = 0; i < lines.length; i += 1) { + if (mask[i] || /^\s*>/.test(lines[i])) continue; + const atx = lines[i].match(/^ {0,3}(#{1,6})[ \t]/); + if (atx && atx[1].length === 1) { + lines.splice(i, 1); + break; + } + if ( + i + 1 < lines.length + && !mask[i + 1] + && /^ {0,3}=+[ \t]*$/.test(lines[i + 1]) + && lines[i].trim() + && !/^\s*(?:[-*+]|\d+\.)[ \t]/.test(lines[i]) + ) { + lines.splice(i, 2); + break; + } + } + return lines.join('\n'); +} + function renderLlmsFull(site, { keepComments = false, onWarn } = {}) { const warn = onWarn || noop; const pages = site.groups.flatMap((group) => group.pages); const blocks = pages.map((page) => { - let body = readText(page.abs); - body = body.replace(/^ {0,3}#[ \t]+.*(?:\n|$)/, ''); - body = body.trim(); + const body = stripFrontMatterAndFirstH1(readText(page.abs)).trim(); return `# ${page.title}\nSource: ${page.url}\n\n${body}`; }); return `${blocks.join('\n\n')}\n`; diff --git a/test/llms.test.js b/test/llms.test.js index baa458d..286fcd4 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -481,3 +481,19 @@ test('llms-full.txt lists pages in llms.txt order with # Title and Source header assert.match(full, /Source: https:\/\/example.com\/docs\/README.md/); assert.ok(full.indexOf('# Home') < full.indexOf('# Bee')); }); + +test('front matter and the first H1 are removed from each body', () => { + const dir = makeDocs({ + 'README.md': '---\ndescription: "D"\n---\n# Home\n\nKept.\n', + 'setext.md': 'Setext Title\n============\n\nBody stays.\n', + 'fenced.md': '```\n# Fake\n```\n> # Quoted\n# Real\n\nAfter.\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.equal(full.includes('description:'), false); + assert.match(full, /# Home\nSource: .*\n\nKept\./); + assert.match(full, /# Setext Title\nSource: .*\n\nBody stays\./); + assert.match(full, /```\n# Fake\n```/); + assert.match(full, /> # Quoted/); + assert.match(full, /# Real\nSource:/); + assert.match(full, /After\./); +}); From 8eaeed03a0fff1331000b4eb4af9cad36c47b618 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:13 -0400 Subject: [PATCH 075/133] feat: both ignore-marker forms are stripped --- src/llms.js | 8 +++++++- test/llms.test.js | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 7417b4f..43d91e5 100644 --- a/src/llms.js +++ b/src/llms.js @@ -329,11 +329,17 @@ function stripFrontMatterAndFirstH1(text) { return lines.join('\n'); } +function stripIgnoreMarkers(text) { + return text + .replace(/[ \t]*/g, '') + .replace(/[ \t]*\{docsify-ignore(?:-all)?\}/g, ''); +} + function renderLlmsFull(site, { keepComments = false, onWarn } = {}) { const warn = onWarn || noop; const pages = site.groups.flatMap((group) => group.pages); const blocks = pages.map((page) => { - const body = stripFrontMatterAndFirstH1(readText(page.abs)).trim(); + const body = stripIgnoreMarkers(stripFrontMatterAndFirstH1(readText(page.abs))).trim(); return `# ${page.title}\nSource: ${page.url}\n\n${body}`; }); return `${blocks.join('\n\n')}\n`; diff --git a/test/llms.test.js b/test/llms.test.js index 286fcd4..379d142 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -497,3 +497,12 @@ test('front matter and the first H1 are removed from each body', () => { assert.match(full, /# Real\nSource:/); assert.match(full, /After\./); }); + +test('both ignore-marker forms are stripped', () => { + const dir = makeDocs({ + 'README.md': '# Home {docsify-ignore}\n\nKeep this.\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.equal(full.includes('docsify-ignore'), false); + assert.match(full, /Keep this\./); +}); From 8e5b90f8b08ab99bbad013f617172afd55e084bb Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:14 -0400 Subject: [PATCH 076/133] feat: relative markdown links resolve from the docs root, as Docsify does --- src/llms.js | 36 +++++++++++++++++++++++++++++++++++- test/llms.test.js | 12 ++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 43d91e5..994a4a1 100644 --- a/src/llms.js +++ b/src/llms.js @@ -335,11 +335,45 @@ function stripIgnoreMarkers(text) { .replace(/[ \t]*\{docsify-ignore(?:-all)?\}/g, ''); } +// Docsify 5 with relativePath: false (checked in headless Chrome against docsify@5.0.0): +// markdown links load from the docs root, markdown images load from the routed page's folder, +// and raw HTML src and href resolve against index.html, which is the docs root. +function fileFolder(rel) { + const dir = path.posix.dirname(rel); + return dir === '.' ? '' : dir; +} + +function absolute(url, baseUrl, folder) { + return new URL(url, `${baseUrl}${folder ? `${folder}/` : ''}`).href; +} + +function linkTarget(url, ctx) { + return absolute(url, ctx.baseUrl, ''); +} + +const DEST = '([^)\\s]+)'; +const LINK = new RegExp(`\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); + +function mapDest(dest, target, ctx) { + return target(dest, ctx); +} + +function rewritePlain(text, ctx) { + const link = (url) => mapDest(url, linkTarget, ctx); + let next = text.replace(LINK, (full, label, url, rest) => `[${label}](${link(url)}${rest})`); + return next; +} + +function processBody(page, site, options) { + const text = stripFrontMatterAndFirstH1(readText(page.abs)); + return rewritePlain(stripIgnoreMarkers(text), { baseUrl: site.baseUrl }).trim(); +} + function renderLlmsFull(site, { keepComments = false, onWarn } = {}) { const warn = onWarn || noop; const pages = site.groups.flatMap((group) => group.pages); const blocks = pages.map((page) => { - const body = stripIgnoreMarkers(stripFrontMatterAndFirstH1(readText(page.abs))).trim(); + const body = processBody(page, site, { keepComments, onWarn: warn }); return `# ${page.title}\nSource: ${page.url}\n\n${body}`; }); return `${blocks.join('\n\n')}\n`; diff --git a/test/llms.test.js b/test/llms.test.js index 379d142..bd81856 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -506,3 +506,15 @@ test('both ignore-marker forms are stripped', () => { assert.equal(full.includes('docsify-ignore'), false); assert.match(full, /Keep this\./); }); + +test('relative markdown links resolve from the docs root, as Docsify does', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[other](other.md) [dot](./guide/next.md) [up](../top.md) [titled](other.md "Other page")\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /\[other\]\(https:\/\/example\.com\/docs\/other\.md\)/); + assert.match(full, /\[dot\]\(https:\/\/example\.com\/docs\/guide\/next\.md\)/); + assert.match(full, /\[up\]\(https:\/\/example\.com\/top\.md\)/); + assert.match(full, /\[titled\]\(https:\/\/example\.com\/docs\/other\.md "Other page"\)/); +}); From 0ebb79fb110a194868efee9d7d42bb319a96cbf5 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:15 -0400 Subject: [PATCH 077/133] feat: absolute URLs, mailto links, #anchors, protocol-relative URLs, and code stay unchanged --- src/llms.js | 73 ++++++++++++++++++++++++++++++++++++++++++++++- test/llms.test.js | 35 +++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 994a4a1..074f910 100644 --- a/src/llms.js +++ b/src/llms.js @@ -343,11 +343,18 @@ function fileFolder(rel) { return dir === '.' ? '' : dir; } +function isRelative(url) { + if (url.startsWith('#') || url.startsWith('//')) return false; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false; + return true; +} + function absolute(url, baseUrl, folder) { return new URL(url, `${baseUrl}${folder ? `${folder}/` : ''}`).href; } function linkTarget(url, ctx) { + if (!isRelative(url)) return url; return absolute(url, ctx.baseUrl, ''); } @@ -364,9 +371,73 @@ function rewritePlain(text, ctx) { return next; } +function splitByFence(text) { + const lines = text.split('\n'); + const mask = codeMask(lines); + const parts = []; + let start = 0; + let mode = mask[0]; + for (let i = 1; i <= lines.length; i += 1) { + if (i === lines.length || mask[i] !== mode) { + parts.push({ code: mode, value: lines.slice(start, i).join('\n') }); + start = i; + mode = mask[i]; + } + } + return parts; +} + +function splitInline(text) { + const parts = []; + let i = 0; + while (i < text.length) { + const tick = text.indexOf('`', i); + if (tick === -1) { + parts.push({ code: false, value: text.slice(i) }); + break; + } + let j = tick; + while (j < text.length && text[j] === '`') j += 1; + const marker = text.slice(tick, j); + const end = text.indexOf(marker, j); + if (end === -1) { + parts.push({ code: false, value: text.slice(i) }); + break; + } + if (tick > i) parts.push({ code: false, value: text.slice(i, tick) }); + parts.push({ code: true, value: text.slice(tick, end + marker.length) }); + i = end + marker.length; + } + return parts; +} + +function outsideCode(text, fn) { + return splitByFence(text).map((part) => { + if (part.code) return part.value; + return splitInline(part.value).map((item) => (item.code ? item.value : fn(item.value))).join(''); + }).join('\n'); +} + +function transformPlain(text, ctx) { + let next = stripIgnoreMarkers(text); + return outsideCode(next, (value) => rewritePlain(value, ctx)); +} + +function transform(text, ctx) { + return outsideCode(text, (value) => transformPlain(value, ctx)); +} + function processBody(page, site, options) { const text = stripFrontMatterAndFirstH1(readText(page.abs)); - return rewritePlain(stripIgnoreMarkers(text), { baseUrl: site.baseUrl }).trim(); + return transform(text, { + docsDir: site.docsDir, + baseUrl: site.baseUrl, + abs: page.abs, + rel: page.rel, + pageRel: page.rel, + keepComments: Boolean(options.keepComments), + onWarn: options.onWarn, + }).trim(); } function renderLlmsFull(site, { keepComments = false, onWarn } = {}) { diff --git a/test/llms.test.js b/test/llms.test.js index bd81856..6a88c20 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -518,3 +518,38 @@ test('relative markdown links resolve from the docs root, as Docsify does', () = assert.match(full, /\[up\]\(https:\/\/example\.com\/top\.md\)/); assert.match(full, /\[titled\]\(https:\/\/example\.com\/docs\/other\.md "Other page"\)/); }); + +test('absolute URLs, mailto links, #anchors, protocol-relative URLs, and code stay unchanged', () => { + const dir = makeDocs({ + 'README.md': [ + '# Home', + '', + '[ext](https://example.com/ext) [mail](mailto:a@b.c) [local](#local) [cdn](//cdn.example.com/x.js)', + '', + '![abs](https://example.com/pic.png) ', + '', + 'ext mail', + '', + 'local cdn', + '', + '`[not a link](skip.md)`', + '', + '```', + '[not](skip2.md)', + '```', + ].join('\n'), + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /\[ext\]\(https:\/\/example\.com\/ext\)/); + assert.match(full, /\[mail\]\(mailto:a@b\.c\)/); + assert.match(full, /\[local\]\(#local\)/); + assert.match(full, /\[cdn\]\(\/\/cdn\.example\.com\/x\.js\)/); + assert.match(full, /!\[abs\]\(https:\/\/example\.com\/pic\.png\)/); + assert.match(full, //); + assert.match(full, //); + assert.match(full, //); + assert.match(full, //); + assert.match(full, //); + assert.match(full, /`\[not a link\]\(skip\.md\)`/); + assert.match(full, /```\n\[not\]\(skip2\.md\)\n```/); +}); From 6f56547f66f2fa364e7b34f673d7f59dc904a45b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:15 -0400 Subject: [PATCH 078/133] feat: extensionless and folder links load .md and README.md, as Docsify does --- src/llms.js | 14 +++++++++++++- test/llms.test.js | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 074f910..fe3dc50 100644 --- a/src/llms.js +++ b/src/llms.js @@ -353,9 +353,21 @@ function absolute(url, baseUrl, folder) { return new URL(url, `${baseUrl}${folder ? `${folder}/` : ''}`).href; } +function splitSuffix(url) { + const at = url.search(/[?#]/); + return at === -1 ? [url, ''] : [url.slice(0, at), url.slice(at)]; +} + +function docsifyPagePath(file) { + if (file === '' || file.endsWith('/')) return `${file}README.md`; + if (/\.(md|html)$/.test(file)) return file; + return `${file}.md`; +} + function linkTarget(url, ctx) { if (!isRelative(url)) return url; - return absolute(url, ctx.baseUrl, ''); + const [file, suffix] = splitSuffix(url); + return absolute(`${docsifyPagePath(file)}${suffix}`, ctx.baseUrl, ''); } const DEST = '([^)\\s]+)'; diff --git a/test/llms.test.js b/test/llms.test.js index 6a88c20..12e5244 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -553,3 +553,17 @@ test('absolute URLs, mailto links, #anchors, protocol-relative URLs, and code st assert.match(full, /`\[not a link\]\(skip\.md\)`/); assert.match(full, /```\n\[not\]\(skip2\.md\)\n```/); }); + +test('extensionless and folder links load .md and README.md, as Docsify does', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[ext](other) [dir](guide/) [hash](other#install) [query](?tab=1) [page](demo.html) [md](other.md?x=1)\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /\[ext\]\(https:\/\/example\.com\/docs\/other\.md\)/); + assert.match(full, /\[dir\]\(https:\/\/example\.com\/docs\/guide\/README\.md\)/); + assert.match(full, /\[hash\]\(https:\/\/example\.com\/docs\/other\.md#install\)/); + assert.match(full, /\[query\]\(https:\/\/example\.com\/docs\/README\.md\?tab=1\)/); + assert.match(full, /\[page\]\(https:\/\/example\.com\/docs\/demo\.html\)/); + assert.match(full, /\[md\]\(https:\/\/example\.com\/docs\/other\.md\?x=1\)/); +}); From 6ed7278f614cbbfaf483ef97ffd1b5a6531f6bb3 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:16 -0400 Subject: [PATCH 079/133] feat: a relative link to a non-markdown file keeps its path from the docs root --- src/llms.js | 2 +- test/llms.test.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index fe3dc50..8d7ff9c 100644 --- a/src/llms.js +++ b/src/llms.js @@ -360,7 +360,7 @@ function splitSuffix(url) { function docsifyPagePath(file) { if (file === '' || file.endsWith('/')) return `${file}README.md`; - if (/\.(md|html)$/.test(file)) return file; + if (path.posix.extname(file)) return file; return `${file}.md`; } diff --git a/test/llms.test.js b/test/llms.test.js index 12e5244..21bba3f 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -567,3 +567,13 @@ test('extensionless and folder links load .md and README.md, as Docsify does', ( assert.match(full, /\[page\]\(https:\/\/example\.com\/docs\/demo\.html\)/); assert.match(full, /\[md\]\(https:\/\/example\.com\/docs\/other\.md\?x=1\)/); }); + +test('a relative link to a non-markdown file keeps its path from the docs root', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[pdf](files/guide.pdf) [archive](files/demo.tar.gz)\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /\[pdf\]\(https:\/\/example\.com\/docs\/files\/guide\.pdf\)/); + assert.match(full, /\[archive\]\(https:\/\/example\.com\/docs\/files\/demo\.tar\.gz\)/); +}); From ac1f2018edd6e48928f57e09b4910c7fac5e971c Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:16 -0400 Subject: [PATCH 080/133] feat: markdown images resolve from the page folder, as Docsify does --- src/llms.js | 20 +++++++++++++++++++- test/llms.test.js | 12 ++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 8d7ff9c..80c3133 100644 --- a/src/llms.js +++ b/src/llms.js @@ -370,8 +370,15 @@ function linkTarget(url, ctx) { return absolute(`${docsifyPagePath(file)}${suffix}`, ctx.baseUrl, ''); } +function imageTarget(url, ctx) { + if (!isRelative(url)) return url; + return absolute(url, ctx.baseUrl, fileFolder(ctx.pageRel)); +} + const DEST = '([^)\\s]+)'; const LINK = new RegExp(`\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); +const IMAGE = new RegExp(`!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); +const BADGE = new RegExp(`\\[!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)\\]\\(${DEST}([^)]*)\\)`, 'g'); function mapDest(dest, target, ctx) { return target(dest, ctx); @@ -379,7 +386,18 @@ function mapDest(dest, target, ctx) { function rewritePlain(text, ctx) { const link = (url) => mapDest(url, linkTarget, ctx); - let next = text.replace(LINK, (full, label, url, rest) => `[${label}](${link(url)}${rest})`); + const image = (url) => mapDest(url, imageTarget, ctx); + const slots = []; + const hold = (value) => { + slots.push(value); + return `%%LLMS_SLOT_${slots.length - 1}%%`; + }; + let next = text.replace(BADGE, (full, alt, src, srcRest, href, hrefRest) => ( + hold(`[![${alt}](${image(src)}${srcRest})](${link(href)}${hrefRest})`) + )); + next = next.replace(IMAGE, (full, alt, url, rest) => hold(`![${alt}](${image(url)}${rest})`)); + next = next.replace(LINK, (full, label, url, rest) => `[${label}](${link(url)}${rest})`); + next = next.replace(/%%LLMS_SLOT_(\d+)%%/g, (full, index) => slots[Number(index)]); return next; } diff --git a/test/llms.test.js b/test/llms.test.js index 21bba3f..042f7c9 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -577,3 +577,15 @@ test('a relative link to a non-markdown file keeps its path from the docs root', assert.match(full, /\[pdf\]\(https:\/\/example\.com\/docs\/files\/guide\.pdf\)/); assert.match(full, /\[archive\]\(https:\/\/example\.com\/docs\/files\/demo\.tar\.gz\)/); }); + +test('markdown images resolve from the page folder, as Docsify does', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\n![root](img/r.png)\n', + 'guide/setup.md': '# Setup\n\n![pic](./img/a.png) ![up](../img/b.png "B") [![badge](img/badge.svg)](other.md)\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /!\[root\]\(https:\/\/example\.com\/docs\/img\/r\.png\)/); + assert.match(full, /!\[pic\]\(https:\/\/example\.com\/docs\/guide\/img\/a\.png\)/); + assert.match(full, /!\[up\]\(https:\/\/example\.com\/docs\/img\/b\.png "B"\)/); + assert.match(full, /\[!\[badge\]\(https:\/\/example\.com\/docs\/guide\/img\/badge\.svg\)\]\(https:\/\/example\.com\/docs\/other\.md\)/); +}); From 8ad8630d60bcff86512d23c7c61f5af60dab813f Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:17 -0400 Subject: [PATCH 081/133] feat: img src resolves from the docs root, as the browser does in Docsify --- src/llms.js | 8 ++++++++ test/llms.test.js | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/llms.js b/src/llms.js index 80c3133..ef613ce 100644 --- a/src/llms.js +++ b/src/llms.js @@ -375,10 +375,16 @@ function imageTarget(url, ctx) { return absolute(url, ctx.baseUrl, fileFolder(ctx.pageRel)); } +function htmlTarget(url, ctx) { + if (!isRelative(url)) return url; + return absolute(url, ctx.baseUrl, ''); +} + const DEST = '([^)\\s]+)'; const LINK = new RegExp(`\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const IMAGE = new RegExp(`!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const BADGE = new RegExp(`\\[!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)\\]\\(${DEST}([^)]*)\\)`, 'g'); +const IMG_SRC = /(]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi; function mapDest(dest, target, ctx) { return target(dest, ctx); @@ -398,6 +404,8 @@ function rewritePlain(text, ctx) { next = next.replace(IMAGE, (full, alt, url, rest) => hold(`![${alt}](${image(url)}${rest})`)); next = next.replace(LINK, (full, label, url, rest) => `[${label}](${link(url)}${rest})`); next = next.replace(/%%LLMS_SLOT_(\d+)%%/g, (full, index) => slots[Number(index)]); + const html = (full, pre, quote, url) => `${pre}${quote}${htmlTarget(url, ctx)}${quote}`; + next = next.replace(IMG_SRC, html); return next; } diff --git a/test/llms.test.js b/test/llms.test.js index 042f7c9..bd0fe94 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -589,3 +589,13 @@ test('markdown images resolve from the page folder, as Docsify does', () => { assert.match(full, /!\[up\]\(https:\/\/example\.com\/docs\/img\/b\.png "B"\)/); assert.match(full, /\[!\[badge\]\(https:\/\/example\.com\/docs\/guide\/img\/badge\.svg\)\]\(https:\/\/example\.com\/docs\/other\.md\)/); }); + +test('img src resolves from the docs root, as the browser does in Docsify', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\nb \'c\'\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /b/); + assert.match(full, /c/); +}); From 5404e9dcaf75824c18964069015786ad52db05da Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:18 -0400 Subject: [PATCH 082/133] feat: a href resolves from the docs root, as the browser does in Docsify --- src/llms.js | 2 ++ test/llms.test.js | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/llms.js b/src/llms.js index ef613ce..df6f5be 100644 --- a/src/llms.js +++ b/src/llms.js @@ -385,6 +385,7 @@ const LINK = new RegExp(`\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const IMAGE = new RegExp(`!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const BADGE = new RegExp(`\\[!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)\\]\\(${DEST}([^)]*)\\)`, 'g'); const IMG_SRC = /(]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi; +const A_HREF = /(]*\bhref\s*=\s*)(["'])([^"']+)\2/gi; function mapDest(dest, target, ctx) { return target(dest, ctx); @@ -406,6 +407,7 @@ function rewritePlain(text, ctx) { next = next.replace(/%%LLMS_SLOT_(\d+)%%/g, (full, index) => slots[Number(index)]); const html = (full, pre, quote, url) => `${pre}${quote}${htmlTarget(url, ctx)}${quote}`; next = next.replace(IMG_SRC, html); + next = next.replace(A_HREF, html); return next; } diff --git a/test/llms.test.js b/test/llms.test.js index bd0fe94..5288ecd 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -599,3 +599,13 @@ test('img src resolves from the docs root, as the browser does in Docsify', () = assert.match(full, /b/); assert.match(full, /c/); }); + +test('a href resolves from the docs root, as the browser does in Docsify', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\nmore up\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /more<\/a>/); + assert.match(full, /up<\/a>/); +}); From 12cd83cd497e891cc791660263a90ca24c335bbb Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:18 -0400 Subject: [PATCH 083/133] feat: a URL that starts with / resolves against the base URL, which is the docs root --- src/llms.js | 1 + test/llms.test.js | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/llms.js b/src/llms.js index df6f5be..bb91034 100644 --- a/src/llms.js +++ b/src/llms.js @@ -350,6 +350,7 @@ function isRelative(url) { } function absolute(url, baseUrl, folder) { + if (url.startsWith('/')) return new URL(url.replace(/^\/+/, ''), baseUrl).href; return new URL(url, `${baseUrl}${folder ? `${folder}/` : ''}`).href; } diff --git a/test/llms.test.js b/test/llms.test.js index 5288ecd..1923828 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -609,3 +609,16 @@ test('a href resolves from the docs root, as the browser does in Docsify', () => assert.match(full, /more<\/a>/); assert.match(full, /up<\/a>/); }); + +test('a URL that starts with / resolves against the base URL, which is the docs root', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[top](/top.md) [home](/) ![logo](/img/logo.png) x\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /\[top\]\(https:\/\/example\.com\/docs\/top\.md\)/); + assert.match(full, /\[home\]\(https:\/\/example\.com\/docs\/README\.md\)/); + assert.match(full, /!\[logo\]\(https:\/\/example\.com\/docs\/img\/logo\.png\)/); + assert.match(full, //); + assert.match(full, //); +}); From dcf2f9a7c12d00e13bc514188a2a315120764a00 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:19 -0400 Subject: [PATCH 084/133] feat: reference-style definitions resolve like the links and images that use them --- src/llms.js | 20 +++++++++++++++++++- test/llms.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index bb91034..fd1576b 100644 --- a/src/llms.js +++ b/src/llms.js @@ -385,6 +385,7 @@ const DEST = '([^)\\s]+)'; const LINK = new RegExp(`\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const IMAGE = new RegExp(`!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const BADGE = new RegExp(`\\[!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)\\]\\(${DEST}([^)]*)\\)`, 'g'); +const REF_DEF = /^( {0,3}\[([^\]]+)\]:[ \t]*)(<[^>\n]*>|\S+)/gm; const IMG_SRC = /(]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi; const A_HREF = /(]*\bhref\s*=\s*)(["'])([^"']+)\2/gi; @@ -392,6 +393,18 @@ function mapDest(dest, target, ctx) { return target(dest, ctx); } +function refKey(label) { + return label.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function imageRefKeys(text) { + const keys = new Set(); + for (const match of text.matchAll(/!\[([^\]]*)\](?:\[([^\]]*)\]|(?![(:]))/g)) { + keys.add(refKey(match[2] || match[1])); + } + return keys; +} + function rewritePlain(text, ctx) { const link = (url) => mapDest(url, linkTarget, ctx); const image = (url) => mapDest(url, imageTarget, ctx); @@ -406,6 +419,10 @@ function rewritePlain(text, ctx) { next = next.replace(IMAGE, (full, alt, url, rest) => hold(`![${alt}](${image(url)}${rest})`)); next = next.replace(LINK, (full, label, url, rest) => `[${label}](${link(url)}${rest})`); next = next.replace(/%%LLMS_SLOT_(\d+)%%/g, (full, index) => slots[Number(index)]); + next = next.replace(REF_DEF, (full, pre, label, url) => { + const target = ctx.imageRefs.has(refKey(label)) ? imageTarget : linkTarget; + return `${pre}${mapDest(url, target, ctx)}`; + }); const html = (full, pre, quote, url) => `${pre}${quote}${htmlTarget(url, ctx)}${quote}`; next = next.replace(IMG_SRC, html); next = next.replace(A_HREF, html); @@ -465,7 +482,8 @@ function transformPlain(text, ctx) { } function transform(text, ctx) { - return outsideCode(text, (value) => transformPlain(value, ctx)); + const local = { ...ctx, imageRefs: imageRefKeys(text) }; + return outsideCode(text, (value) => transformPlain(value, local)); } function processBody(page, site, options) { diff --git a/test/llms.test.js b/test/llms.test.js index 1923828..fb1b77c 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -622,3 +622,27 @@ test('a URL that starts with / resolves against the base URL, which is the docs assert.match(full, //); assert.match(full, //); }); + +test('reference-style definitions resolve like the links and images that use them', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': [ + '# Setup', + '', + 'See [the guide][g], [Other], `[code][g]`, and ![logo][l]. ![Badge][]', + '', + '[g]: ./next.md', + '[other]: other.md "Other"', + '[l]: img/logo.png', + '[badge]: img/badge.svg', + ' [abs]: https://example.com/abs', + ].join('\n'), + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /^\[g\]: https:\/\/example\.com\/docs\/next\.md$/m); + assert.match(full, /^\[other\]: https:\/\/example\.com\/docs\/other\.md "Other"$/m); + assert.match(full, /^\[l\]: https:\/\/example\.com\/docs\/guide\/img\/logo\.png$/m); + assert.match(full, /^\[badge\]: https:\/\/example\.com\/docs\/guide\/img\/badge\.svg$/m); + assert.match(full, /^ {2}\[abs\]: https:\/\/example\.com\/abs$/m); + assert.match(full, /`\[code\]\[g\]`/); +}); From 4eb00229533a564ed5caf1bc27cc3dd8fac46e5b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:20 -0400 Subject: [PATCH 085/133] feat: angle-bracket destinations are rewritten with spaces percent-encoded --- src/llms.js | 6 +++++- test/llms.test.js | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index fd1576b..33f7e12 100644 --- a/src/llms.js +++ b/src/llms.js @@ -381,7 +381,7 @@ function htmlTarget(url, ctx) { return absolute(url, ctx.baseUrl, ''); } -const DEST = '([^)\\s]+)'; +const DEST = '(<[^>\\n]*>|[^)\\s]+)'; const LINK = new RegExp(`\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const IMAGE = new RegExp(`!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const BADGE = new RegExp(`\\[!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)\\]\\(${DEST}([^)]*)\\)`, 'g'); @@ -390,6 +390,10 @@ const IMG_SRC = /(]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi; const A_HREF = /(]*\bhref\s*=\s*)(["'])([^"']+)\2/gi; function mapDest(dest, target, ctx) { + if (/^<[^>]*>$/.test(dest)) { + const inner = dest.slice(1, -1); + return isRelative(inner) ? target(inner, ctx) : dest; + } return target(dest, ctx); } diff --git a/test/llms.test.js b/test/llms.test.js index fb1b77c..cb9c6fc 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -646,3 +646,15 @@ test('reference-style definitions resolve like the links and images that use the assert.match(full, /^ {2}\[abs\]: https:\/\/example\.com\/abs$/m); assert.match(full, /`\[code\]\[g\]`/); }); + +test('angle-bracket destinations are rewritten with spaces percent-encoded', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[angle]() ![pic]() [abs]()\n\n[def]: \n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /\[angle\]\(https:\/\/example\.com\/docs\/my%20file\.md\)/); + assert.match(full, /!\[pic\]\(https:\/\/example\.com\/docs\/guide\/my%20pic\.png\)/); + assert.match(full, /\[abs\]\(\)/); + assert.match(full, /^\[def\]: https:\/\/example\.com\/docs\/other%20page\.md$/m); +}); From d30eaed822e0aa9636327186054df169a221641d Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:20 -0400 Subject: [PATCH 086/133] feat: unquoted src and href values are rewritten --- src/llms.js | 11 ++++++----- test/llms.test.js | 11 +++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/llms.js b/src/llms.js index 33f7e12..406dd7b 100644 --- a/src/llms.js +++ b/src/llms.js @@ -386,8 +386,8 @@ const LINK = new RegExp(`\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const IMAGE = new RegExp(`!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)`, 'g'); const BADGE = new RegExp(`\\[!\\[([^\\]]*)\\]\\(${DEST}([^)]*)\\)\\]\\(${DEST}([^)]*)\\)`, 'g'); const REF_DEF = /^( {0,3}\[([^\]]+)\]:[ \t]*)(<[^>\n]*>|\S+)/gm; -const IMG_SRC = /(]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi; -const A_HREF = /(]*\bhref\s*=\s*)(["'])([^"']+)\2/gi; +const IMG_SRC = /(]*\bsrc\s*=\s*)(?:(["'])([^"']+)\2|([^\s"'>]+))/gi; +const A_HREF = /(]*\bhref\s*=\s*)(?:(["'])([^"']+)\2|([^\s"'>]+))/gi; function mapDest(dest, target, ctx) { if (/^<[^>]*>$/.test(dest)) { @@ -427,9 +427,10 @@ function rewritePlain(text, ctx) { const target = ctx.imageRefs.has(refKey(label)) ? imageTarget : linkTarget; return `${pre}${mapDest(url, target, ctx)}`; }); - const html = (full, pre, quote, url) => `${pre}${quote}${htmlTarget(url, ctx)}${quote}`; - next = next.replace(IMG_SRC, html); - next = next.replace(A_HREF, html); + const html = (full, pre, quote, quoted, bare) => ( + quote ? `${pre}${quote}${htmlTarget(quoted, ctx)}${quote}` : `${pre}${htmlTarget(bare, ctx)}` + ); + next = next.replace(IMG_SRC, html).replace(A_HREF, html); return next; } diff --git a/test/llms.test.js b/test/llms.test.js index cb9c6fc..caa903c 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -658,3 +658,14 @@ test('angle-bracket destinations are rewritten with spaces percent-encoded', () assert.match(full, /\[abs\]\(\)/); assert.match(full, /^\[def\]: https:\/\/example\.com\/docs\/other%20page\.md$/m); }); + +test('unquoted src and href values are rewritten', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\nfoo x top\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /foo<\/a>/); + assert.match(full, /x/); + assert.match(full, /top<\/a>/); +}); From 2bd0143ce5e775d01167c921df46790124e9cee3 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:21 -0400 Subject: [PATCH 087/133] feat: llms.txt descriptions render [text][ref] links as plain text --- src/headings.js | 15 ++++++++++----- test/llms.test.js | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/headings.js b/src/headings.js index 5773a28..1d43805 100644 --- a/src/headings.js +++ b/src/headings.js @@ -71,10 +71,14 @@ function hasIgnoreAll(raw) { return /\{docsify-ignore-all\}/.test(raw); } +// A target is an inline (url) or a reference [label], so badges and images in either form count as media. +const BADGE = /\[!\[[^\]]*\](?:\([^)]*\)|\[[^\]]*\])\](?:\([^)]*\)|\[[^\]]*\])/g; +const IMAGE = /!\[[^\]]*\](?:\([^)]*\)|\[[^\]]*\])/g; + function onlyMedia(raw) { const left = raw - .replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '') - .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(BADGE, '') + .replace(IMAGE, '') .replace(/\[[^\]]*\]\([^)]*:include[^)]*\)/g, '') .trim(); return left.length === 0; @@ -82,9 +86,9 @@ function onlyMedia(raw) { function plainText(raw) { return raw - .replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '') - .replace(/!\[[^\]]*\]\([^)]*\)/g, '') - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(BADGE, '') + .replace(IMAGE, '') + .replace(/\[([^\]]*)\](?:\([^)]*\)|\[[^\]]*\])/g, '$1') .replace(/`+/g, '') .replace(/[*_~]+/g, '') .replace(/<[^>]+>/g, '') @@ -99,6 +103,7 @@ function isStructural(line) { if (/^\s*[!?]>\s?/.test(line)) return true; if (line.includes('|')) return true; if (/^\s*<[^>]+>\s*$/.test(line)) return true; + if (/^ {0,3}\[[^\]]+\]:/.test(line)) return true; return false; } diff --git a/test/llms.test.js b/test/llms.test.js index caa903c..b4b8c0e 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -669,3 +669,26 @@ test('unquoted src and href values are rewritten', () => { assert.match(full, /x/); assert.match(full, /top<\/a>/); }); + +test('llms.txt descriptions render [text][ref] links as plain text', () => { + const dir = makeDocs({ + 'README.md': [ + '# Home', + '', + '[![npm][badge]][npm] See [the guide][g] and ![logo][l] now. More.', + '', + '[g]: guide.md', + '[l]: logo.png', + '[badge]: https://img.shields.io/npm/v/x.svg', + '[npm]: https://www.npmjs.com/package/x', + ].join('\n'), + 'refs.md': '# Refs\n\n[a]: a.md\n\nReal text here.\n', + 'badges.md': '# Badges\n\n[![npm][badge]][npm]\n\nAfter the badge.\n\n[badge]: https://img.shields.io/npm/v/x.svg\n[npm]: https://www.npmjs.com/package/x\n', + }); + const site = siteOf(dir); + const pages = site.groups.flatMap((group) => group.pages); + assert.equal(site.summary, 'See the guide and now. More.'); + assert.equal(pages.find((page) => page.rel === 'README.md').description, 'See the guide and now.'); + assert.equal(pages.find((page) => page.rel === 'refs.md').description, 'Real text here.'); + assert.equal(pages.find((page) => page.rel === 'badges.md').description, 'After the badge.'); +}); From 403680e8b438df705c408c208ad8c84415b1b2ad Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:21 -0400 Subject: [PATCH 088/133] feat: a local markdown include is inlined --- src/llms.js | 83 +++++++++++++++++++++++++++++++++++++++++++++++ test/llms.test.js | 34 +++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/llms.js b/src/llms.js index 406dd7b..545694e 100644 --- a/src/llms.js +++ b/src/llms.js @@ -481,8 +481,91 @@ function outsideCode(text, fn) { }).join('\n'); } +function includeMode(url, title) { + const typeMatch = title.match(/:type=(\S+)/); + const type = typeMatch ? typeMatch[1] : ''; + const clean = url.split('#')[0].split('?')[0]; + if (type === 'markdown' || (!type && /\.(md|markdown)$/i.test(clean))) return 'markdown'; + return 'link'; +} + +function resolveLocal(url, ctx) { + const clean = url.split('#')[0].split('?')[0]; + const abs = clean.startsWith('/') + ? path.resolve(ctx.docsDir, clean.slice(1)) + : path.resolve(path.dirname(ctx.abs), clean); + const relToDocs = path.relative(path.resolve(ctx.docsDir), abs); + return { abs, rel: relToDocs.split(path.sep).join('/') }; +} + +function findNextLink(text, from) { + for (let i = from; i < text.length; i += 1) { + if (text[i] !== '[') continue; + if (text[i - 1] === '!') continue; + const close = text.indexOf(']', i + 1); + if (close === -1 || text[close + 1] !== '(') continue; + let j = close + 2; + while (j < text.length && text[j] === ' ') j += 1; + const urlStart = j; + while (j < text.length && text[j] !== ')' && text[j] !== ' ' && text[j] !== '\n') j += 1; + const url = text.slice(urlStart, j); + while (j < text.length && text[j] === ' ') j += 1; + let title = ''; + if (text[j] === '"' || text[j] === "'") { + const quote = text[j]; + const titleEnd = text.indexOf(quote, j + 1); + if (titleEnd === -1) continue; + title = text.slice(j + 1, titleEnd); + j = titleEnd + 1; + while (j < text.length && text[j] === ' ') j += 1; + } + if (text[j] !== ')') continue; + return { + start: i, + end: j + 1, + url, + title, + raw: text.slice(i, j + 1), + }; + } + return null; +} + +// An include that stays a link points where Docsify fetches it: the including file's folder. +function includeLink(link, ctx) { + const at = link.raw.indexOf(link.url, link.raw.indexOf('](')); + const url = absolute(link.url, ctx.baseUrl, fileFolder(ctx.rel)); + return `${link.raw.slice(0, at)}${url}${link.raw.slice(at + link.url.length)}`; +} + +function renderInclude(link, ctx) { + if (!link.title.includes(':include')) return link.raw; + const mode = includeMode(link.url, link.title); + const resolved = resolveLocal(link.url, ctx); + if (mode === 'link') return includeLink(link, ctx); + const included = readText(resolved.abs); + return included; +} + +function expandIncludes(text, ctx) { + let out = ''; + let i = 0; + while (i < text.length) { + const link = findNextLink(text, i); + if (!link) { + out += text.slice(i); + break; + } + out += text.slice(i, link.start); + out += renderInclude(link, ctx); + i = link.end; + } + return out; +} + function transformPlain(text, ctx) { let next = stripIgnoreMarkers(text); + next = expandIncludes(next, ctx); return outsideCode(next, (value) => rewritePlain(value, ctx)); } diff --git a/test/llms.test.js b/test/llms.test.js index b4b8c0e..c6cad6e 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -692,3 +692,37 @@ test('llms.txt descriptions render [text][ref] links as plain text', () => { assert.equal(pages.find((page) => page.rel === 'refs.md').description, 'Real text here.'); assert.equal(pages.find((page) => page.rel === 'badges.md').description, 'After the badge.'); }); + +test('a local markdown include is inlined', () => { + const dir = makeDocs({ + 'README.md': [ + '# Home', + '', + '[part](part.md \':include\')', + '', + '[root](/guide/root-part.md \':include\')', + '', + '[typed](typed.txt ":include :type=markdown")', + '', + 'Plain: ![solo](solo.png) [note] [open ![pic](a.png) [spaced]( file.md) [titled](file.md "title")', + '[gap](file.md "title" ) [junk](file.md \'t\' x) [bad](file.md "noend) and [', + ].join('\n'), + 'part.md': '# Part\n\nInlined body.\n', + 'guide/root-part.md': 'FROM_ROOT_INCLUDE\n', + 'guide/page.md': '# Page\n\n[sibling](sibling.md \':include\')\n', + 'guide/sibling.md': 'FROM_SIBLING\n', + 'typed.txt': 'Typed text.\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /# Part\n\nInlined body\./); + assert.match(full, /FROM_ROOT_INCLUDE/); + assert.match(full, /# Page\nSource: .*\n\nFROM_SIBLING/); + assert.match(full, /Typed text\./); + assert.equal(full.includes(':include'), false); + assert.match(full, /!\[solo\]\(https:\/\/example\.com\/docs\/solo\.png\) \[note\] \[open !\[pic\]\(https:\/\/example\.com\/docs\/a\.png\)/); + assert.match(full, /\[spaced\]\( file\.md\)/); + assert.match(full, /\[titled\]\(https:\/\/example\.com\/docs\/file\.md "title"\)/); + assert.match(full, /\[gap\]\(https:\/\/example\.com\/docs\/file\.md "title" \)/); + assert.match(full, /\[junk\]\(https:\/\/example\.com\/docs\/file\.md 't' x\)/); + assert.match(full, /\[bad\]\(https:\/\/example\.com\/docs\/file\.md "noend\) and \[/); +}); From 28a5838a54ee7b2405372ac9cbf4ebdbff3cf283 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:22 -0400 Subject: [PATCH 089/133] test: inlined content resolves images from the page folder and links from the docs root, as Docsify does --- test/llms.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/llms.test.js b/test/llms.test.js index c6cad6e..2298e4c 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -726,3 +726,16 @@ test('a local markdown include is inlined', () => { assert.match(full, /\[junk\]\(https:\/\/example\.com\/docs\/file\.md 't' x\)/); assert.match(full, /\[bad\]\(https:\/\/example\.com\/docs\/file\.md "noend\) and \[/); }); + +test('inlined content resolves images from the page folder and links from the docs root, as Docsify does', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[part](_parts/part.md \':include\')\n', + 'guide/_parts/part.md': '![inner](pic.png) [link](next.md) \n\n`[skip](no.md)`\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /!\[inner\]\(https:\/\/example\.com\/docs\/guide\/pic\.png\)/); + assert.match(full, /\[link\]\(https:\/\/example\.com\/docs\/next\.md\)/); + assert.match(full, //); + assert.match(full, /`\[skip\]\(no\.md\)`/); +}); From bd313612e2ac3bca1536d9f8fa8601bca227e975 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:22 -0400 Subject: [PATCH 090/133] feat: reference images in inlined content resolve from the page folder --- src/llms.js | 2 +- test/llms.test.js | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 545694e..55da5e9 100644 --- a/src/llms.js +++ b/src/llms.js @@ -544,7 +544,7 @@ function renderInclude(link, ctx) { const resolved = resolveLocal(link.url, ctx); if (mode === 'link') return includeLink(link, ctx); const included = readText(resolved.abs); - return included; + return transform(included, { ...ctx, abs: resolved.abs, rel: resolved.rel }); } function expandIncludes(text, ctx) { diff --git a/test/llms.test.js b/test/llms.test.js index 2298e4c..a143921 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -739,3 +739,14 @@ test('inlined content resolves images from the page folder and links from the do assert.match(full, //); assert.match(full, /`\[skip\]\(no\.md\)`/); }); + +test('reference images in inlined content resolve from the page folder', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[part](_parts/part.md \':include\')\n', + 'guide/_parts/part.md': 'See ![diagram][d] and [next][n].\n\n[d]: img/d.png\n[n]: next.md\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /^\[d\]: https:\/\/example\.com\/docs\/guide\/img\/d\.png$/m); + assert.match(full, /^\[n\]: https:\/\/example\.com\/docs\/next\.md$/m); +}); From dc747140c662120e25f01d80aa2d032ad0f3d7ff Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:23 -0400 Subject: [PATCH 091/133] feat: an include inside an included file stays a link, as Docsify does --- src/llms.js | 4 ++-- test/llms.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/llms.js b/src/llms.js index 55da5e9..8611fc5 100644 --- a/src/llms.js +++ b/src/llms.js @@ -544,7 +544,7 @@ function renderInclude(link, ctx) { const resolved = resolveLocal(link.url, ctx); if (mode === 'link') return includeLink(link, ctx); const included = readText(resolved.abs); - return transform(included, { ...ctx, abs: resolved.abs, rel: resolved.rel }); + return transform(included, { ...ctx, inlined: true }); } function expandIncludes(text, ctx) { @@ -565,7 +565,7 @@ function expandIncludes(text, ctx) { function transformPlain(text, ctx) { let next = stripIgnoreMarkers(text); - next = expandIncludes(next, ctx); + if (!ctx.inlined) next = expandIncludes(next, ctx); return outsideCode(next, (value) => rewritePlain(value, ctx)); } diff --git a/test/llms.test.js b/test/llms.test.js index a143921..6108e3e 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -750,3 +750,16 @@ test('reference images in inlined content resolve from the page folder', () => { assert.match(full, /^\[d\]: https:\/\/example\.com\/docs\/guide\/img\/d\.png$/m); assert.match(full, /^\[n\]: https:\/\/example\.com\/docs\/next\.md$/m); }); + +test('an include inside an included file stays a link, as Docsify does', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHi.\n', + 'guide/setup.md': '# Setup\n\n[a](_parts/a.md \':include\')\n', + 'guide/_parts/a.md': 'A_TEXT\n\n[b](b.md \':include\')\n', + 'guide/_parts/b.md': 'B_TEXT\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /A_TEXT/); + assert.equal(full.includes('B_TEXT'), false); + assert.match(full, /\[b\]\(https:\/\/example\.com\/docs\/b\.md ':include'\)/); +}); From 2285bea117c4da15a6590dfee42023b6f38a14f5 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:23 -0400 Subject: [PATCH 092/133] feat: a missing include warns and stays a link --- src/llms.js | 5 +++++ test/llms.test.js | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/llms.js b/src/llms.js index 8611fc5..ed70494 100644 --- a/src/llms.js +++ b/src/llms.js @@ -495,6 +495,7 @@ function resolveLocal(url, ctx) { ? path.resolve(ctx.docsDir, clean.slice(1)) : path.resolve(path.dirname(ctx.abs), clean); const relToDocs = path.relative(path.resolve(ctx.docsDir), abs); + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return { missing: true }; return { abs, rel: relToDocs.split(path.sep).join('/') }; } @@ -542,6 +543,10 @@ function renderInclude(link, ctx) { if (!link.title.includes(':include')) return link.raw; const mode = includeMode(link.url, link.title); const resolved = resolveLocal(link.url, ctx); + if (resolved.missing) { + ctx.onWarn(`missing include: ${link.url}`); + return includeLink(link, ctx); + } if (mode === 'link') return includeLink(link, ctx); const included = readText(resolved.abs); return transform(included, { ...ctx, inlined: true }); diff --git a/test/llms.test.js b/test/llms.test.js index 6108e3e..12ec537 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -763,3 +763,20 @@ test('an include inside an included file stays a link, as Docsify does', () => { assert.equal(full.includes('B_TEXT'), false); assert.match(full, /\[b\]\(https:\/\/example\.com\/docs\/b\.md ':include'\)/); }); + +test('a missing include warns and stays a link', () => { + const dir = makeDocs({ + 'README.md': '# Home\n', + 'guide/page.md': '# Page\n\n[missing](missing.md \':include\')\n\n[dir](sub \':include\')\n\n[part](_parts/part.md \':include\')\n', + 'guide/_parts/part.md': 'PART_TEXT\n\n[gone](gone.md \':include\')\n', + }); + fs.mkdirSync(path.join(dir, 'guide/sub')); + const warnings = []; + const full = renderLlmsFull(siteOf(dir), { onWarn: (message) => warnings.push(message) }); + assert.deepEqual(warnings, ['missing include: missing.md', 'missing include: sub']); + assert.match(full, /\[missing\]\(https:\/\/example\.com\/docs\/guide\/missing\.md ':include'\)/); + assert.match(full, /\[dir\]\(https:\/\/example\.com\/docs\/guide\/sub ':include'\)/); + const quiet = renderLlmsFull(siteOf(dir)); + assert.match(quiet, /guide\/missing\.md ':include'/); + assert.match(full, /\[gone\]\(https:\/\/example\.com\/docs\/gone\.md ':include'\)/); +}); From a2440c08c1219adabbe304f047488caf2bb6f4b7 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:24 -0400 Subject: [PATCH 093/133] feat: an include outside the docs folder warns and stays a link --- src/llms.js | 5 +++++ test/llms.test.js | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/llms.js b/src/llms.js index ed70494..b574ca9 100644 --- a/src/llms.js +++ b/src/llms.js @@ -495,6 +495,7 @@ function resolveLocal(url, ctx) { ? path.resolve(ctx.docsDir, clean.slice(1)) : path.resolve(path.dirname(ctx.abs), clean); const relToDocs = path.relative(path.resolve(ctx.docsDir), abs); + if (relToDocs.startsWith('..')) return { outside: true }; if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return { missing: true }; return { abs, rel: relToDocs.split(path.sep).join('/') }; } @@ -543,6 +544,10 @@ function renderInclude(link, ctx) { if (!link.title.includes(':include')) return link.raw; const mode = includeMode(link.url, link.title); const resolved = resolveLocal(link.url, ctx); + if (resolved.outside) { + ctx.onWarn(`include is outside the docs folder: ${link.url}`); + return includeLink(link, ctx); + } if (resolved.missing) { ctx.onWarn(`missing include: ${link.url}`); return includeLink(link, ctx); diff --git a/test/llms.test.js b/test/llms.test.js index 12ec537..5b627ff 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -780,3 +780,16 @@ test('a missing include warns and stays a link', () => { assert.match(quiet, /guide\/missing\.md ':include'/); assert.match(full, /\[gone\]\(https:\/\/example\.com\/docs\/gone\.md ':include'\)/); }); + +test('an include outside the docs folder warns and stays a link', () => { + const parent = makeDocs({ + 'docs/README.md': '# Home\n\n[out](../secret.md \':include\')\n', + 'secret.md': 'SECRET_OUTSIDE\n', + }); + const dir = path.join(parent, 'docs'); + const warnings = []; + const full = renderLlmsFull(siteOf(dir), { onWarn: (message) => warnings.push(message) }); + assert.deepEqual(warnings, ['include is outside the docs folder: ../secret.md']); + assert.equal(full.includes('SECRET_OUTSIDE'), false); + assert.match(full, /\[out\]\(https:\/\/example\.com\/secret\.md ':include'\)/); +}); From 4a9e246819580fa53aebfffbb231d8e9a40c0999 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:25 -0400 Subject: [PATCH 094/133] feat: remote http and https includes stay links --- src/llms.js | 1 + test/llms.test.js | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/llms.js b/src/llms.js index b574ca9..4e2a8d4 100644 --- a/src/llms.js +++ b/src/llms.js @@ -542,6 +542,7 @@ function includeLink(link, ctx) { function renderInclude(link, ctx) { if (!link.title.includes(':include')) return link.raw; + if (!isRelative(link.url)) return link.raw; const mode = includeMode(link.url, link.title); const resolved = resolveLocal(link.url, ctx); if (resolved.outside) { diff --git a/test/llms.test.js b/test/llms.test.js index 5b627ff..27c9010 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -793,3 +793,14 @@ test('an include outside the docs folder warns and stays a link', () => { assert.equal(full.includes('SECRET_OUTSIDE'), false); assert.match(full, /\[out\]\(https:\/\/example\.com\/secret\.md ':include'\)/); }); + +test('remote http and https includes stay links', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\n[a](https://example.com/a.md \':include\')\n\n[b](http://example.com/b.md \':include\')\n', + }); + const warnings = []; + const full = renderLlmsFull(siteOf(dir), { onWarn: (message) => warnings.push(message) }); + assert.deepEqual(warnings, []); + assert.match(full, /\[a\]\(https:\/\/example.com\/a.md ':include'\)/); + assert.match(full, /\[b\]\(http:\/\/example.com\/b.md ':include'\)/); +}); From 7ec12cad36c3ced77869cfd2611f8ce6a0979e96 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:25 -0400 Subject: [PATCH 095/133] feat: :type=code includes go in a fence with a longer fence when needed --- src/llms.js | 17 +++++++++++++++++ test/llms.test.js | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/llms.js b/src/llms.js index 4e2a8d4..5165ae7 100644 --- a/src/llms.js +++ b/src/llms.js @@ -484,6 +484,7 @@ function outsideCode(text, fn) { function includeMode(url, title) { const typeMatch = title.match(/:type=(\S+)/); const type = typeMatch ? typeMatch[1] : ''; + if (type === 'code') return 'code'; const clean = url.split('#')[0].split('?')[0]; if (type === 'markdown' || (!type && /\.(md|markdown)$/i.test(clean))) return 'markdown'; return 'link'; @@ -500,6 +501,19 @@ function resolveLocal(url, ctx) { return { abs, rel: relToDocs.split(path.sep).join('/') }; } +function codeFence(content, ext) { + const ticks = content.match(/`+/g); + let longest = 0; + if (ticks) { + for (const run of ticks) { + if (run.length > longest) longest = run.length; + } + } + const fence = '`'.repeat(Math.max(3, longest + 1)); + const body = content.replace(/\s+$/, ''); + return `${fence}${ext}\n${body}\n${fence}`; +} + function findNextLink(text, from) { for (let i = from; i < text.length; i += 1) { if (text[i] !== '[') continue; @@ -554,6 +568,9 @@ function renderInclude(link, ctx) { return includeLink(link, ctx); } if (mode === 'link') return includeLink(link, ctx); + if (mode === 'code') { + return codeFence(readText(resolved.abs), path.posix.extname(resolved.rel).slice(1)); + } const included = readText(resolved.abs); return transform(included, { ...ctx, inlined: true }); } diff --git a/test/llms.test.js b/test/llms.test.js index 27c9010..abd2a19 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -804,3 +804,14 @@ test('remote http and https includes stay links', () => { assert.match(full, /\[a\]\(https:\/\/example.com\/a.md ':include'\)/); assert.match(full, /\[b\]\(http:\/\/example.com\/b.md ':include'\)/); }); + +test(':type=code includes go in a fence with a longer fence when needed', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\n[code](widget.js \':include :type=code\')\n\n[plain](plain.txt \':include :type=code\')\n', + 'widget.js': 'const sample = "```";\nconsole.log(\'hi\');\n', + 'plain.txt': 'no ticks\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /````js\nconst sample = "```";\nconsole.log\('hi'\);\n````/); + assert.match(full, /```txt\nno ticks\n```/); +}); From e9aa9f61f9ed08b99514a6e86f62776c7b832d82 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:26 -0400 Subject: [PATCH 096/133] feat: other :type values and :fragment includes stay links --- src/llms.js | 3 ++- test/llms.test.js | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 5165ae7..f2b3f74 100644 --- a/src/llms.js +++ b/src/llms.js @@ -484,9 +484,10 @@ function outsideCode(text, fn) { function includeMode(url, title) { const typeMatch = title.match(/:type=(\S+)/); const type = typeMatch ? typeMatch[1] : ''; + if (title.includes(':fragment=') || (type && type !== 'markdown' && type !== 'code')) return 'link'; if (type === 'code') return 'code'; const clean = url.split('#')[0].split('?')[0]; - if (type === 'markdown' || (!type && /\.(md|markdown)$/i.test(clean))) return 'markdown'; + if (type === 'markdown' || /\.(md|markdown)$/i.test(clean)) return 'markdown'; return 'link'; } diff --git a/test/llms.test.js b/test/llms.test.js index abd2a19..a782446 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -815,3 +815,24 @@ test(':type=code includes go in a fence with a longer fence when needed', () => assert.match(full, /````js\nconst sample = "```";\nconsole.log\('hi'\);\n````/); assert.match(full, /```txt\nno ticks\n```/); }); + +test('other :type values and :fragment includes stay links', () => { + const dir = makeDocs({ + 'README.md': [ + '# Home', + '', + '[frag](guide.md \':include :fragment=part\')', + '', + '[other](guide.md \':include :type=html\')', + '', + '[notes](notes.txt \':include\')', + ].join('\n'), + 'guide.md': '# Guide \n\nNope.\n', + 'notes.txt': 'plain\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.equal(full.includes('Nope.'), false); + assert.match(full, /\[frag\]\(https:\/\/example\.com\/docs\/guide\.md ':include :fragment=part'\)/); + assert.match(full, /\[other\]\(https:\/\/example\.com\/docs\/guide\.md ':include :type=html'\)/); + assert.match(full, /\[notes\]\(https:\/\/example\.com\/docs\/notes\.txt ':include'\)/); +}); From e30f321d81ae3d53d75ddf7b389213f7dab9dcbc Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:27 -0400 Subject: [PATCH 097/133] feat: HTML comments are stripped outside code, in inlined content, and across lines --- src/llms.js | 4 ++-- test/llms.test.js | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/llms.js b/src/llms.js index f2b3f74..de67ea0 100644 --- a/src/llms.js +++ b/src/llms.js @@ -572,7 +572,7 @@ function renderInclude(link, ctx) { if (mode === 'code') { return codeFence(readText(resolved.abs), path.posix.extname(resolved.rel).slice(1)); } - const included = readText(resolved.abs); + const included = stripIgnoreMarkers(readText(resolved.abs)).replace(//g, ''); return transform(included, { ...ctx, inlined: true }); } @@ -604,7 +604,7 @@ function transform(text, ctx) { } function processBody(page, site, options) { - const text = stripFrontMatterAndFirstH1(readText(page.abs)); + const text = stripIgnoreMarkers(stripFrontMatterAndFirstH1(readText(page.abs))).replace(//g, ''); return transform(text, { docsDir: site.docsDir, baseUrl: site.baseUrl, diff --git a/test/llms.test.js b/test/llms.test.js index a782446..f113aa4 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -836,3 +836,15 @@ test('other :type values and :fragment includes stay links', () => { assert.match(full, /\[other\]\(https:\/\/example\.com\/docs\/guide\.md ':include :type=html'\)/); assert.match(full, /\[notes\]\(https:\/\/example\.com\/docs\/notes\.txt ':include'\)/); }); + +test('HTML comments are stripped outside code, in inlined content, and across lines', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\nHello world.\n\n\n\n[part](_parts/part.md \':include\')\n', + '_parts/part.md': 'Before after.\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.equal(full.includes('hidden'), false); + assert.equal(full.includes('multi'), false); + assert.equal(full.includes('secret'), false); + assert.match(full, /Before {2}after\./); +}); From 194b783e7ede49c1ae96a2b152fcc895125d8226 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:27 -0400 Subject: [PATCH 098/133] feat: comments inside fenced code and inline code stay --- src/llms.js | 5 +++-- test/llms.test.js | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/llms.js b/src/llms.js index de67ea0..0a508b9 100644 --- a/src/llms.js +++ b/src/llms.js @@ -572,7 +572,7 @@ function renderInclude(link, ctx) { if (mode === 'code') { return codeFence(readText(resolved.abs), path.posix.extname(resolved.rel).slice(1)); } - const included = stripIgnoreMarkers(readText(resolved.abs)).replace(//g, ''); + const included = readText(resolved.abs); return transform(included, { ...ctx, inlined: true }); } @@ -594,6 +594,7 @@ function expandIncludes(text, ctx) { function transformPlain(text, ctx) { let next = stripIgnoreMarkers(text); + next = next.replace(//g, ''); if (!ctx.inlined) next = expandIncludes(next, ctx); return outsideCode(next, (value) => rewritePlain(value, ctx)); } @@ -604,7 +605,7 @@ function transform(text, ctx) { } function processBody(page, site, options) { - const text = stripIgnoreMarkers(stripFrontMatterAndFirstH1(readText(page.abs))).replace(//g, ''); + const text = stripFrontMatterAndFirstH1(readText(page.abs)); return transform(text, { docsDir: site.docsDir, baseUrl: site.baseUrl, diff --git a/test/llms.test.js b/test/llms.test.js index f113aa4..f4521ff 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -848,3 +848,12 @@ test('HTML comments are stripped outside code, in inlined content, and across li assert.equal(full.includes('secret'), false); assert.match(full, /Before {2}after\./); }); + +test('comments inside fenced code and inline code stay', () => { + const dir = makeDocs({ + 'README.md': '# Home\n\n``\n\n```\n\n```\n', + }); + const full = renderLlmsFull(siteOf(dir)); + assert.match(full, /``/); + assert.match(full, //); +}); From bba55434d38ca506e614e51bc29d9363ea3aa3d4 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:28 -0400 Subject: [PATCH 099/133] feat: --keep-comments keeps comments and still strips ignore markers --- src/llms.js | 2 +- test/llms.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/llms.js b/src/llms.js index 0a508b9..c3a794d 100644 --- a/src/llms.js +++ b/src/llms.js @@ -594,7 +594,7 @@ function expandIncludes(text, ctx) { function transformPlain(text, ctx) { let next = stripIgnoreMarkers(text); - next = next.replace(//g, ''); + if (!ctx.keepComments) next = next.replace(//g, ''); if (!ctx.inlined) next = expandIncludes(next, ctx); return outsideCode(next, (value) => rewritePlain(value, ctx)); } diff --git a/test/llms.test.js b/test/llms.test.js index f4521ff..897b833 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -857,3 +857,12 @@ test('comments inside fenced code and inline code stay', () => { assert.match(full, /``/); assert.match(full, //); }); + +test('--keep-comments keeps comments and still strips ignore markers', () => { + const dir = makeDocs({ + 'README.md': '# Home \n\nHello world.\n', + }); + const full = renderLlmsFull(siteOf(dir), { keepComments: true }); + assert.match(full, //); + assert.equal(full.includes('docsify-ignore'), false); +}); From 9c698090df5c8f224bd65d4d2e25fa940f51a96b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:28 -0400 Subject: [PATCH 100/133] feat: a commented-out include is not expanded --- src/llms.js | 8 ++++++++ test/llms.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/llms.js b/src/llms.js index c3a794d..545d3c6 100644 --- a/src/llms.js +++ b/src/llms.js @@ -580,7 +580,15 @@ function expandIncludes(text, ctx) { let out = ''; let i = 0; while (i < text.length) { + const commentAt = text.indexOf('', commentAt + 4); + const stop = end === -1 ? text.length : end + 3; + out += text.slice(i, stop); + i = stop; + continue; + } if (!link) { out += text.slice(i); break; diff --git a/test/llms.test.js b/test/llms.test.js index 897b833..f11fbb7 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -866,3 +866,28 @@ test('--keep-comments keeps comments and still strips ignore markers', () => { assert.match(full, //); assert.equal(full.includes('docsify-ignore'), false); }); + +test('a commented-out include is not expanded', () => { + const dir = makeDocs({ + 'README.md': [ + '# Home', + '', + '', + '', + 'Visible.', + '', + '/); + assert.match(stripped, /Visible\./); +}); From 89aa26706b5deb3be22217c32f25ea23c48eb80b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:29 -0400 Subject: [PATCH 101/133] test: llms-full.txt matches test/fixtures/site.llms-full.txt --- test/fixtures/site.llms-full.txt | 89 ++++++++++++++++++++++++++++++++ test/llms.test.js | 15 ++++++ 2 files changed, 104 insertions(+) create mode 100644 test/fixtures/site.llms-full.txt diff --git a/test/fixtures/site.llms-full.txt b/test/fixtures/site.llms-full.txt new file mode 100644 index 0000000..295abcb --- /dev/null +++ b/test/fixtures/site.llms-full.txt @@ -0,0 +1,89 @@ +# Toc Site +Source: https://example.com/docs/README.md + +[![npm](https://img.shields.io/npm/v/tocsify.svg)](https://www.npmjs.com/package/tocsify) + +This is the home page. It explains the tool. + +# TOC + +See the contents. + +# About +Source: https://example.com/docs/about.md + +About this project. More words here. + +# Comments +Source: https://example.com/docs/comments.md + +Hello world. + + + +`` + +``` + +``` + +Text after. + +# Samples +Source: https://example.com/docs/samples.md + +````js +const sample = "```"; +console.log(sample); +```` + +[remote](https://example.com/a.md ':include') + +[secure](http://example.com/b.md ':include') + +[frag](https://example.com/docs/guide/setup.md ':include :fragment=part') + +[other](https://example.com/docs/guide/setup.md ':include :type=html') + +[notes](https://example.com/docs/notes.txt ':include') + +# Part +Source: https://example.com/docs/guide/parts/part.md + +A part of the guide. + +![inner](https://example.com/docs/guide/parts/pic.png) + +[up](https://example.com/setup.md) + +# Setup +Source: https://example.com/docs/guide/setup.md + +Use the guide for install. + +See [part](https://example.com/docs/guide/parts/part.md) and ![pic](https://example.com/docs/guide/img/a.png). + +b + +more + +ext + +mail + +local + +cdn + +[root](https://example.com/docs/top.md) + +Read [Advanced setup][adv], see ![diagram][diagram], and check [notes](https://example.com/docs/release%20notes.md), FAQ, [pdf](https://example.com/docs/files/guide.pdf), [about](https://example.com/docs/about.md), and [guide home](https://example.com/docs/README.md). + +`[not a link](skip.md)` + +``` +[not](skip2.md) +``` + +[adv]: https://example.com/docs/advanced.md +[diagram]: https://example.com/docs/guide/img/diagram.png diff --git a/test/llms.test.js b/test/llms.test.js index f11fbb7..04d7385 100644 --- a/test/llms.test.js +++ b/test/llms.test.js @@ -891,3 +891,18 @@ test('a commented-out include is not expanded', () => { assert.match(kept, //); assert.match(stripped, /Visible\./); }); + +test('llms-full.txt matches test/fixtures/site.llms-full.txt', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-golden-full-')); + fs.cpSync(siteDir, dir, { recursive: true }); + const site = buildSite({ + docsDir: dir, + baseUrl: 'https://example.com/docs/', + exclude: [path.join(dir, 'toc.md')], + cwd: dir, + }); + assert.equal( + renderLlmsFull(site), + fs.readFileSync(path.join(root, 'test/fixtures/site.llms-full.txt'), 'utf8'), + ); +}); From ca59702d3ee1bb7adbe7a9928fa7829ace157e8b Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:29 -0400 Subject: [PATCH 102/133] feat: --help and -h print usage with maxdepth default 6 --- src/cli.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ test/cli.test.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/cli.js create mode 100644 test/cli.test.js diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..bef2ecd --- /dev/null +++ b/src/cli.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +'use strict'; + +const HELP = `Usage + $ tocsify [dir] [options] + + dir defaults to docs. + +Options + --file, -f Path of the table of contents. Default: docs/toc.md + --maxdepth, -m Deepest heading level, 1 to 6. Default: 6 + --no-header Do not prefix page links with ###. + --verbose, -v Also print the table of contents to stdout. + --no-llm Do not write llms.txt and llms-full.txt. + --base-url Site URL for links in the llms files. Detected if not set. + --title Title for llms.txt. Default: H1 of the home page + --summary Summary for llms.txt. Default: first paragraph of the home page + --group h2 or sidebar. Default: h2 + --keep-comments Keep HTML comments in llms-full.txt. + --help, -h Show this help. + --version Show the version. + +Examples + $ tocsify docs + $ tocsify docs --maxdepth=2 --no-header + $ tocsify docs --base-url=https://example.com/docs/ + $ tocsify docs --no-llm +`; + +function run(argv, { stdout }) { + if (argv.includes('--help') || argv.includes('-h')) { + stdout.write(HELP); + return 0; + } + return 0; +} + +if (require.main === module) { + process.exitCode = run(process.argv.slice(2), { + cwd: process.cwd(), + env: process.env, + stdout: process.stdout, + stderr: process.stderr, + }); +} +module.exports = { run, HELP }; diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..780c446 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const { HELP } = require('../src/cli'); + +const root = path.join(__dirname, '..'); +const cliPath = path.join(root, 'src/cli.js'); + +function makeDocs(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-cli-')); + for (const [rel, text] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, text); + } + return dir; +} + +function runCli(args, cwd, extraEnv = {}) { + const env = { ...process.env, ...extraEnv }; + delete env.GITHUB_REPOSITORY; + if (Object.prototype.hasOwnProperty.call(extraEnv, 'GITHUB_REPOSITORY')) { + env.GITHUB_REPOSITORY = extraEnv.GITHUB_REPOSITORY; + } + return spawnSync(process.execPath, [cliPath, ...args], { + cwd, + env, + encoding: 'utf8', + }); +} + +test('--help and -h print usage with maxdepth default 6', () => { + const dir = makeDocs({}); + const long = runCli(['--help'], dir); + const short = runCli(['-h'], dir); + assert.equal(long.status, 0); + assert.equal(short.status, 0); + assert.equal(long.stdout, HELP); + assert.equal(short.stdout, HELP); + assert.match(HELP, /Default: 6/); + assert.equal(fs.existsSync(path.join(dir, 'docs/toc.md')), false); +}); From 73184ec3e46fab764f4773c50d60a2a70d525944 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:30 -0400 Subject: [PATCH 103/133] feat: --version prints the package.json version --- src/cli.js | 7 +++++++ test/cli.test.js | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/cli.js b/src/cli.js index bef2ecd..fbb880f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -27,11 +27,18 @@ Examples $ tocsify docs --no-llm `; +const fs = require('fs'); +const path = require('path'); function run(argv, { stdout }) { if (argv.includes('--help') || argv.includes('-h')) { stdout.write(HELP); return 0; } + if (argv.includes('--version')) { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); + stdout.write(`${pkg.version}\n`); + return 0; + } return 0; } diff --git a/test/cli.test.js b/test/cli.test.js index 780c446..10d3aef 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -45,3 +45,11 @@ test('--help and -h print usage with maxdepth default 6', () => { assert.match(HELP, /Default: 6/); assert.equal(fs.existsSync(path.join(dir, 'docs/toc.md')), false); }); + +test('--version prints the package.json version', () => { + const dir = makeDocs({}); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const result = runCli(['--version'], dir); + assert.equal(result.status, 0); + assert.equal(result.stdout, `${pkg.version}\n`); +}); From f10d6f0c96626aa5a088d1be23fde413819fb2df Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:31 -0400 Subject: [PATCH 104/133] feat: a default run writes toc.md, llms.txt, and llms-full.txt --- src/cli.js | 112 ++++++++++++++++++++++++++++++++++++++++++++--- test/cli.test.js | 9 ++++ 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/cli.js b/src/cli.js index fbb880f..413dd20 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,6 +1,13 @@ #!/usr/bin/env node 'use strict'; +const fs = require('fs'); +const path = require('path'); +const { parseArgs } = require('util'); +const { listPages } = require('./files'); +const { renderToc } = require('./tocgen'); +const { resolveBaseUrl, buildSite, renderLlmsTxt, renderLlmsFull } = require('./llms'); + const HELP = `Usage $ tocsify [dir] [options] @@ -27,18 +34,112 @@ Examples $ tocsify docs --no-llm `; -const fs = require('fs'); -const path = require('path'); -function run(argv, { stdout }) { - if (argv.includes('--help') || argv.includes('-h')) { +const OPTIONS = { + file: { type: 'string', short: 'f', default: 'docs/toc.md' }, + maxdepth: { type: 'string', short: 'm', default: '6' }, + header: { type: 'boolean', default: true }, + verbose: { type: 'boolean', short: 'v', default: false }, + llm: { type: 'boolean', default: true }, + 'base-url': { type: 'string' }, + title: { type: 'string' }, + summary: { type: 'string' }, + group: { type: 'string', default: 'h2' }, + 'keep-comments': { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + version: { type: 'boolean', default: false }, +}; + +function rewriteHeaderArgs(argv) { + return argv.map((arg) => { + if (arg === '--header=false') return '--no-header'; + if (arg === '--header=true') return '--header'; + return arg; + }); +} + +function run(argv, { + cwd, env, stdout, stderr, +}) { + let parsed; + try { + parsed = parseArgs({ + args: argv, + strict: true, + allowNegative: true, + allowPositionals: true, + options: OPTIONS, + }); + } catch (err) { + stderr.write(`[tocsify] error: ${err.message}\n`); + return 1; + } + if (parsed.values.help) { stdout.write(HELP); return 0; } - if (argv.includes('--version')) { + if (parsed.values.version) { const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); stdout.write(`${pkg.version}\n`); return 0; } + const dirArg = parsed.positionals[0] || 'docs'; + const docsDir = path.resolve(cwd, dirArg); + const fileAbs = path.resolve(cwd, parsed.values.file); + const warnings = []; + const onWarn = (message) => warnings.push(message); + let toc; + let llms = null; + let full = null; + try { + const pages = listPages(docsDir, { exclude: [fileAbs] }); + toc = renderToc(pages, { + maxdepth: Number(parsed.values.maxdepth), + header: parsed.values.header, + }); + if (true) { + const baseUrl = resolveBaseUrl({ + flag: parsed.values['base-url'], + docsDir, + cwd, + env, + }); + const site = buildSite({ + docsDir, + baseUrl, + exclude: [fileAbs], + group: parsed.values.group, + title: parsed.values.title, + summary: parsed.values.summary, + onWarn, + cwd, + }); + llms = renderLlmsTxt(site); + full = renderLlmsFull(site, { + keepComments: parsed.values['keep-comments'], + onWarn, + }); + } + } catch (err) { + stderr.write(`[tocsify] error: ${err.message}\n`); + return 1; + } + const writes = [[fileAbs, toc]]; + if (llms !== null) { + writes.push([path.join(docsDir, 'llms.txt'), llms]); + writes.push([path.join(docsDir, 'llms-full.txt'), full]); + } + const written = []; + try { + for (const [abs, data] of writes) { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, data); + written.push(abs); + } + } catch (err) { + stderr.write(`[tocsify] error: ${err.message}\n`); + return 1; + } + const names = writes.map(([abs]) => path.relative(cwd, abs).split(path.sep).join('/')); return 0; } @@ -50,4 +151,5 @@ if (require.main === module) { stderr: process.stderr, }); } + module.exports = { run, HELP }; diff --git a/test/cli.test.js b/test/cli.test.js index 10d3aef..cad6acd 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -53,3 +53,12 @@ test('--version prints the package.json version', () => { assert.equal(result.status, 0); assert.equal(result.stdout, `${pkg.version}\n`); }); + +test('a default run writes toc.md, llms.txt, and llms-full.txt', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n', 'docs/README.md': '# Home\n\nWelcome.\n' }); + const result = runCli(['--base-url=https://example.com/docs/'], dir); + assert.equal(result.status, 0); + assert.equal(fs.existsSync(path.join(dir, 'docs/toc.md')), true); + assert.equal(fs.existsSync(path.join(dir, 'docs/llms.txt')), true); + assert.equal(fs.existsSync(path.join(dir, 'docs/llms-full.txt')), true); +}); From f75f7c54cd15c988d9c71ef412924f460365b998 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:32 -0400 Subject: [PATCH 105/133] feat: --no-llm writes only toc.md and needs no base URL --- src/cli.js | 2 +- test/cli.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli.js b/src/cli.js index 413dd20..79a7384 100644 --- a/src/cli.js +++ b/src/cli.js @@ -96,7 +96,7 @@ function run(argv, { maxdepth: Number(parsed.values.maxdepth), header: parsed.values.header, }); - if (true) { + if (parsed.values.llm !== false) { const baseUrl = resolveBaseUrl({ flag: parsed.values['base-url'], docsDir, diff --git a/test/cli.test.js b/test/cli.test.js index cad6acd..628de37 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -62,3 +62,12 @@ test('a default run writes toc.md, llms.txt, and llms-full.txt', () => { assert.equal(fs.existsSync(path.join(dir, 'docs/llms.txt')), true); assert.equal(fs.existsSync(path.join(dir, 'docs/llms-full.txt')), true); }); + +test('--no-llm writes only toc.md and needs no base URL', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n' }); + const result = runCli(['--no-llm'], dir); + assert.equal(result.status, 0); + assert.equal(fs.existsSync(path.join(dir, 'docs/toc.md')), true); + assert.equal(fs.existsSync(path.join(dir, 'docs/llms.txt')), false); + assert.equal(fs.existsSync(path.join(dir, 'docs/llms-full.txt')), false); +}); From 255177d958e8f982350dc6f63f792a4df81faa8a Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:33 -0400 Subject: [PATCH 106/133] feat: dir defaults to docs and an extra positional fails --- src/cli.js | 4 ++++ test/cli.test.js | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/cli.js b/src/cli.js index 79a7384..21abdec 100644 --- a/src/cli.js +++ b/src/cli.js @@ -82,6 +82,10 @@ function run(argv, { stdout.write(`${pkg.version}\n`); return 0; } + if (parsed.positionals.length > 1) { + stderr.write('[tocsify] error: too many arguments\n'); + return 1; + } const dirArg = parsed.positionals[0] || 'docs'; const docsDir = path.resolve(cwd, dirArg); const fileAbs = path.resolve(cwd, parsed.values.file); diff --git a/test/cli.test.js b/test/cli.test.js index 628de37..b9f299d 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -71,3 +71,13 @@ test('--no-llm writes only toc.md and needs no base URL', () => { assert.equal(fs.existsSync(path.join(dir, 'docs/llms.txt')), false); assert.equal(fs.existsSync(path.join(dir, 'docs/llms-full.txt')), false); }); + +test('dir defaults to docs and an extra positional fails', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n' }); + const ok = runCli(['--no-llm'], dir); + assert.equal(ok.status, 0); + assert.equal(fs.existsSync(path.join(dir, 'docs/toc.md')), true); + const bad = runCli(['docs', 'extra', '--no-llm'], dir); + assert.equal(bad.status, 1); + assert.match(bad.stderr, /\[tocsify\] error: too many arguments/); +}); From 701fac37637e6e4a268a404b52e2f67774477574 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:33 -0400 Subject: [PATCH 107/133] test: --file and -f set the toc path --- test/cli.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/cli.test.js b/test/cli.test.js index b9f299d..d7df958 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -81,3 +81,13 @@ test('dir defaults to docs and an extra positional fails', () => { assert.equal(bad.status, 1); assert.match(bad.stderr, /\[tocsify\] error: too many arguments/); }); + +test('--file and -f set the toc path', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n' }); + const result = runCli(['--no-llm', '--file', 'out/toc.md'], dir); + assert.equal(result.status, 0); + assert.equal(fs.existsSync(path.join(dir, 'out/toc.md')), true); + const short = runCli(['--no-llm', '-f', 'out/other.md'], dir); + assert.equal(short.status, 0); + assert.equal(fs.existsSync(path.join(dir, 'out/other.md')), true); +}); From 8064449528b93fa2b9a66cace1ba85356231d7ad Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:35 -0400 Subject: [PATCH 108/133] feat: --maxdepth and -m set the depth and reject 0, 7, and 2.5 --- src/cli.js | 4 ++++ test/cli.test.js | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/cli.js b/src/cli.js index 21abdec..4501536 100644 --- a/src/cli.js +++ b/src/cli.js @@ -88,6 +88,10 @@ function run(argv, { } const dirArg = parsed.positionals[0] || 'docs'; const docsDir = path.resolve(cwd, dirArg); + if (!/^[1-6]$/.test(parsed.values.maxdepth)) { + stderr.write('[tocsify] error: --maxdepth must be an integer from 1 to 6\n'); + return 1; + } const fileAbs = path.resolve(cwd, parsed.values.file); const warnings = []; const onWarn = (message) => warnings.push(message); diff --git a/test/cli.test.js b/test/cli.test.js index d7df958..2afebaa 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -91,3 +91,20 @@ test('--file and -f set the toc path', () => { assert.equal(short.status, 0); assert.equal(fs.existsSync(path.join(dir, 'out/other.md')), true); }); + +test('--maxdepth and -m set the depth and reject 0, 7, and 2.5', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n## Two\n### Three\n' }); + const result = runCli(['--no-llm', '--maxdepth=2'], dir); + assert.equal(result.status, 0); + const toc = fs.readFileSync(path.join(dir, 'docs/toc.md'), 'utf8'); + assert.equal(toc.includes('Three'), false); + assert.equal(toc.includes('Two'), true); + const short = runCli(['--no-llm', '-m', '1', '--file=docs/toc-m.md'], dir); + assert.equal(short.status, 0); + assert.equal(fs.readFileSync(path.join(dir, 'docs/toc-m.md'), 'utf8').includes('Two'), false); + for (const value of ['0', '7', '2.5']) { + const bad = runCli(['--no-llm', `--maxdepth=${value}`], dir); + assert.equal(bad.status, 1); + assert.match(bad.stderr, /--maxdepth must be an integer from 1 to 6/); + } +}); From f38145420dfd456ef4cda306f1c08d6a4e5d854d Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:36 -0400 Subject: [PATCH 109/133] feat: --no-header and --header=false drop ### and --header=true keeps it --- src/cli.js | 2 +- test/cli.test.js | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cli.js b/src/cli.js index 4501536..9c41786 100644 --- a/src/cli.js +++ b/src/cli.js @@ -63,7 +63,7 @@ function run(argv, { let parsed; try { parsed = parseArgs({ - args: argv, + args: rewriteHeaderArgs(argv), strict: true, allowNegative: true, allowPositionals: true, diff --git a/test/cli.test.js b/test/cli.test.js index 2afebaa..a4b8a40 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -108,3 +108,16 @@ test('--maxdepth and -m set the depth and reject 0, 7, and 2.5', () => { assert.match(bad.stderr, /--maxdepth must be an integer from 1 to 6/); } }); + +test('--no-header and --header=false drop ### and --header=true keeps it', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n' }); + const off = runCli(['--no-llm', '--no-header', '--file=off.md'], dir); + assert.equal(off.status, 0); + assert.equal(fs.readFileSync(path.join(dir, 'off.md'), 'utf8').includes('### '), false); + const falseFlag = runCli(['--no-llm', '--header=false', '--file=false.md'], dir); + assert.equal(falseFlag.status, 0); + assert.equal(fs.readFileSync(path.join(dir, 'false.md'), 'utf8').includes('### '), false); + const on = runCli(['--no-llm', '--header=true', '--file=on.md'], dir); + assert.equal(on.status, 0); + assert.match(fs.readFileSync(path.join(dir, 'on.md'), 'utf8'), /^### \[guide\]\(guide\.md\)/m); +}); From 57e672dbb0b3068e87470f65878a445538626691 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:38 -0400 Subject: [PATCH 110/133] feat: --verbose and -v print the toc to stdout --- src/cli.js | 1 + test/cli.test.js | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/src/cli.js b/src/cli.js index 9c41786..d6a264e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -147,6 +147,7 @@ function run(argv, { stderr.write(`[tocsify] error: ${err.message}\n`); return 1; } + if (parsed.values.verbose) stdout.write(toc); const names = writes.map(([abs]) => path.relative(cwd, abs).split(path.sep).join('/')); return 0; } diff --git a/test/cli.test.js b/test/cli.test.js index a4b8a40..adc90dd 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -121,3 +121,13 @@ test('--no-header and --header=false drop ### and --header=true keeps it', () => assert.equal(on.status, 0); assert.match(fs.readFileSync(path.join(dir, 'on.md'), 'utf8'), /^### \[guide\]\(guide\.md\)/m); }); + +test('--verbose and -v print the toc to stdout', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n' }); + const result = runCli(['--no-llm', '--verbose'], dir); + assert.equal(result.status, 0); + assert.match(result.stdout, /\[guide\]\(guide\.md\)/); + const short = runCli(['--no-llm', '-v', '--file=v.md'], dir); + assert.equal(short.status, 0); + assert.match(short.stdout, /\[guide\]\(guide\.md\)/); +}); From 5e2bad34bfd29a319ba3ff47a277e7102ffdc5bd Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:38 -0400 Subject: [PATCH 111/133] test: an unknown flag exits 1 --- test/cli.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/cli.test.js b/test/cli.test.js index adc90dd..9c31fa2 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -131,3 +131,10 @@ test('--verbose and -v print the toc to stdout', () => { assert.equal(short.status, 0); assert.match(short.stdout, /\[guide\]\(guide\.md\)/); }); + +test('an unknown flag exits 1', () => { + const dir = makeDocs({}); + const result = runCli(['--bogus'], dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /\[tocsify\] error:/); +}); From 0322db5187001945544b4cdf4e44db33ddff0083 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:40 -0400 Subject: [PATCH 112/133] feat: a missing docs folder exits 1 --- src/cli.js | 4 ++++ test/cli.test.js | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/cli.js b/src/cli.js index d6a264e..84ef150 100644 --- a/src/cli.js +++ b/src/cli.js @@ -88,6 +88,10 @@ function run(argv, { } const dirArg = parsed.positionals[0] || 'docs'; const docsDir = path.resolve(cwd, dirArg); + if (!fs.existsSync(docsDir) || !fs.statSync(docsDir).isDirectory()) { + stderr.write(`[tocsify] error: docs folder not found: ${dirArg}\n`); + return 1; + } if (!/^[1-6]$/.test(parsed.values.maxdepth)) { stderr.write('[tocsify] error: --maxdepth must be an integer from 1 to 6\n'); return 1; diff --git a/test/cli.test.js b/test/cli.test.js index 9c31fa2..542adda 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -138,3 +138,14 @@ test('an unknown flag exits 1', () => { assert.equal(result.status, 1); assert.match(result.stderr, /\[tocsify\] error:/); }); + +test('a missing docs folder exits 1', () => { + const dir = makeDocs({}); + const missing = runCli(['missing', '--no-llm'], dir); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /docs folder not found: missing/); + fs.writeFileSync(path.join(dir, 'not-a-dir'), 'x'); + const file = runCli(['not-a-dir', '--no-llm'], dir); + assert.equal(file.status, 1); + assert.match(file.stderr, /docs folder not found: not-a-dir/); +}); From bf2cffb455a9e906f740d8b062285d75b5613155 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:40 -0400 Subject: [PATCH 113/133] test: a missing base URL exits 1 and names --base-url --- test/cli.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/cli.test.js b/test/cli.test.js index 542adda..e7ae476 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -149,3 +149,14 @@ test('a missing docs folder exits 1', () => { assert.equal(file.status, 1); assert.match(file.stderr, /docs folder not found: not-a-dir/); }); + +test('a missing base URL exits 1 and names --base-url', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n' }); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'tocsify-cli-none-')); + fs.cpSync(path.join(dir, 'docs'), path.join(outside, 'docs'), { recursive: true }); + const result = runCli(['docs'], outside); + assert.equal(result.status, 1); + assert.match(result.stderr, /\[tocsify\] error: could not detect the site URL/); + assert.match(result.stderr, /--base-url/); + assert.equal(fs.existsSync(path.join(outside, 'docs/toc.md')), false); +}); From f89d30f03943ca3b4afdbbb5407bfab31518be7c Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:42 -0400 Subject: [PATCH 114/133] feat: a bad --group value exits 1 --- src/cli.js | 4 ++++ test/cli.test.js | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/cli.js b/src/cli.js index 84ef150..0fde9e6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -92,6 +92,10 @@ function run(argv, { stderr.write(`[tocsify] error: docs folder not found: ${dirArg}\n`); return 1; } + if (parsed.values.group !== 'h2' && parsed.values.group !== 'sidebar') { + stderr.write('[tocsify] error: --group must be h2 or sidebar\n'); + return 1; + } if (!/^[1-6]$/.test(parsed.values.maxdepth)) { stderr.write('[tocsify] error: --maxdepth must be an integer from 1 to 6\n'); return 1; diff --git a/test/cli.test.js b/test/cli.test.js index e7ae476..c1f8f07 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -160,3 +160,10 @@ test('a missing base URL exits 1 and names --base-url', () => { assert.match(result.stderr, /--base-url/); assert.equal(fs.existsSync(path.join(outside, 'docs/toc.md')), false); }); + +test('a bad --group value exits 1', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n' }); + const result = runCli(['--group=nope', '--no-llm'], dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /--group must be h2 or sidebar/); +}); From 975e25cf0f9d407e7eebbb2b29b6930df6209dbc Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:44 -0400 Subject: [PATCH 115/133] feat: warnings go to stderr and the run exits 0 --- src/cli.js | 1 + test/cli.test.js | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/src/cli.js b/src/cli.js index 0fde9e6..a867db1 100644 --- a/src/cli.js +++ b/src/cli.js @@ -157,6 +157,7 @@ function run(argv, { } if (parsed.values.verbose) stdout.write(toc); const names = writes.map(([abs]) => path.relative(cwd, abs).split(path.sep).join('/')); + for (const message of warnings) stderr.write(`[tocsify] warning: ${message}\n`); return 0; } diff --git a/test/cli.test.js b/test/cli.test.js index c1f8f07..16f8540 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -167,3 +167,13 @@ test('a bad --group value exits 1', () => { assert.equal(result.status, 1); assert.match(result.stderr, /--group must be h2 or sidebar/); }); + +test('warnings go to stderr and the run exits 0', () => { + const dir = makeDocs({ + 'docs/README.md': '# Home\n\n[missing](missing.md \':include\')\n', + }); + const result = runCli(['--base-url=https://example.com/docs/'], dir); + assert.equal(result.status, 0); + assert.match(result.stderr, /\[tocsify\] warning: missing include: missing.md/); + assert.equal(fs.existsSync(path.join(dir, 'docs/llms-full.txt')), true); +}); From 20744c51a3f2e1bd82690ac0630defc0b90a864f Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:44 -0400 Subject: [PATCH 116/133] test: git remote detection works in a temp git repo --- test/cli.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/cli.test.js b/test/cli.test.js index 16f8540..0021bce 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -177,3 +177,18 @@ test('warnings go to stderr and the run exits 0', () => { assert.match(result.stderr, /\[tocsify\] warning: missing include: missing.md/); assert.equal(fs.existsSync(path.join(dir, 'docs/llms-full.txt')), true); }); + +test('git remote detection works in a temp git repo', () => { + const dir = makeDocs({ 'docs/README.md': '# Repo\n\nHello.\n' }); + const init = spawnSync('git', ['init'], { cwd: dir, encoding: 'utf8' }); + assert.equal(init.status, 0); + const remote = spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/Owner/Repo.git'], { + cwd: dir, + encoding: 'utf8', + }); + assert.equal(remote.status, 0); + const result = runCli(['docs'], dir); + assert.equal(result.status, 0, result.stderr); + const llms = fs.readFileSync(path.join(dir, 'docs/llms.txt'), 'utf8'); + assert.match(llms, /https:\/\/owner\.github\.io\/Repo\//); +}); From b31252d32b3156e9cd801d0b49c844ca99b8594a Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:46 -0400 Subject: [PATCH 117/133] feat: the success line names each written file --- src/cli.js | 1 + test/cli.test.js | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/cli.js b/src/cli.js index a867db1..1ff55d6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -157,6 +157,7 @@ function run(argv, { } if (parsed.values.verbose) stdout.write(toc); const names = writes.map(([abs]) => path.relative(cwd, abs).split(path.sep).join('/')); + stdout.write(`[tocsify] wrote ${names.join(', ')}\n`); for (const message of warnings) stderr.write(`[tocsify] warning: ${message}\n`); return 0; } diff --git a/test/cli.test.js b/test/cli.test.js index 0021bce..10549c4 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -192,3 +192,12 @@ test('git remote detection works in a temp git repo', () => { const llms = fs.readFileSync(path.join(dir, 'docs/llms.txt'), 'utf8'); assert.match(llms, /https:\/\/owner\.github\.io\/Repo\//); }); + +test('the success line names each written file', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n' }); + const full = runCli(['--base-url=https://example.com/docs/'], dir); + assert.equal(full.status, 0); + assert.match(full.stdout, /\[tocsify\] wrote docs\/toc.md, docs\/llms.txt, docs\/llms-full.txt\n$/); + const only = runCli(['--no-llm', '--file=docs/only.md'], dir); + assert.match(only.stdout, /\[tocsify\] wrote docs\/only.md\n$/); +}); From ca9a7ab9546f9c7b8026678a2c8ba9e59d7f774e Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 118/133] feat: a write failure exits 1 and does not keep a partial write --- src/cli.js | 9 +++++---- test/cli.test.js | 9 +++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/cli.js b/src/cli.js index 1ff55d6..2f8bc63 100644 --- a/src/cli.js +++ b/src/cli.js @@ -92,14 +92,14 @@ function run(argv, { stderr.write(`[tocsify] error: docs folder not found: ${dirArg}\n`); return 1; } - if (parsed.values.group !== 'h2' && parsed.values.group !== 'sidebar') { - stderr.write('[tocsify] error: --group must be h2 or sidebar\n'); - return 1; - } if (!/^[1-6]$/.test(parsed.values.maxdepth)) { stderr.write('[tocsify] error: --maxdepth must be an integer from 1 to 6\n'); return 1; } + if (parsed.values.group !== 'h2' && parsed.values.group !== 'sidebar') { + stderr.write('[tocsify] error: --group must be h2 or sidebar\n'); + return 1; + } const fileAbs = path.resolve(cwd, parsed.values.file); const warnings = []; const onWarn = (message) => warnings.push(message); @@ -152,6 +152,7 @@ function run(argv, { written.push(abs); } } catch (err) { + for (const abs of written) fs.unlinkSync(abs); stderr.write(`[tocsify] error: ${err.message}\n`); return 1; } diff --git a/test/cli.test.js b/test/cli.test.js index 10549c4..8dd6613 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -201,3 +201,12 @@ test('the success line names each written file', () => { const only = runCli(['--no-llm', '--file=docs/only.md'], dir); assert.match(only.stdout, /\[tocsify\] wrote docs\/only.md\n$/); }); + +test('a write failure exits 1 and does not keep a partial write', () => { + const dir = makeDocs({ 'docs/guide.md': '# Guide\n\nHello.\n' }); + fs.mkdirSync(path.join(dir, 'docs/llms.txt')); + const result = runCli(['--base-url=https://example.com/docs/'], dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /\[tocsify\] error:/); + assert.equal(fs.existsSync(path.join(dir, 'docs/toc.md')), false); +}); From 81811b2e5be876681a3f1558929868399df37fe4 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 119/133] feat: release tocsify 3.0.0 with no runtime dependencies --- package.json | 83 +++++++++++++++++----------------------------------- 1 file changed, 27 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 78bb31a..80e7e10 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,28 @@ { "name": "tocsify", - "version": "1.0.10", - "description": "Generates a table of contents based on the structure of your docsify docs directory!", + "version": "3.0.0", + "description": "Generates a table of contents and llms.txt files from a Docsify docs directory.", "author": "Dani Roxberry (https://droxey.com)", "license": "MIT", - "main": "./lib/index.js", - "scripts": { - "clean": "rimraf lib", - "test": "npm run lint && npm run cover", - "test:prod": "cross-env BABEL_ENV=production npm run test", - "test:only": "mocha --require babel-core/register --require babel-polyfill --recursive --exit", - "test:watch": "npm test -- --watch", - "cover": "istanbul cover _mocha -- --require babel-core/register --require babel-polyfill --recursive --exit", - "lint": "eslint src test", - "build": "cross-env BABEL_ENV=production babel src --out-dir lib", - "prepare": "npm run clean && npm run lint && npm run test:prod && npm run build" + "type": "commonjs", + "main": "./src/cli.js", + "bin": { + "tocsify": "./src/cli.js" + }, + "exports": { + ".": "./src/cli.js", + "./package.json": "./package.json" }, "files": [ - "lib", "src" ], - "bin": { - "tocsify": "./lib/index.js" + "engines": { + "node": ">=22.13.0" + }, + "scripts": { + "lint": "eslint .", + "test": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 \"test/*.test.js\"" }, - "keywords": [ - "tocsify", - "docsify", - "npm-package", - "npm-module", - "table-of-contents", - "toc", - "markdown", - "cli", - "table", - "documentation", - "README", - "index" - ], "repository": { "type": "git", "url": "git+https://github.com/droxey/tocsify.git" @@ -45,32 +31,17 @@ "url": "https://github.com/droxey/tocsify/issues" }, "homepage": "https://droxey.com/tocsify/#/index", - "dependencies": { - "glob": "^7.1.3", - "markdown-toc": "^1.2.0", - "meow": "^5.0.0" - }, + "keywords": [ + "tocsify", + "docsify", + "llms.txt", + "table-of-contents", + "markdown", + "cli" + ], "devDependencies": { - "babel-cli": "^6.26.0", - "babel-eslint": "^8.2.6", - "babel-plugin-add-module-exports": "^0.2.1", - "babel-polyfill": "^6.26.0", - "babel-preset-env": "^1.6.1", - "babel-preset-minify": "^0.3.0", - "chai": "^4.2.0", - "chai-files": "^1.4.0", - "chai-fs": "^2.0.0", - "cross-env": "^5.1.3", - "docsify-cli": "^4.2.1", - "eslint": "^5.14.1", - "eslint-config-airbnb": "^17.1.0", - "eslint-config-airbnb-base": "^13.1.0", - "eslint-plugin-import": "^2.14.0", - "eslint-plugin-jsx-a11y": "^6.1.1", - "eslint-plugin-react": "^7.12.4", - "eslint-watch": "^4.0.2", - "istanbul": "^1.0.0-alpha", - "mocha": "^5.2.0", - "rimraf": "^2.6.2" + "@eslint/js": "^10", + "eslint": "^10", + "globals": "^16.4.0" } } From 2b1c3ab5b7fc5091d49fe0934526778ea5ff9009 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 120/133] chore: drop the Babel toolchain and track the npm lockfile --- .babelrc | 16 ---------------- .eslintignore | 2 -- .eslintrc | 13 ------------- .gitignore | 4 ---- .npmignore | 28 --------------------------- .nvmrc | 2 +- .travis.yml | 10 ---------- src/index.js | 52 -------------------------------------------------- test/index.js | 53 --------------------------------------------------- 9 files changed, 1 insertion(+), 179 deletions(-) delete mode 100644 .babelrc delete mode 100644 .eslintignore delete mode 100644 .eslintrc delete mode 100644 .npmignore delete mode 100644 .travis.yml delete mode 100644 src/index.js delete mode 100644 test/index.js diff --git a/.babelrc b/.babelrc deleted file mode 100644 index ef749a2..0000000 --- a/.babelrc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "env": { - "development": { - "presets": ["env"], - "plugins": [ - "add-module-exports" - ] - }, - "production": { - "presets": ["env", "minify"], - "plugins": [ - "add-module-exports" - ] - } - } -} diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index d636432..0000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -coverage -lib diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 66cc62d..0000000 --- a/.eslintrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "parser": "babel-eslint", - "extends": "airbnb", - "env": { - "mocha": true - }, - "rules": { - "comma-dangle": ["error", "only-multiline"], - "consistent-return": "off", - "prefer-template": "off", - "prefer-destructuring": "off" - } -} diff --git a/.gitignore b/.gitignore index f879349..14454f9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,11 +33,7 @@ jspm_packages # Editors .idea -# Lib -lib - # npm package lock -package-lock.json yarn.lock # others diff --git a/.npmignore b/.npmignore deleted file mode 100644 index c638fdb..0000000 --- a/.npmignore +++ /dev/null @@ -1,28 +0,0 @@ -*.log -npm-debug.log* - -# Coverage directory used by tools like istanbul -coverage -.nyc_output - -# Dependency directories -node_modules - -# npm package lock -package-lock.json -yarn.lock - -.vscode - -# project files -src -test -docs -CHANGELOG.md -.travis.yml -.editorconfig -.eslintignore -.eslintrc -.babelrc -.gitignore -.nvmrc diff --git a/.nvmrc b/.nvmrc index d3a8b24..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v8.11.1 +22 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2da3f8d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: node_js -node_js: - - '10' - - '8' -script: - - npm run test - - npm run build -branches: - only: - - master diff --git a/src/index.js b/src/index.js deleted file mode 100644 index c4f2a7a..0000000 --- a/src/index.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node - -const meow = require('meow'); -const generate = require('../src/tocgen'); - -const cli = meow( - ` - Usage - $ tocsify docs --file=docs/toc.md - - Options - --verbose, -v Write output to stdout. - --file, -f Write output to file. - --header, -h Include a descriptive header. Default is true. - --maxdepth, -m Specify max level header(1 - 6) to output. Default is 3. - - Examples - $ tocsify docs --file=docs/toc.md - $ tocsify docs --verbose --file=docs/toc.md - $ tocsify docs --maxdepth=2 - $ tocsify docs --header=false -`, { - flags: { - verbose: { - type: 'boolean', - alias: 'v', - default: false - }, - header: { - type: 'boolean', - alias: 'h', - default: true - }, - file: { - type: 'string', - default: 'docs/toc.md', - alias: 'f' - }, - maxdepth: { - type: 'Number', - alias: 'm', - default: 6 - }, - - } - } -); - -generate(cli.input[0], cli.flags, () => { - process.stdout.write('\n[tocsify] table of contents generated!\n'); - process.exit(); -}); diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 3041bdd..0000000 --- a/test/index.js +++ /dev/null @@ -1,53 +0,0 @@ -const chai = require('chai'); -const chaiFiles = require('chai-files'); -const fs = require('fs'); -const generate = require('../src/tocgen'); - -const expect = chai.expect; -const file = chaiFiles.file; - -const testDir = './docs'; -const fileName = 'toc-test.md'; -const filePath = `${testDir}/${fileName}`; -const maxDepth = 3; -const header = false; - -chai.use(chaiFiles); - -describe('tocsify tests', () => { - before((done) => { - generate( - testDir, { - verbose: false, - v: false, - file: filePath, - f: filePath, - m: maxDepth, - maxdepth: maxDepth, - h: header, - headers: header - }, - () => { - done(); - } - ); - }); - - it('should generate a toc file', () => { - const watcher = fs.watch(testDir, (eventType, filename) => { - if (filename === fileName) { - expect(file(filePath)).to.exist(); - watcher.close(); - } - }); - }); - - it('should match the output of demo toc.md doc', () => { - const watcher = fs.watch(testDir, (eventType, filename) => { - if (filename === fileName) { - expect(file(filePath)).to.equal(file('./docs/toc.md')); - watcher.close(); - } - }); - }); -}); From 082b8b5e9b832fd34b182d066e9e78c90ca1540f Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 121/133] chore: lint with ESLint 10 flat config --- eslint.config.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..62c7f75 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,15 @@ +'use strict'; + +const js = require('@eslint/js'); +const globals = require('globals'); + +module.exports = [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2024, + sourceType: 'commonjs', + globals: globals.node, + }, + }, +]; From c33c21d8353ffde0d8a23b9f15b69e4e89054128 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 122/133] ci: test Node 22, 24, and 26, plus Windows --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f0618d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: ['22.13.0', '22.x', '24.x', '26.x'] + include: + - os: windows-latest + node: '24.x' + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test From 9832830d29052011a51b8bfc77e722c9545971f3 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 123/133] ci: publish 3.0.0 from a GitHub release with OIDC --- .github/workflows/release.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ebb5ce1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,23 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24.x + package-manager-cache: false + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm test + - run: npm publish From e665569cd699aee81a09deff8082637ed82b95aa Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 124/133] docs: add the 3.0.0 changelog --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3e3e65a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 3.0.0 + +### Breaking + +- Node `>=22.13.0`. +- `llms.txt` and `llms-full.txt` are written by default. `--no-llm` turns them off. +- A base URL is required unless tocsify can detect one. +- Anchors in `toc.md` now use Docsify 5 ids. This changes links for headings with leading digits, accents, emoji, `:id=`, links, or `#`. +- `-h` now means `--help` instead of `--header`. +- `bin` and `main` move to `src/cli.js`. `lib/` is gone. `exports` blocks deep imports. +- `dir` defaults to `docs`. + +### Fixes + +- The underscore skip rule. +- Exact `README.md` and `index.md` matching. +- Comment-form ignore markers. +- Help text that said `--maxdepth` defaults to 3. +- The demo loading Docsify 4.13.1. +- Tests that asserted nothing. + +### Removed + +- Every runtime dependency. This clears the markdown-toc and meow audit findings. From e275e2a6b1ebde21e0c5e65281adb70ce0870d70 Mon Sep 17 00:00:00 2001 From: Dani Roxberry Date: Thu, 8 Oct 2026 15:32:48 -0400 Subject: [PATCH 125/133] docs: describe llms.txt, the base URL, and Docsify 5 --- README.md | 137 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 105 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 4169578..4325938 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,126 @@ -# 📋 tocsify +# tocsify [![NPM version](https://img.shields.io/npm/v/tocsify.svg?style=flat)](https://www.npmjs.com/package/tocsify) [![NPM downloads](https://img.shields.io/npm/dm/tocsify.svg?style=flat)](https://npmjs.org/package/tocsify) -📋 npm module that **generates a table of contents** based on the **file structure** of a [Docsify](https://docsify.js.org) `docs` directory! +tocsify reads a Docsify 5 `docs` folder and writes three files: -## Features +- `docs/toc.md`, a table of contents +- `docs/llms.txt`, an index for LLMs +- `docs/llms-full.txt`, the full text of every page -* Adds relative path to title in each top-level entry for context. -* Skips markdown files beginning with `_`. -* Allows users to specify `maxdepth` for cleaner generation. -* Skips generation for headers marked `{docsify-ignore}` -* If `{docsify-ignore-all}` exists in a top level header (`# Example Header {docsify-ignore-all}`), skip generating the table of contents for the entire document. - -## Installation - -Install globally for use in any [Docsify](https://docsify.js.org) project! +## Install ```bash npm install -g tocsify ``` +Node.js `>=22.13.0` is required. + ## Usage -```bash - Usage - $ tocsify docs --file=docs/toc.md - - Options - --verbose, -v Write output to stdout. - --file, -f Write output to file. - --header, -h Include a descriptive header. Default is true. - --maxdepth, -m Specify max level header(1 - 6) to output. Default is 3. - - Examples - $ tocsify docs --file=docs/toc.md - $ tocsify docs --verbose --file=docs/toc.md - $ tocsify docs --maxdepth=2 - $ tocsify docs --header=false +```text +Usage + $ tocsify [dir] [options] + + dir defaults to docs. + +Options + --file, -f Path of the table of contents. Default: docs/toc.md + --maxdepth, -m Deepest heading level, 1 to 6. Default: 6 + --no-header Do not prefix page links with ###. + --verbose, -v Also print the table of contents to stdout. + --no-llm Do not write llms.txt and llms-full.txt. + --base-url Site URL for links in the llms files. Detected if not set. + --title Title for llms.txt. Default: H1 of the home page + --summary Summary for llms.txt. Default: first paragraph of the home page + --group h2 or sidebar. Default: h2 + --keep-comments Keep HTML comments in llms-full.txt. + --help, -h Show this help. + --version Show the version. + +Examples + $ tocsify docs + $ tocsify docs --maxdepth=2 --no-header + $ tocsify docs --base-url=https://example.com/docs/ + $ tocsify docs --no-llm +``` + +`-h` means `--help`. It used to mean `--header`. + +## Base URL + +`llms.txt` and `llms-full.txt` use absolute links. tocsify picks the first hit: + +1. `--base-url`, which must start with `http://` or `https://` +2. The first non-empty line of `/CNAME` +3. `GITHUB_REPOSITORY`, as `https://owner.github.io/repo/` +4. `git remote origin`, when it is a GitHub URL +5. `homepage` in `package.json` + +Pass `--base-url` when the docs folder is not the GitHub Pages root. `--no-llm` skips this step and writes only `toc.md`. + +In `llms-full.txt`, each relative link becomes the URL Docsify 5 loads with its default `relativePath: false`. Markdown links, ``, and `` resolve from the docs root. Markdown images resolve from the page folder. A link that starts with `/` resolves from the base URL, which is the docs root. + +## GitHub Actions + +Run tocsify on push to `main`, then commit the three files when they change. A push made with `GITHUB_TOKEN` does not start another workflow run. + +```yaml +name: Docs +on: + push: + branches: [main] +permissions: + contents: write +jobs: + llms: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24.x + - run: npx tocsify@3 docs + - run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/toc.md docs/llms.txt docs/llms-full.txt + git diff --cached --quiet || git commit -m "docs: update toc and llms files" + git push +``` + +## Docsify 5 + +Put these two links in ``: + +```html + + ``` -## Integration +Put a `