From fb3676ee3a73613ac435a04f349d5ba749dfa46d Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:12:52 +0900 Subject: [PATCH] Read a heading on the line above its link list as a heading and a list Expo's llms.txt writes each section heading on the line right above its list, with no blank line between. The heading and the list were then one paragraph, which is neither a lone heading nor a paragraph of links only, so it stayed verbatim: 282 of the file's 558 entries were text with no link to follow. A paragraph whose first line is a heading and whose other lines are a link list now renders as that heading and that list. A heading above anything else stays one verbatim block, and the raw modes are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/distill.js | 10 +++++++++- tests/text.test.js | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/distill.js b/src/distill.js index 1eaa2a3..e40ceb6 100644 --- a/src/distill.js +++ b/src/distill.js @@ -191,7 +191,15 @@ function textToHTML(text, url = '', { full = false, type = '' } = {}) { const parts = paragraphs.map((p) => { const level = heading(p); if (level) return `${escHTML(p.slice(level + 1))}`; - return (full ? null : linkList(p, url)) ?? `
${escHTML(p)}
`; + if (full) return `
${escHTML(p)}
`; + // Expo's llms.txt writes each section heading on the line right above + // its list, with no blank line between, so the two arrive as one + // paragraph. The heading line is then a heading and the rest a list. + const cut = p.indexOf('\n'); + const top = cut > 0 ? heading(p.slice(0, cut)) : 0; + const list = linkList(top ? p.slice(cut + 1) : p, url); + if (!list) return `
${escHTML(p)}
`; + return top ? `${escHTML(p.slice(top + 1, cut))}${list}` : list; }); // Only a heading that opens the file names it. A README that starts with // prose and has a `# More information` section further down is not titled diff --git a/tests/text.test.js b/tests/text.test.js index 3799bfa..05b589f 100644 --- a/tests/text.test.js +++ b/tests/text.test.js @@ -74,6 +74,26 @@ test('the reference shortcuts resolve, and a file path keeps its slashes', () => assert.equal(resolveSite('gh', ['releases', 'facebook', 'react']).url, 'https://github.com/facebook/react/releases'); }); +test('a heading on the line right above its link list is a heading and a list', () => { + // Expo's llms.txt has no blank line between a section heading and its + // list, so the pair was one paragraph, which the heading line kept from + // being a list: every link in the section stayed verbatim text. + const text = '# Expo documentation\n\n### AI agents\n' + + '- [Claude Code and Expo](https://docs.expo.dev/agents/claude.md)\n' + + '- [Codex and Expo](https://docs.expo.dev/agents/codex.md)\n\n' + + '### Notes\nPlain prose under a heading.\n- [Not a list](https://docs.expo.dev/x.md)\n'; + const page = distill(text, 'https://docs.expo.dev/llms.txt'); + assert.deepEqual(page.blocks.filter((b) => b.href).map((b) => [b.text, b.href]), [ + ['Claude Code and Expo', 'https://docs.expo.dev/agents/claude.md'], + ['Codex and Expo', 'https://docs.expo.dev/agents/codex.md'], + ]); + const heading = page.blocks.find((b) => b.text === 'AI agents'); + assert.equal(heading?.type, 'heading', 'the heading line was lost or kept its #'); + // A heading above anything but a link list stays one verbatim block. + assert.ok(texts(page).some((l) => l.startsWith('### Notes\nPlain prose under a heading.'))); + assert.equal(toMarkdown(text, 'https://docs.expo.dev/llms.txt'), text.trimEnd(), 'raw rewrote the file'); +}); + // The shape of svelte.dev's llms.txt: a title, a summary, and sections of // links with a description each. const LLMS = '# Svelte Documentation for LLMs\n\n> Svelte is a UI framework.\n\n## Documentation Sets\n\n'