diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..329b94b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run build:graph + - run: npm test + - run: npm run lint:refs + - run: npm run build diff --git a/astro.config.mjs b/astro.config.mjs index 9ab0a1e..d668314 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -38,6 +38,14 @@ export default defineConfig({ '/resilient-plural-identity': '/writeups/resilient-plural-identity/', '/resilient-disbursement-rails': '/writeups/resilient-disbursement-rails/', '/resilient-civic-participation': '/writeups/resilient-civic-participation/', + // Map jurisdiction filenames keep mixed case (id-OJK.md). Astro glob + // ids are lowercase, so inbound mixed-case URLs need a hop. + '/jurisdictions/id-OJK/': '/jurisdictions/id-ojk/', + '/jurisdictions/sg-MAS/': '/jurisdictions/sg-mas/', + '/jurisdictions/eu-EUDR/': '/jurisdictions/eu-eudr/', + '/jurisdictions/eu-MiCA/': '/jurisdictions/eu-mica/', + '/jurisdictions/us-SEC/': '/jurisdictions/us-sec/', + '/jurisdictions/de-eWpG/': '/jurisdictions/de-ewpg/', }, markdown: { remarkPlugins: [remarkRewriteLinks, remarkApproachVariants], diff --git a/src/components/explore/BrowseGrid.tsx b/src/components/explore/BrowseGrid.tsx index 5883c06..d421757 100644 --- a/src/components/explore/BrowseGrid.tsx +++ b/src/components/explore/BrowseGrid.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from 'react'; import type { GraphData, GraphNode } from '../../lib/graph-types'; import { getNodeColor, TYPE_LABELS, nodeMatchesFilters } from '../../lib/graph-layout'; +import { toContentSlug } from '../../lib/slugify'; /* * Filterable card grid for the /explore/browse view. Light theme. @@ -24,7 +25,7 @@ function nodeHref(node: GraphNode): string | null { const route = ROUTE_BY_TYPE[node.type]; if (!route) return null; const prefix = node.type === 'pattern' ? 'pattern-' : node.type === 'approach' ? 'approach-' : ''; - return `${route}${prefix}${node.slug}/`; + return `${route}${prefix}${toContentSlug(node.slug)}/`; } export function BrowseGrid({ graph }: Props) { diff --git a/src/components/explore/DetailPanel.tsx b/src/components/explore/DetailPanel.tsx index 57a0bf6..3dae7c3 100644 --- a/src/components/explore/DetailPanel.tsx +++ b/src/components/explore/DetailPanel.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { GraphData, GraphNode } from '../../lib/graph-types'; import { getNodeColor, TYPE_LABELS } from '../../lib/graph-layout'; import { renderMarkdown } from '../../lib/render'; +import { toContentSlug } from '../../lib/slugify'; /* * Side panel that opens when a graph node is selected. Shows @@ -29,7 +30,7 @@ function nodeHref(node: GraphNode): string | null { const route = ROUTE_BY_TYPE[node.type]; if (!route) return null; const prefix = node.type === 'pattern' ? 'pattern-' : node.type === 'approach' ? 'approach-' : ''; - return `${route}${prefix}${node.slug}/`; + return `${route}${prefix}${toContentSlug(node.slug)}/`; } export function DetailPanel({ node, graph, onClose, onSelectNode }: Props) { diff --git a/src/data/faq.ts b/src/data/faq.ts index d2bc0b2..ecce2f9 100644 --- a/src/data/faq.ts +++ b/src/data/faq.ts @@ -118,7 +118,7 @@ export const faqCategories: FaqCategory[] = [ 'Viewing keys and zero-knowledge proofs of compliance (proving you hold a valid KYC attestation without revealing your identity) are the primary mechanisms. The regulatory posture is: privacy is acceptable as long as compliance access exists.', ], links: [ - { label: 'EU / MiCA', href: '/jurisdictions/eu-MiCA/' }, + { label: 'EU / MiCA', href: '/jurisdictions/eu-mica/' }, { label: 'Regulatory disclosure', href: '/patterns/pattern-regulatory-disclosure-keys-proofs/' }, ], }, diff --git a/src/lib/related.ts b/src/lib/related.ts index 1b06493..2b36681 100644 --- a/src/lib/related.ts +++ b/src/lib/related.ts @@ -13,6 +13,7 @@ */ import { getCollection } from 'astro:content'; +import { toContentSlug } from './slugify'; export type RelatedKey = | 'useCases' @@ -95,12 +96,13 @@ export async function resolveSlugs( const folder = KEY_TO_FOLDER[collection]; const out: RelatedItem[] = []; for (const slug of slugs) { - const title = maps[collection].get(slug); + const id = toContentSlug(slug); + const title = maps[collection].get(id); if (!title) { unresolved?.push(slug); continue; } - out.push({ label: title, href: `/${folder}/${slug}/` }); + out.push({ label: title, href: `/${folder}/${id}/` }); } return out; } @@ -175,15 +177,16 @@ export async function extractRelated( }; function tryAdd(key: RelatedKey, slug: string): void { - if (key === currentCollection && slug === currentSlug) return; - const dedupeKey = `${key}:${slug}`; + const id = toContentSlug(slug); + if (key === currentCollection && id === currentSlug) return; + const dedupeKey = `${key}:${id}`; if (seen.has(dedupeKey)) return; - const title = maps[key].get(slug); + const title = maps[key].get(id); if (!title) return; // unknown slug — skip rather than render a broken link seen.add(dedupeKey); buckets[key].push({ label: title, - href: `/${KEY_TO_FOLDER[key]}/${slug}/`, + href: `/${KEY_TO_FOLDER[key]}/${id}/`, }); } @@ -409,16 +412,17 @@ async function buildBackrefIndex(): Promise { const idx: RawIndex = new Map(); function record(targetKey: RelatedKey, targetSlug: string, src: Src, perSrcSeen: Set): void { + const id = toContentSlug(targetSlug); // Skip self-reference - if (targetKey === src.key && targetSlug === src.id) return; + if (targetKey === src.key && id === src.id) return; // Skip unknown targets (slugs that don't resolve) - if (!maps[targetKey].has(targetSlug)) return; + if (!maps[targetKey].has(id)) return; - const dedupeKey = `${targetKey}:${targetSlug}`; + const dedupeKey = `${targetKey}:${id}`; if (perSrcSeen.has(dedupeKey)) return; perSrcSeen.add(dedupeKey); - const bucketKey: BackrefKey = `${targetKey}:${targetSlug}`; + const bucketKey: BackrefKey = `${targetKey}:${id}`; if (!idx.has(bucketKey)) idx.set(bucketKey, []); const sourceFolder = KEY_TO_FOLDER[src.key]; idx.get(bucketKey)!.push({ diff --git a/src/lib/render.ts b/src/lib/render.ts index 48200ab..d92da51 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -21,6 +21,7 @@ import { marked } from 'marked'; import type { Tokens } from 'marked'; import graphData from '../data/graph.json'; import type { GraphData } from './graph-types'; +import { toContentSlug } from './slugify'; // ───────────────────────────────────────────────────────────────────────────── // CONTENT_DIRS — must agree with guide/scripts/build-graph.mjs#CONTENT_DIRS. @@ -56,7 +57,7 @@ function resolveRouteHref(href: string): string | null { const parts = href.split('/').filter(p => p && p !== '.' && p !== '..'); const [dir, file] = parts; if (parts.length > 2 || !INDEX_ROUTES.has(dir)) return null; - return file ? `/${dir}/${file.replace(/\.md$/, '')}/` : `/${dir}/`; + return file ? `/${dir}/${toContentSlug(file.replace(/\.md$/, ''))}/` : `/${dir}/`; } const graph = graphData as GraphData; @@ -128,7 +129,7 @@ function resolveMdHref(href: string): ResolvedLink | null { // Astro content-collection routes use the raw filename as entry.id // (e.g. `pattern-shielding`), so the URL keeps the prefix. The graph // node id keeps the historical stripped form for the exists check. - const routeSlug = filename.replace(/\.md$/, ''); + const routeSlug = toContentSlug(filename.replace(/\.md$/, '')); candidates.push({ route: `${cfg.route}/${routeSlug}/${suffix}`, exists: nodeIds.has(id), diff --git a/src/lib/slugify.ts b/src/lib/slugify.ts index 70c8fed..8dcc6ef 100644 --- a/src/lib/slugify.ts +++ b/src/lib/slugify.ts @@ -7,6 +7,15 @@ * "int-banking-secrecy"-> "INT·BANKING-SECRECY" * "hk-crypto-licensing"-> "HK·CRYPTO-LICENSING" */ +/** + * Astro's glob loader slugifies collection ids to lowercase, so + * `id-OJK.md` is served at `/jurisdictions/id-ojk/`. Map filenames + * keep their original case; route URLs have to match `entry.id`. + */ +export function toContentSlug(slug: string): string { + return slug.toLowerCase(); +} + export function jurisdictionCode(slug: string): string { const [region, ...rest] = slug.split('-'); if (!region) return slug.toUpperCase(); diff --git a/src/plugins/remark-rewrite-links.ts b/src/plugins/remark-rewrite-links.ts index 9b04ee5..6202f86 100644 --- a/src/plugins/remark-rewrite-links.ts +++ b/src/plugins/remark-rewrite-links.ts @@ -21,6 +21,7 @@ import { visit } from 'unist-util-visit'; import type { Root, Link, Text } from 'mdast'; import type { Plugin } from 'unified'; import type { VFile } from 'vfile'; +import { toContentSlug } from '../lib/slugify'; const KNOWN_COLLECTIONS = new Set([ 'use-cases', @@ -127,7 +128,7 @@ export const remarkRewriteLinks: Plugin<[], Root> = () => { if (crossMatch) { const [, collection, slug, anchor] = crossMatch; if (KNOWN_COLLECTIONS.has(collection)) { - node.url = `/${collection}/${slug}/${anchor ?? ''}`; + node.url = `/${collection}/${toContentSlug(slug)}/${anchor ?? ''}`; return; } node.url = `${GITHUB_BLOB}/${collection}/${slug}.md${anchor ?? ''}`; @@ -138,7 +139,7 @@ export const remarkRewriteLinks: Plugin<[], Root> = () => { const sameMatch = url.match(/^([^./]+)\.md(#.*)?$/); if (sameMatch && currentCollection) { const [, slug, anchor] = sameMatch; - node.url = `/${currentCollection}/${slug}/${anchor ?? ''}`; + node.url = `/${currentCollection}/${toContentSlug(slug)}/${anchor ?? ''}`; return; } diff --git a/tests/render-links.test.ts b/tests/render-links.test.ts index ebee958..ccddd4c 100644 --- a/tests/render-links.test.ts +++ b/tests/render-links.test.ts @@ -23,7 +23,7 @@ describe('renderMarkdown link rewriting', () => { }); it('still rewrites .md card links', () => { - expect(href('[MiCA](../jurisdictions/eu-MiCA.md)')).toBe('/jurisdictions/eu-MiCA/'); + expect(href('[MiCA](../jurisdictions/eu-MiCA.md)')).toBe('/jurisdictions/eu-mica/'); }); it('leaves absolute and external links untouched', () => {