Skip to content

Commit cafb85d

Browse files
Add articles.json output for MCP corpus
Adds a new Hugo output format (Articles) that generates public/articles.json — a full-text JSON index of all non-draft post and page content. Includes fields expected by the MCP corpus builder (title, url, summary, content, date, type, tags, categories). Also adds a Node.js test suite to verify the output is correct after a build.
1 parent 79d7b1a commit cafb85d

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

config/_default/config.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,11 @@ baseName = "search"
4747
isPlainText = true
4848
notAlternative = true
4949

50+
[outputFormats.Articles]
51+
mediaType = "application/json"
52+
baseName = "articles"
53+
isPlainText = true
54+
notAlternative = true
55+
5056
[outputs]
51-
home = ["HTML", "RSS", "REDIRECTS", "SearchIndex"]
57+
home = ["HTML", "RSS", "REDIRECTS", "SearchIndex", "Articles"]

layouts/index.articles.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{{- $.Scratch.Add "articles" slice -}}
2+
{{- range where .Site.RegularPages "Type" "in" (slice "post" "page") -}}
3+
{{- if not .Params.draft -}}
4+
{{- $date := "" -}}
5+
{{- if not .Date.IsZero -}}
6+
{{- $date = .Date.Format "2006-01-02" -}}
7+
{{- end -}}
8+
{{- $.Scratch.Add "articles" (dict
9+
"title" .Title
10+
"url" .Permalink
11+
"summary" (.Summary | plainify)
12+
"content" .Plain
13+
"date" $date
14+
"type" .Type
15+
"tags" (default (slice) .Params.tags)
16+
"categories" (default (slice) .Params.categories)
17+
) -}}
18+
{{- end -}}
19+
{{- end -}}
20+
{{- $.Scratch.Get "articles" | jsonify -}}

tests/articles-json.test.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
6+
const root = path.resolve(__dirname, '..');
7+
const articlesPath = path.join(root, 'public', 'articles.json');
8+
9+
function loadArticles() {
10+
if (!fs.existsSync(articlesPath)) {
11+
throw new Error('public/articles.json missing - run `npm run build` first');
12+
}
13+
return JSON.parse(fs.readFileSync(articlesPath, 'utf8'));
14+
}
15+
16+
function draftSlugs() {
17+
const postDir = path.join(root, 'content', 'post');
18+
return fs
19+
.readdirSync(postDir)
20+
.filter((f) => f.endsWith('.md'))
21+
.filter((f) => {
22+
const body = fs.readFileSync(path.join(postDir, f), 'utf8');
23+
const frontMatter = body.split('---')[1] || '';
24+
return /^draft:\s*true\s*$/m.test(frontMatter);
25+
})
26+
.map((f) => f.replace(/\.md$/, ''));
27+
}
28+
29+
test('articles.json is a non-empty array', () => {
30+
const articles = loadArticles();
31+
assert.ok(Array.isArray(articles), 'expected an array');
32+
assert.ok(articles.length > 50, `expected more than 50 articles, got ${articles.length}`);
33+
});
34+
35+
test('articles.json exposes the fields the MCP corpus builder expects', () => {
36+
const articles = loadArticles();
37+
for (const key of ['title', 'url', 'summary', 'content', 'date', 'type', 'tags', 'categories']) {
38+
assert.ok(key in articles[0], `missing field: ${key}`);
39+
}
40+
});
41+
42+
test('articles.json contains no drafts', () => {
43+
const articles = loadArticles();
44+
const drafts = draftSlugs();
45+
46+
// The fixture must be meaningful: if nothing is a draft, this proves nothing.
47+
assert.ok(drafts.length > 0, 'expected at least one draft post to exist');
48+
49+
for (const slug of drafts) {
50+
const hit = articles.find((a) => a.url.includes(`/${slug}`));
51+
assert.equal(hit, undefined, `draft leaked into articles.json: ${slug}`);
52+
}
53+
});
54+
55+
test('articles.json excludes command pages', () => {
56+
const articles = loadArticles();
57+
assert.ok(!articles.some((a) => a.type === 'commands'), 'command pages must not be indexed here');
58+
});
59+
60+
test('articles.json does not truncate content the way search.json does', () => {
61+
const articles = loadArticles();
62+
const longest = articles.reduce((m, a) => Math.max(m, a.content.length), 0);
63+
assert.ok(longest > 500, `expected full text, longest content was ${longest} chars`);
64+
});
65+
66+
test('articles.json uses absolute URLs', () => {
67+
const articles = loadArticles();
68+
assert.ok(
69+
articles.every((a) => a.url.startsWith('https://dbatools.io/')),
70+
'every url must be absolute'
71+
);
72+
});

0 commit comments

Comments
 (0)