Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
3 changes: 2 additions & 1 deletion src/components/explore/BrowseGrid.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/components/explore/DetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/data/faq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
],
},
Expand Down
24 changes: 14 additions & 10 deletions src/lib/related.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import { getCollection } from 'astro:content';
import { toContentSlug } from './slugify';

export type RelatedKey =
| 'useCases'
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}/`,
});
}

Expand Down Expand Up @@ -409,16 +412,17 @@ async function buildBackrefIndex(): Promise<RawIndex> {
const idx: RawIndex = new Map();

function record(targetKey: RelatedKey, targetSlug: string, src: Src, perSrcSeen: Set<string>): 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({
Expand Down
5 changes: 3 additions & 2 deletions src/lib/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions src/lib/slugify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions src/plugins/remark-rewrite-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 ?? ''}`;
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion tests/render-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down