Skip to content

Commit 5cad149

Browse files
authored
Merge pull request #16 from nicholsn/fix/scaffold-astro-review
Fix scaffold Astro template for real authoring (adversarial review)
2 parents e41e6cf + 78b8534 commit 5cad149

6 files changed

Lines changed: 98 additions & 15 deletions

File tree

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
// @ts-check
22
import { defineConfig } from 'astro/config';
3+
import remarkLokfLinks from './remark-lokf-links.mjs';
34
import remarkStripLeadingTitle from './remark-strip-leading-title.mjs';
45

56
// `site` + `base` are derived from the bundle's base_iri, so the site works
67
// both on a custom domain (base "/") and as a GitHub *project* page
78
// (base "/<repo>"). Internal links use the href() helper in src/lib/lokf.ts,
8-
// which prefixes import.meta.env.BASE_URL. The remark plugin drops a concept
9-
// body's redundant leading `# Title` (the layout renders it from frontmatter).
9+
// which prefixes import.meta.env.BASE_URL. The remark plugins rewrite concept
10+
// `.md` cross-links to base-aware routes and drop a body's redundant leading
11+
// `# Title` (the layout renders it from frontmatter).
1012
export default defineConfig({
1113
site: '__KB_SITE__',
1214
base: '__KB_BASE__',
1315
markdown: {
14-
remarkPlugins: [remarkStripLeadingTitle],
16+
remarkPlugins: [
17+
[remarkLokfLinks, { base: '__KB_BASE__' }],
18+
remarkStripLeadingTitle,
19+
],
1520
},
1621
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import path from 'node:path';
2+
3+
/**
4+
* Concept bodies link to peers with markdown `.md` paths — relative
5+
* (`../glossary/active-user.md`) or bundle-root-absolute
6+
* (`/tables/user-events.md`, the form the author-concept skill teaches).
7+
* Rewrite them to the concept's site route, prefixed with the site's base so
8+
* they work on a GitHub *project* page under `/<repo>/`. External, mailto, and
9+
* anchor links are left alone; links outside `knowledge/` are left alone.
10+
*
11+
* `base` is the Astro `base` (e.g. `/my-kb` or `/`), injected from the config.
12+
*/
13+
const KNOWLEDGE_ROOT = path.join(process.cwd(), 'knowledge');
14+
15+
function walk(node, fn) {
16+
if (!node || typeof node !== 'object') return;
17+
if (node.type === 'link') fn(node);
18+
if (Array.isArray(node.children)) for (const c of node.children) walk(c, fn);
19+
}
20+
21+
export default function remarkLokfLinks({ base = '/' } = {}) {
22+
const prefix = base === '/' ? '' : base.replace(/\/$/, '');
23+
return (tree, file) => {
24+
const filePath = file.path || (file.history && file.history[0]);
25+
const dir = filePath ? path.dirname(filePath) : KNOWLEDGE_ROOT;
26+
walk(tree, (node) => {
27+
const url = node.url || '';
28+
if (!url.endsWith('.md') || /^(https?:|mailto:|#)/.test(url)) return;
29+
const abs = url.startsWith('/')
30+
? path.join(KNOWLEDGE_ROOT, url.replace(/^\//, ''))
31+
: path.resolve(dir, url);
32+
const rel = path.relative(KNOWLEDGE_ROOT, abs).replace(/\.md$/, '');
33+
if (rel.startsWith('..')) return;
34+
node.url = prefix + '/' + rel.split(path.sep).join('/');
35+
});
36+
};
37+
}

src/lokf/templates/kb/src/content.config.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@ import { glob } from 'astro/loaders';
55
* The `knowledge/` directory at the repo root IS the content: a LOKF bundle of
66
* markdown concept files. OKF requires only `type`, and consumers must
77
* tolerate unknown keys — so the schema validates the common fields and
8-
* passes everything else through. `index.md` and `log.md` are reserved bundle
9-
* files (OKF §3), not concepts.
8+
* passes everything else through.
109
*/
1110
const knowledge = defineCollection({
1211
loader: glob({
13-
pattern: ['**/*.md', '!index.md', '!log.md'],
12+
// `index.md` and `log.md` are reserved bundle files (OKF §3) at ANY depth —
13+
// the toolkit ignores them everywhere, so exclude them everywhere (`**`
14+
// matches zero segments too, keeping the root files excluded).
15+
pattern: ['**/*.md', '!**/index.md', '!**/log.md'],
1416
base: './knowledge',
17+
// Preserve the literal relative path as the entry id (Astro's default
18+
// slugifies it), so a concept's IRI (base_iri + id) matches the toolkit's
19+
// concept_id exactly — no case/underscore drift between site and graph.
20+
generateId: ({ entry }) => entry.replace(/\.md$/, ''),
1521
}),
1622
schema: z
1723
.object({

src/lokf/templates/kb/src/lib/lokf.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,20 @@ export const REL_LABEL: Record<string, string> = {
2929
export const href = (path: string) =>
3030
import.meta.env.BASE_URL.replace(/\/$/, '') + '/' + path.replace(/^\//, '');
3131

32-
export const iriOf = (e: Concept) => BASE_IRI + e.id;
32+
/**
33+
* Resolve a relation target to a full IRI — mirrors the toolkit's
34+
* `Bundle.resolve`: absolute IRIs (http(s)/urn) pass through; a bundle-relative
35+
* Concept ID (`glossary/active-user` or `/glossary/active-user`) hangs off the
36+
* base IRI so it matches the concept's own `iriOf`.
37+
*/
38+
export const resolveRef = (ref: string) =>
39+
/^(https?:|urn:)/.test(ref) ? ref : BASE_IRI + ref.replace(/^\//, '');
40+
41+
/** A concept's IRI: an explicit absolute frontmatter `id`, else base + path. */
42+
export const iriOf = (e: Concept) => {
43+
const id = (e.data as Record<string, unknown>).id;
44+
return typeof id === 'string' && /^(https?:|urn:)/.test(id) ? id : BASE_IRI + e.id;
45+
};
3346
export const hrefOf = (e: Concept) => href(e.id);
3447
export const titleOf = (e: Concept) => e.data.title ?? e.id;
3548

@@ -48,11 +61,11 @@ export function relationsOf(e: Concept): { slot: string; target: string }[] {
4861
const v = d[slot];
4962
if (v === undefined) continue;
5063
for (const target of Array.isArray(v) ? v : [v]) {
51-
if (typeof target === 'string') out.push({ slot, target });
64+
if (typeof target === 'string') out.push({ slot, target: resolveRef(target) });
5265
}
5366
}
5467
for (const rel of (d.relations as { predicate?: string; target?: string }[] | undefined) ?? []) {
55-
if (rel?.predicate && rel?.target) out.push({ slot: rel.predicate, target: rel.target });
68+
if (rel?.predicate && rel?.target) out.push({ slot: rel.predicate, target: resolveRef(rel.target) });
5669
}
5770
return out;
5871
}

src/lokf/templates/kb/src/pages/graph.jsonld.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { APIRoute } from 'astro';
2-
import { loadBundle, iriOf } from '../lib/lokf';
2+
import { loadBundle, iriOf, resolveRef, RELATION_SLOTS } from '../lib/lokf';
33

44
/** The LOKF JSON-LD context published by the lokf project. */
55
const CONTEXT =
@@ -12,11 +12,19 @@ const CONTEXT =
1212
*/
1313
export const GET: APIRoute = async () => {
1414
const { concepts } = await loadBundle();
15-
const graph = concepts.map((c) => ({
16-
id: iriOf(c),
17-
...(c.data as Record<string, unknown>),
18-
...(c.body ? { body: c.body } : {}),
19-
}));
15+
const graph = concepts.map((c) => {
16+
const data = { ...(c.data as Record<string, unknown>) };
17+
// Resolve bundle-relative relation targets to full IRIs so the RDF edges
18+
// are unambiguous (mirrors the toolkit); `id` = the concept's own IRI.
19+
for (const slot of RELATION_SLOTS) {
20+
const v = data[slot];
21+
if (v === undefined) continue;
22+
data[slot] = (Array.isArray(v) ? v : [v]).map((t) =>
23+
typeof t === 'string' ? resolveRef(t) : t,
24+
);
25+
}
26+
return { ...data, id: iriOf(c), ...(c.body ? { body: c.body } : {}) };
27+
});
2028
const doc = { '@context': CONTEXT, '@graph': graph };
2129
return new Response(JSON.stringify(doc, null, 2), {
2230
headers: { 'Content-Type': 'application/ld+json; charset=utf-8' },

tests/test_scaffold.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,17 @@ def test_refuses_file_target(tmp_path):
8888
(tmp_path / "kb").write_text("i am a file")
8989
with pytest.raises(FileExistsError):
9090
scaffold.new("kb", path=tmp_path)
91+
92+
93+
def test_template_handles_real_authoring(tmp_path):
94+
"""Guards the adversarial-review fixes: nested reserved files, .md links,
95+
relative relation targets, and literal-path ids."""
96+
root = scaffold.new("kb", path=tmp_path)
97+
cfg = (root / "src/content.config.ts").read_text()
98+
assert "'!**/index.md'" in cfg and "'!**/log.md'" in cfg # reserved at any depth
99+
assert "generateId" in cfg # literal-path ids, no slugify
100+
astro = (root / "astro.config.mjs").read_text()
101+
assert "remarkLokfLinks" in astro # .md cross-links -> routes
102+
assert (root / "remark-lokf-links.mjs").exists()
103+
lib = (root / "src/lib/lokf.ts").read_text()
104+
assert "resolveRef" in lib # relative relation targets

0 commit comments

Comments
 (0)