From 0fccb0cac156a1334e76c405cfb61095ff3b8553 Mon Sep 17 00:00:00 2001 From: Kumar Shivendu Date: Thu, 24 Sep 2026 04:17:20 +0530 Subject: [PATCH] Publish each page's markdown at /path.md as well as /path/index.md (#2743) * feat(build): publish each page's markdown at /path.md as well as /path/index.md Hugo writes the Markdown output format beside the HTML it belongs to, so a page at /articles/foo/ is published at /articles/foo/index.md. That is correct and it stays: it is what the llms.txt convention prescribes for directory-style URLs, it is the form /llms.txt already advertises for 670 pages, and it is the target of the we emit in every page head. What it is not is guessable. Every other documentation site I compared exposes a page's markdown by swapping the extension, so a client holding only a URL can construct /articles/foo.md directly instead of first fetching the HTML to read the alternate link: /articles/immutable-data-structures.md 404 -> 200 text/markdown /articles/immutable-data-structures/index.md 200 200 (unchanged) So publish both. A post-build step copies every public/**/index.md to public/**.md; the originals are untouched, so no published URL changes. Netlify already serves .md as text/markdown, and the files are small: 7.6 MB against a 1.4 GB build, 0.5%. Deliberately a build step rather than a Netlify redirect. A splat has to be terminal, so /articles/*.md is not a pattern Netlify accepts, and a rule that cannot be verified locally is worse than a copy that can. Adds npm run md:test, which builds the site, runs the script over the output and checks all 789 aliases exist and match their source byte for byte, that the canonical index.md and index.html are untouched, that nothing is written outside the publish directory, and that a second run overwrites nothing. Verified the tests fail when the script is stubbed out. The test deletes its build directory on exit. A build is ~1.4 GB, and a suite that leaves temp directories behind fills a disk faster than anyone notices. Co-Authored-By: Claude Opus 5 (1M context) * test(md): cut the alias tests down, and keep master's npm scripts Review: the tests were redundant. Two of the four asserted what cp does, and all four needed a full ~1.4 GB Hugo build to say it. Replaced with two tests over a synthetic directory, covering the only two things the script decides: skip the site root, never overwrite an existing file. 87 lines to 39, and the run drops from a full site build to 0.26s. package.json: this branch predates the charts work, so its scripts block replaced master's. Merging as-is would have silently deleted viz:test and viz:charts. Now keeps both and adds md:test. Also trims the script's comment header from 19 lines to 8. Co-Authored-By: Claude Opus 5 * test(md): drop the npm script, run the test file directly package.json is the site's manifest; a runner alias for one shell script does not belong in it. The test runs as: node --test test/markdown/aliases.test.mjs package.json is now untouched by this branch, which also removes the risk of the merge dropping master's viz scripts. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- install-and-build.sh | 3 +- qdrant-landing/scripts/emit-md-aliases.sh | 29 ++++++++++++++ qdrant-landing/test/markdown/aliases.test.mjs | 40 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100755 qdrant-landing/scripts/emit-md-aliases.sh create mode 100644 qdrant-landing/test/markdown/aliases.test.mjs diff --git a/install-and-build.sh b/install-and-build.sh index bf37de316..ce5d11196 100644 --- a/install-and-build.sh +++ b/install-and-build.sh @@ -31,4 +31,5 @@ curl -LJO "https://github.com/sass/dart-sass/releases/download/${DART_SASS_VERSI tar -xf "${SASS_ARCHIVE}" && \ rm "${SASS_ARCHIVE}" && \ export PATH="${CURRENT_DIR}/dart-sass:${PATH}" && \ - cd qdrant-landing && npm install && hugo --gc --minify --config config.toml,config-theme.toml --buildFuture -b ${DEPLOY_PRIME_URL} + cd qdrant-landing && npm install && hugo --gc --minify --config config.toml,config-theme.toml --buildFuture -b ${DEPLOY_PRIME_URL} && \ + ./scripts/emit-md-aliases.sh public diff --git a/qdrant-landing/scripts/emit-md-aliases.sh b/qdrant-landing/scripts/emit-md-aliases.sh new file mode 100755 index 000000000..6669c1027 --- /dev/null +++ b/qdrant-landing/scripts/emit-md-aliases.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Publish every /path/index.md a second time as /path.md, the guessable form a +# client can build from a URL without fetching the HTML first. Additions, never +# moves: /llms.txt advertises the index.md URLs. +# +# A build step rather than a Netlify redirect because a splat has to be +# terminal, so /articles/*.md is not a pattern Netlify accepts. +set -euo pipefail + +PUBLIC="${1:-public}" +[ -d "$PUBLIC" ] || { echo "md-aliases: no such directory: $PUBLIC" >&2; exit 1; } + +written=0 +skipped=0 +while IFS= read -r -d '' src; do + dir=$(dirname "$src") + # The site root would write a sibling of the publish directory, not a page. + [ "$dir" = "$PUBLIC" ] && continue + dest="${dir}.md" + if [ -e "$dest" ]; then + skipped=$((skipped + 1)) + continue + fi + cp "$src" "$dest" + written=$((written + 1)) +done < <(find "$PUBLIC" -type f -name index.md -print0) + +echo "md-aliases: wrote ${written}, skipped ${skipped} (a file was already there)" diff --git a/qdrant-landing/test/markdown/aliases.test.mjs b/qdrant-landing/test/markdown/aliases.test.mjs new file mode 100644 index 000000000..63c5dca1a --- /dev/null +++ b/qdrant-landing/test/markdown/aliases.test.mjs @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// node --test test/markdown/aliases.test.mjs +const run = (dir) => execFileSync('bash', ['scripts/emit-md-aliases.sh', dir], { encoding: 'utf8' }); + +function fixture() { + const out = mkdtempSync(join(tmpdir(), 'md-alias-')); + mkdirSync(join(out, 'articles', 'foo'), { recursive: true }); + writeFileSync(join(out, 'articles', 'foo', 'index.md'), 'page\n'); + writeFileSync(join(out, 'index.md'), 'home\n'); + return out; +} + +test('the site root does not write outside the publish directory', () => { + const out = fixture(); + try { + run(out); + assert.ok(existsSync(join(out, 'articles', 'foo.md')), 'a page should gain its alias'); + assert.ok(!existsSync(`${out}.md`), 'the root must not produce a sibling of the publish dir'); + } finally { + rmSync(out, { recursive: true, force: true }); + } +}); + +test('an existing file is never overwritten', () => { + const out = fixture(); + try { + writeFileSync(join(out, 'articles', 'foo.md'), 'hand written\n'); + run(out); + assert.equal(execFileSync('cat', [join(out, 'articles', 'foo.md')], { encoding: 'utf8' }), + 'hand written\n', 'an existing alias must survive'); + } finally { + rmSync(out, { recursive: true, force: true }); + } +});