diff --git a/.gitignore b/.gitignore index 6a67c77..d4c9649 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ supabase/.temp/ .cursor/ .vercel +# Git worktrees +.worktrees/ + # OS .DS_Store Thumbs.db diff --git a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx index 1aec8d3..3f6d501 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -226,7 +226,7 @@ async function LawReaderSection({ const supabase = await createClient(); // Fire all initial queries in parallel (1 RTT instead of 2) - const [{ count: totalPasalCount }, { data: structure }, { data: initialPasals }, { data: rels }] = await Promise.all([ + const [{ count: totalPasalCount }, { data: structure }, { data: initialPasals }, { data: rels }, { data: pasalParentIds }, { data: worksData }] = await Promise.all([ supabase .from("document_nodes") .select("id", { count: "exact", head: true }) @@ -250,14 +250,42 @@ async function LawReaderSection({ .select("*, relationship_types(code, name_id, name_en)") .or(`source_work_id.eq.${workId},target_work_id.eq.${workId}`) .order("id"), + // Lightweight query: just parent_id values for all pasals, used to filter + // structural nodes (BABs, Bagians) that have no pasal content — these are + // typically table-of-contents entries parsed as structural nodes. + supabase + .from("document_nodes") + .select("parent_id") + .eq("work_id", workId) + .eq("node_type", "pasal") + .not("parent_id", "is", null), + supabase + .from("works") + .select("slug, regulation_types!inner(code)") + .not("slug", "is", null), ]); - const usePagination = (totalPasalCount || 0) >= 100; + // Build cross-reference lookup: "uu-13-2003" → "/peraturan/uu/uu-13-2003" + const worksLookup: Record = {}; + for (const w of worksData ?? []) { + const rt = (Array.isArray(w.regulation_types) ? w.regulation_types[0] : w.regulation_types) as { code: string } | null; + if (w.slug && rt?.code) { + const typeCode = rt.code.toLowerCase(); + worksLookup[w.slug] = `/peraturan/${typeCode}/${w.slug}`; + } + } + + // Structured laws must always load all pasals SSR so the BAB-grouping logic has the + // full set. Only use client-side infinite scroll for flat laws (no structural nodes) + // with a large pasal count. Check all node types that trigger the tree-rendering path: + // bab, aturan, lampiran, bagian, paragraf. + const hasStructure = (structure || []).length > 0; + const usePagination = (totalPasalCount || 0) >= 100 && !hasStructure; const structuralNodes = structure; let pasalNodes = initialPasals; const relationships = rels; - // For small documents with >30 pasals, fetch the rest + // For documents with >30 pasals not using client-side pagination, fetch the rest SSR if (!usePagination && (totalPasalCount || 0) > 30) { const { data: remaining } = await supabase .from("document_nodes") @@ -287,19 +315,60 @@ async function LawReaderSection({ const pageUrl = `https://pasal.id/peraturan/${type.toLowerCase()}/${slug}`; + // Build a set of structural node IDs that have at least one pasal child (at any depth). + // Used to skip empty structural nodes (e.g. TOC entries parsed as BAB nodes). + const structuralIdsWithPasals = new Set( + (pasalParentIds || []).map((r) => r.parent_id).filter(Boolean), + ); + // Build tree structure - const babNodes = structuralNodes || []; + const allStructuralNodes = structuralNodes || []; const allPasals = pasalNodes || []; + // Pre-build a parent→children map for O(n) descendant traversal. + const structuralChildrenMap = new Map(); + for (const n of allStructuralNodes) { + if (n.parent_id !== null) { + const siblings = structuralChildrenMap.get(n.parent_id) ?? []; + siblings.push(n.id); + structuralChildrenMap.set(n.parent_id, siblings); + } + } + + /** Returns true if nodeId or any of its structural descendants has a pasal. */ + function hasDescendantPasal(nodeId: number): boolean { + if (structuralIdsWithPasals.has(nodeId)) return true; + for (const childId of structuralChildrenMap.get(nodeId) ?? []) { + if (hasDescendantPasal(childId)) return true; + } + return false; + } + + // Filter out structural nodes (BABs, Bagians, etc.) that have no pasal content in the DB. + // This removes table-of-contents entries that the parser mistakenly captures as structural + // nodes — common in ratification laws (e.g. UU 6/2023) where the attached law's TOC + // appears verbatim and gets parsed as BAB markers without any associated Pasal content. + // + // We apply hasDescendantPasal() to EVERY structural node regardless of depth or parent_id. + // Previously there was a short-circuit `if (node.parent_id !== null) return true` here, + // but that incorrectly passed phantom TOC-BABs that live inside a LAMPIRAN node (they have + // a non-null parent_id pointing to the lampiran, but still have zero pasal descendants). + // Real Bagian/Paragraf nodes also have non-null parent_ids but pass the check because they + // ARE in structuralIdsWithPasals (pasals are direct children). The renderer handles + // sub-section grouping via subSectionIds — it never renders a structural node independently + // unless it's the top-level BAB iteration below. + const babNodes = allStructuralNodes.filter((node) => hasDescendantPasal(node.id)); + const mainContent = ( <> {babNodes.length > 0 ? ( babNodes.map((bab) => { - // Filter pasals for this BAB - const directPasals = allPasals.filter((p) => p.parent_id === bab.id); + // Find direct sub-sections (Bagian/Paragraf) of this BAB const subSectionIds = new Set( babNodes.filter((n) => n.parent_id === bab.id).map((n) => n.id), ); + // Filter pasals for this BAB + const directPasals = allPasals.filter((p) => p.parent_id === bab.id); const nestedPasals = allPasals.filter( (p) => subSectionIds.has(p.parent_id ?? -1), ); @@ -321,7 +390,7 @@ async function LawReaderSection({ )} {allBabPasals.map((pasal) => ( - + ))} ); @@ -336,10 +405,11 @@ async function LawReaderSection({ totalPasals={totalPasalCount || 0} pathname={pathname} pageUrl={pageUrl} + worksLookup={worksLookup} /> ) : ( allPasals.map((pasal) => ( - + )) )} diff --git a/apps/web/src/components/reader/PasalBlock.tsx b/apps/web/src/components/reader/PasalBlock.tsx index f2ff522..a11ef96 100644 --- a/apps/web/src/components/reader/PasalBlock.tsx +++ b/apps/web/src/components/reader/PasalBlock.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { Link } from "@/i18n/routing"; import PasalLogo from "@/components/PasalLogo"; import CopyButton from "@/components/CopyButton"; +import RichPasalContent from "@/components/reader/RichPasalContent"; import { Pencil, Link2 } from "lucide-react"; interface PasalNode { @@ -17,9 +18,10 @@ interface PasalBlockProps { pasal: PasalNode; pathname: string; pageUrl: string; + worksLookup: Record; } -export default function PasalBlock({ pasal, pathname, pageUrl }: PasalBlockProps) { +export default function PasalBlock({ pasal, pathname, pageUrl, worksLookup }: PasalBlockProps) { const t = useTranslations("reader"); const content = pasal.content_text || ""; const koreksiHref = `${pathname}/koreksi/${pasal.id}`; @@ -48,7 +50,7 @@ export default function PasalBlock({ pasal, pathname, pageUrl }: PasalBlockProps -
{content}
+ ); } diff --git a/apps/web/src/components/reader/PasalList.tsx b/apps/web/src/components/reader/PasalList.tsx index 9c10529..95d2004 100644 --- a/apps/web/src/components/reader/PasalList.tsx +++ b/apps/web/src/components/reader/PasalList.tsx @@ -24,6 +24,7 @@ interface PasalListProps { totalPasals: number; pathname: string; pageUrl: string; + worksLookup: Record; } export default function PasalList({ @@ -33,6 +34,7 @@ export default function PasalList({ totalPasals, pathname, pageUrl, + worksLookup, }: PasalListProps) { const t = useTranslations("reader"); const [pasals, setPasals] = useState(initialPasals); @@ -102,6 +104,7 @@ export default function PasalList({ pasal={pasal} pathname={pathname} pageUrl={pageUrl} + worksLookup={worksLookup} /> ))} diff --git a/apps/web/src/components/reader/RichPasalContent.tsx b/apps/web/src/components/reader/RichPasalContent.tsx new file mode 100644 index 0000000..de983a1 --- /dev/null +++ b/apps/web/src/components/reader/RichPasalContent.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Fragment } from "react"; +import { Link } from "@/i18n/routing"; +import { tokenize } from "@/lib/crossref"; + +interface RichPasalContentProps { + content: string; + worksLookup: Record; +} + +/** + * Renders pasal body text with inline hyperlinks for cross-references. + * + * - "Pasal N" / "Pasal N ayat (X)" → anchor link to #pasal-N (same page) + * - "Undang-Undang Nomor N Tahun YYYY" → Link to /peraturan/[type]/[slug] + * (only if the work exists in worksLookup; otherwise renders as plain text) + * + * Preserves whitespace-pre-wrap formatting. Pure render — no side effects. + */ +export default function RichPasalContent({ + content, + worksLookup, +}: RichPasalContentProps) { + const tokens = tokenize(content, worksLookup); + + return ( +
+ {tokens.map((token, i) => { + if (token.type === "text") { + return {token.value}; + } + + if (token.type === "pasal") { + return ( + + {token.value} + + ); + } + + if (token.type === "uu") { + return ( + [0]["href"]} + className="text-primary underline decoration-dotted underline-offset-2 hover:decoration-solid rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + > + {token.value} + + ); + } + + return null; + })} +
+ ); +} diff --git a/apps/web/src/lib/__tests__/crossref.test.ts b/apps/web/src/lib/__tests__/crossref.test.ts new file mode 100644 index 0000000..3057e78 --- /dev/null +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from "vitest"; +import { tokenize } from "@/lib/crossref"; + +// Fixture lookup keyed by DB slug format: {type}-{number}-{year} +// Matches generate_work_slug() trigger output (migration 053) +const lookup: Record = { + "uu-13-2003": "/peraturan/uu/uu-13-2003", + "pp-74-2008": "/peraturan/pp/pp-74-2008", +}; + +describe("tokenize", () => { + it("returns plain text token for content with no references", () => { + const result = tokenize("Hak dan kewajiban warga negara.", lookup); + expect(result).toEqual([{ type: "text", value: "Hak dan kewajiban warga negara." }]); + }); + + it("detects bare Pasal reference", () => { + const result = tokenize("Sesuai dengan Pasal 5 tentang hak.", lookup); + expect(result).toEqual([ + { type: "text", value: "Sesuai dengan " }, + { type: "pasal", value: "Pasal 5", pasalNumber: "5", href: "#pasal-5" }, + { type: "text", value: " tentang hak." }, + ]); + }); + + it("detects Pasal with letter suffix", () => { + const result = tokenize("Lihat Pasal 5A.", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", pasalNumber: "5A", href: "#pasal-5A" }, + { type: "text" }, + ]); + }); + + it("detects Pasal with ayat", () => { + const result = tokenize("Merujuk Pasal 12 ayat (2) undang-undang ini.", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", value: "Pasal 12 ayat (2)", href: "#pasal-12" }, + { type: "text" }, + ]); + }); + + it("detects Pasal with lowercase", () => { + const result = tokenize("sebagaimana dimaksud dalam pasal 7.", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", pasalNumber: "7", href: "#pasal-7" }, + { type: "text" }, + ]); + }); + + it("detects resolvable UU cross-reference", () => { + const result = tokenize( + "Sesuai dengan Undang-Undang Nomor 13 Tahun 2003.", + lookup + ); + expect(result).toMatchObject([ + { type: "text" }, + { + type: "uu", + value: "Undang-Undang Nomor 13 Tahun 2003", + href: "/peraturan/uu/uu-13-2003", + }, + { type: "text" }, + ]); + }); + + it("renders unresolvable UU reference as plain text", () => { + const result = tokenize( + "Sesuai Undang-Undang Nomor 99 Tahun 1888.", + lookup + ); + expect(result.every((t) => t.type === "text")).toBe(true); + expect(result.map((t) => t.value).join("")).toBe( + "Sesuai Undang-Undang Nomor 99 Tahun 1888." + ); + }); + + it("detects Peraturan Pemerintah cross-reference", () => { + const result = tokenize( + "diatur dalam Peraturan Pemerintah Nomor 74 Tahun 2008.", + lookup + ); + expect(result).toMatchObject([ + { type: "text" }, + { type: "uu", href: "/peraturan/pp/pp-74-2008" }, + { type: "text" }, + ]); + }); + + it("handles both Perpu and Perppu spellings", () => { + const lookup2 = { "perppu-1-2022": "/peraturan/perppu/perppu-1-2022" }; + const r1 = tokenize("Perpu Nomor 1 Tahun 2022", lookup2); + const r2 = tokenize("Perppu Nomor 1 Tahun 2022", lookup2); + // Both spellings must resolve to the same perppu slug + const uuTokens1 = r1.filter((t) => t.type === "uu"); + const uuTokens2 = r2.filter((t) => t.type === "uu"); + expect(uuTokens1).toHaveLength(1); + expect(uuTokens2).toHaveLength(1); + expect(uuTokens1[0]).toMatchObject({ href: "/peraturan/perppu/perppu-1-2022" }); + expect(uuTokens2[0]).toMatchObject({ href: "/peraturan/perppu/perppu-1-2022" }); + }); + + it("handles multiple references in one string", () => { + const result = tokenize( + "Lihat Pasal 3 dan Pasal 7 ayat (1).", + lookup + ); + const pasalTokens = result.filter((t) => t.type === "pasal"); + expect(pasalTokens).toHaveLength(2); + expect(pasalTokens[0]).toMatchObject({ pasalNumber: "3" }); + expect(pasalTokens[1]).toMatchObject({ pasalNumber: "7" }); + }); + + it("returns single text token for empty string", () => { + expect(tokenize("", lookup)).toEqual([{ type: "text", value: "" }]); + }); + + it("detects Peraturan Presiden cross-reference", () => { + const lookup2 = { "perpres-12-2010": "/peraturan/perpres/perpres-12-2010" }; + const result = tokenize("diatur dalam Peraturan Presiden Nomor 12 Tahun 2010.", lookup2); + expect(result).toMatchObject([ + { type: "text" }, + { type: "uu", href: "/peraturan/perpres/perpres-12-2010" }, + { type: "text" }, + ]); + }); + + it("detects Peraturan Daerah cross-reference", () => { + const lookup2 = { "perda-5-2015": "/peraturan/perda/perda-5-2015" }; + const result = tokenize("sebagaimana Peraturan Daerah Nomor 5 Tahun 2015 mengatur.", lookup2); + expect(result).toMatchObject([ + { type: "text" }, + { type: "uu", href: "/peraturan/perda/perda-5-2015" }, + { type: "text" }, + ]); + }); + + it("detects UU citation without Nomor keyword", () => { + // Match starts at index 0 — no leading text token + const result = tokenize("Undang-Undang 13 Tahun 2003 berlaku.", lookup); + expect(result).toMatchObject([ + { type: "uu", href: "/peraturan/uu/uu-13-2003" }, + { type: "text", value: " berlaku." }, + ]); + }); + + it("handles mixed Pasal and UU references in one string", () => { + const result = tokenize( + "Sesuai Pasal 5 Undang-Undang Nomor 13 Tahun 2003 tentang ketenagakerjaan.", + lookup + ); + const pasalTokens = result.filter((t) => t.type === "pasal"); + const uuTokens = result.filter((t) => t.type === "uu"); + expect(pasalTokens).toHaveLength(1); + expect(uuTokens).toHaveLength(1); + expect(pasalTokens[0]).toMatchObject({ pasalNumber: "5" }); + expect(uuTokens[0]).toMatchObject({ href: "/peraturan/uu/uu-13-2003" }); + }); + + it("preserves trailing plain text after last reference", () => { + const result = tokenize("Lihat Pasal 3 untuk ketentuan lebih lanjut.", lookup); + const last = result[result.length - 1]; + expect(last).toEqual({ type: "text", value: " untuk ketentuan lebih lanjut." }); + }); + + it("renders unknown regulation type as plain text", () => { + // "Keputusan Menteri" is not in TYPE_PREFIX_MAP — should stay as text + const result = tokenize("berdasarkan Keputusan Menteri Nomor 5 Tahun 2020.", lookup); + expect(result.every((t) => t.type === "text")).toBe(true); + expect(result.map((t) => t.value).join("")).toBe( + "berdasarkan Keputusan Menteri Nomor 5 Tahun 2020." + ); + }); + + it("detects Pasal with capital-A Ayat (scanned PDF variant)", () => { + // Some PDFs capitalize Ayat — must still produce a pasal link + const result = tokenize("sebagaimana dimaksud dalam Pasal 90 Ayat (3).", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", value: "Pasal 90 Ayat (3)", href: "#pasal-90" }, + { type: "text" }, + ]); + }); + + it("detects all-caps UNDANG-UNDANG citation from scanned PDFs", () => { + const result = tokenize( + "sebagaimana dimaksud dalam UNDANG-UNDANG Nomor 13 Tahun 2003 tentang Ketenagakerjaan.", + lookup + ); + expect(result).toMatchObject([ + { type: "text" }, + { + type: "uu", + value: "UNDANG-UNDANG Nomor 13 Tahun 2003", + href: "/peraturan/uu/uu-13-2003", + }, + { type: "text" }, + ]); + }); + + it("detects all-caps PASAL reference from scanned PDFs", () => { + const result = tokenize("Ketentuan PASAL 5 berlaku.", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", pasalNumber: "5", href: "#pasal-5" }, + { type: "text" }, + ]); + }); +}); diff --git a/apps/web/src/lib/crossref.ts b/apps/web/src/lib/crossref.ts new file mode 100644 index 0000000..3370a97 --- /dev/null +++ b/apps/web/src/lib/crossref.ts @@ -0,0 +1,141 @@ +/** + * Tokenizer for Indonesian legal cross-references in pasal content text. + * + * Splits a string into alternating plain-text and link tokens without + * modifying the original text. Pure function — no React, no side effects. + */ + +export type Token = + | { type: "text"; value: string } + | { type: "pasal"; value: string; pasalNumber: string; href: string } + | { type: "uu"; value: string; href: string }; + +/** + * Regex that matches Indonesian legal cross-references. + * + * Group 1 (PASAL_RE): Pasal references, optionally with ayat. + * - "Pasal 5", "pasal 5A", "Pasal 12 ayat (2)", "Pasal 1 Ayat (a)" + * + * Group 2 (UU_RE): Full regulation citations with number and year. + * - "Undang-Undang Nomor 13 Tahun 2003" / "UNDANG-UNDANG Nomor 13 Tahun 2003" + * - "Peraturan Pemerintah Nomor 74 Tahun 2008" + * - "Peraturan Presiden Nomor 12 Tahun 2010" + * - "Perppu Nomor 1 Tahun 2022" / "Perpu Nomor 1 Tahun 2022" + * + * The `i` flag makes the entire regex case-insensitive, covering all-caps + * variants (UNDANG-UNDANG, PASAL) common in scanned Indonesian PDFs. + */ +const CROSSREF_RE = new RegExp( + // Group 1: Pasal N [ayat (X)] + "((?:Pasal)\\s+\\d+[A-Za-z]?(?:\\s+(?:Ayat)\\s+\\([0-9a-zA-Z]+\\))?)" + + "|" + + // Group 2: Full regulation citation + "((?:Undang-Undang|Peraturan\\s+Pemerintah|Peraturan\\s+Presiden|Peraturan\\s+Daerah|Perppu|Perpu)" + + "(?:\\s+Nomor)?\\s+\\d+\\s+Tahun\\s+\\d{4})", + "gi" +); + +/** + * Mapping from citation keyword to slug type prefix. + * Used to build the lookup key from a UU citation string. + */ +const TYPE_PREFIX_MAP: [RegExp, string][] = [ + [/^Undang-Undang/i, "uu"], + [/^Peraturan\s+Pemerintah/i, "pp"], + [/^Peraturan\s+Presiden/i, "perpres"], + [/^Peraturan\s+Daerah/i, "perda"], + [/^Per(?:p)?pu/i, "perppu"], +]; + +/** + * Extracts number and year from a citation string like + * "Undang-Undang Nomor 13 Tahun 2003" → { number: "13", year: "2003" } + */ +function extractNumberYear(citation: string): { number: string; year: string } | null { + const m = citation.match(/(\d+)\s+Tahun\s+(\d{4})/i); + if (!m) return null; + return { number: m[1], year: m[2] }; +} + +/** + * Builds a slug key like "uu-13-2003" from a citation string. + * Matches the format generated by the DB trigger (generate_work_slug()): + * {type_prefix}-{number}-{year} + * e.g. "Undang-Undang Nomor 13 Tahun 2003" → "uu-13-2003" + * Returns null if the citation cannot be mapped. + */ +function citationToSlugKey(citation: string): string | null { + let typePrefix: string | null = null; + for (const [re, prefix] of TYPE_PREFIX_MAP) { + if (re.test(citation)) { + typePrefix = prefix; + break; + } + } + if (!typePrefix) return null; + + const parsed = extractNumberYear(citation); + if (!parsed) return null; + + return `${typePrefix}-${parsed.number}-${parsed.year}`; +} + +/** + * Tokenizes `text` into an array of plain-text and link tokens. + * + * @param text The raw content_text of a document node + * @param worksLookup Map of slug key → absolute pathname, e.g. { "uu-13-2003": "/peraturan/uu/uu-13-2003" } + */ +export function tokenize(text: string, worksLookup: Record): Token[] { + if (!text) return [{ type: "text", value: "" }]; + + const tokens: Token[] = []; + let lastIndex = 0; + + // Reset regex state (global flag retains lastIndex between calls) + CROSSREF_RE.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = CROSSREF_RE.exec(text)) !== null) { + const [fullMatch, pasalMatch, uuMatch] = match; + const matchStart = match.index; + + // Append preceding plain text + if (matchStart > lastIndex) { + tokens.push({ type: "text", value: text.slice(lastIndex, matchStart) }); + } + + if (pasalMatch) { + // Extract the pasal number from e.g. "Pasal 5A Ayat (2)" / "PASAL 5" → "5A" + const numMatch = pasalMatch.match(/(?:Pasal)\s+(\d+[A-Za-z]?)/i); + const pasalNumber = numMatch ? numMatch[1] : pasalMatch; + tokens.push({ + type: "pasal", + value: pasalMatch, + pasalNumber, + href: `#pasal-${pasalNumber}`, + }); + } else if (uuMatch) { + const slugKey = citationToSlugKey(uuMatch); + if (slugKey && worksLookup[slugKey]) { + tokens.push({ + type: "uu", + value: uuMatch, + href: worksLookup[slugKey], + }); + } else { + // Unresolvable — emit as plain text, do not produce a broken link + tokens.push({ type: "text", value: uuMatch }); + } + } + + lastIndex = matchStart + fullMatch.length; + } + + // Append remaining plain text + if (lastIndex < text.length) { + tokens.push({ type: "text", value: text.slice(lastIndex) }); + } + + return tokens; +} diff --git a/docs/plans/2026-02-27-crossref-links.md b/docs/plans/2026-02-27-crossref-links.md new file mode 100644 index 0000000..8175b76 --- /dev/null +++ b/docs/plans/2026-02-27-crossref-links.md @@ -0,0 +1,615 @@ +# Cross-Reference Hyperlinks Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Render inline hyperlinks in pasal body text for cross-references like "Pasal 5", "Pasal 5 ayat (2)", and "Undang-Undang Nomor 13 Tahun 2003" — linking intra-document references to `#pasal-N` anchors and cross-document references to `/peraturan/[type]/[slug]`. + +**Architecture:** A new `"use client"` component `RichPasalContent` tokenizes `content_text` with regex at render time — no DB changes, no parser changes. A server-side works lookup map (`Record`) is fetched once at the law detail page level and passed down through `PasalList` → `PasalBlock` → `RichPasalContent`. Unresolvable references render as plain text. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Tailwind v4, Vitest + +--- + +## Reference: Key Files + +| File | Role | +|------|------| +| `apps/web/src/components/reader/PasalBlock.tsx` | Renders one pasal — replace plain `
` with `RichPasalContent` | +| `apps/web/src/components/reader/PasalList.tsx` | Renders paginated pasal list — thread `worksLookup` prop through | +| `apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx` | Law detail page — fetch `worksLookup` here, pass down | +| `apps/web/src/lib/crossref.ts` | **New** — tokenizer function + regex (pure, no React) | +| `apps/web/src/components/reader/RichPasalContent.tsx` | **New** — `"use client"` component that renders tokenized content | +| `apps/web/src/lib/__tests__/crossref.test.ts` | **New** — Vitest unit tests for the tokenizer | + +--- + +## Task 1: Write and test the tokenizer (pure function, no React) + +**Files:** +- Create: `apps/web/src/lib/crossref.ts` +- Create: `apps/web/src/lib/__tests__/crossref.test.ts` + +### Step 1: Write the failing tests + +Create `apps/web/src/lib/__tests__/crossref.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { tokenize, type Token } from "@/lib/crossref"; + +const lookup: Record = { + "uu-13-2003": "/peraturan/uu/uu-13-2003", + "pp-74-2008": "/peraturan/pp/pp-74-2008", +}; + +describe("tokenize", () => { + it("returns plain text token for content with no references", () => { + const result = tokenize("Hak dan kewajiban warga negara.", lookup); + expect(result).toEqual([{ type: "text", value: "Hak dan kewajiban warga negara." }]); + }); + + it("detects bare Pasal reference", () => { + const result = tokenize("Sesuai dengan Pasal 5 tentang hak.", lookup); + expect(result).toEqual([ + { type: "text", value: "Sesuai dengan " }, + { type: "pasal", value: "Pasal 5", pasalNumber: "5", href: "#pasal-5" }, + { type: "text", value: " tentang hak." }, + ]); + }); + + it("detects Pasal with letter suffix", () => { + const result = tokenize("Lihat Pasal 5A.", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", pasalNumber: "5A", href: "#pasal-5A" }, + { type: "text" }, + ]); + }); + + it("detects Pasal with ayat", () => { + const result = tokenize("Merujuk Pasal 12 ayat (2) undang-undang ini.", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", value: "Pasal 12 ayat (2)", href: "#pasal-12" }, + { type: "text" }, + ]); + }); + + it("detects Pasal with lowercase", () => { + const result = tokenize("sebagaimana dimaksud dalam pasal 7.", lookup); + expect(result).toMatchObject([ + { type: "text" }, + { type: "pasal", pasalNumber: "7", href: "#pasal-7" }, + { type: "text" }, + ]); + }); + + it("detects resolvable UU cross-reference", () => { + const result = tokenize( + "Sesuai dengan Undang-Undang Nomor 13 Tahun 2003.", + lookup + ); + expect(result).toMatchObject([ + { type: "text" }, + { + type: "uu", + value: "Undang-Undang Nomor 13 Tahun 2003", + href: "/peraturan/uu/uu-13-2003", + }, + { type: "text" }, + ]); + }); + + it("renders unresolvable UU reference as plain text", () => { + const result = tokenize( + "Sesuai Undang-Undang Nomor 99 Tahun 1888.", + lookup + ); + // No UU token — falls through to plain text + expect(result.every((t) => t.type === "text")).toBe(true); + expect(result.map((t) => t.value).join("")).toBe( + "Sesuai Undang-Undang Nomor 99 Tahun 1888." + ); + }); + + it("detects Peraturan Pemerintah cross-reference", () => { + const result = tokenize( + "diatur dalam Peraturan Pemerintah Nomor 74 Tahun 2008.", + lookup + ); + expect(result).toMatchObject([ + { type: "text" }, + { type: "uu", href: "/peraturan/pp/pp-74-2008" }, + { type: "text" }, + ]); + }); + + it("handles both Perpu and Perppu spellings", () => { + const lookup2 = { "perppu-1-2022": "/peraturan/perppu/perppu-1-2022" }; + const r1 = tokenize("Perpu Nomor 1 Tahun 2022", lookup2); + const r2 = tokenize("Perppu Nomor 1 Tahun 2022", lookup2); + // Both should resolve (if in lookup) or both be plain text (if not) + // For this test, neither is in lookup2 by the short form — just confirm no crash + expect(r1).toBeDefined(); + expect(r2).toBeDefined(); + }); + + it("handles multiple references in one string", () => { + const result = tokenize( + "Lihat Pasal 3 dan Pasal 7 ayat (1).", + lookup + ); + const pasalTokens = result.filter((t) => t.type === "pasal"); + expect(pasalTokens).toHaveLength(2); + expect(pasalTokens[0]).toMatchObject({ pasalNumber: "3" }); + expect(pasalTokens[1]).toMatchObject({ pasalNumber: "7" }); + }); + + it("returns single text token for empty string", () => { + expect(tokenize("", lookup)).toEqual([{ type: "text", value: "" }]); + }); +}); +``` + +### Step 2: Run test to verify it fails + +```bash +cd apps/web && npm run test -- --reporter=verbose src/lib/__tests__/crossref.test.ts +``` + +Expected: FAIL with "Cannot find module '@/lib/crossref'" + +### Step 3: Implement the tokenizer + +Create `apps/web/src/lib/crossref.ts`: + +```typescript +/** + * Tokenizer for Indonesian legal cross-references in pasal content text. + * + * Splits a string into alternating plain-text and link tokens without + * modifying the original text. Pure function — no React, no side effects. + */ + +export type Token = + | { type: "text"; value: string } + | { type: "pasal"; value: string; pasalNumber: string; href: string } + | { type: "uu"; value: string; href: string }; + +/** + * Regex that matches Indonesian legal cross-references. + * + * Group 1 (PASAL_RE): Pasal references, optionally with ayat. + * - "Pasal 5", "pasal 5A", "Pasal 12 ayat (2)", "Pasal 1 ayat (a)" + * + * Group 2 (UU_RE): Full regulation citations with number and year. + * - "Undang-Undang Nomor 13 Tahun 2003" + * - "Peraturan Pemerintah Nomor 74 Tahun 2008" + * - "Peraturan Presiden Nomor 12 Tahun 2010" + * - "Perppu Nomor 1 Tahun 2022" / "Perpu Nomor 1 Tahun 2022" + * + * Capture group indices: + * match[1] = pasal match + * match[2] = uu match + */ +const CROSSREF_RE = new RegExp( + // Group 1: Pasal N [ayat (X)] + "((?:Pasal|pasal)\\s+\\d+[A-Za-z]?(?:\\s+ayat\\s+\\([0-9a-zA-Z]+\\))?)" + + "|" + + // Group 2: Full regulation citation + "((?:Undang-Undang|Peraturan\\s+Pemerintah|Peraturan\\s+Presiden|Peraturan\\s+Daerah|Perppu|Perpu)" + + "(?:\\s+Nomor)?\\s+\\d+\\s+[Tt]ahun\\s+\\d{4})", + "g" +); + +/** + * Mapping from citation keyword to slug type prefix. + * Used to build the lookup key from a UU citation string. + */ +const TYPE_PREFIX_MAP: [RegExp, string][] = [ + [/^Undang-Undang/i, "uu"], + [/^Peraturan\s+Pemerintah/i, "pp"], + [/^Peraturan\s+Presiden/i, "perpres"], + [/^Peraturan\s+Daerah/i, "perda"], + [/^Per(?:p)?pu/i, "perppu"], +]; + +/** + * Extracts number and year from a citation string like + * "Undang-Undang Nomor 13 Tahun 2003" → { number: "13", year: "2003" } + */ +function extractNumberYear(citation: string): { number: string; year: string } | null { + const m = citation.match(/(\d+)\s+[Tt]ahun\s+(\d{4})/); + if (!m) return null; + return { number: m[1], year: m[2] }; +} + +/** + * Builds a slug key like "uu-13-2003" from a citation string. + * Returns null if the citation cannot be mapped. + */ +function citationToSlugKey(citation: string): string | null { + let typePrefix: string | null = null; + for (const [re, prefix] of TYPE_PREFIX_MAP) { + if (re.test(citation)) { + typePrefix = prefix; + break; + } + } + if (!typePrefix) return null; + + const parsed = extractNumberYear(citation); + if (!parsed) return null; + + return `${typePrefix}-${parsed.number}-${parsed.year}`; +} + +/** + * Tokenizes `text` into an array of plain-text and link tokens. + * + * @param text The raw content_text of a document node + * @param worksLookup Map of slug key → absolute pathname, e.g. { "uu-13-2003": "/peraturan/uu/uu-13-2003" } + */ +export function tokenize(text: string, worksLookup: Record): Token[] { + if (!text) return [{ type: "text", value: "" }]; + + const tokens: Token[] = []; + let lastIndex = 0; + + // Reset regex state (global flag retains lastIndex between calls) + CROSSREF_RE.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = CROSSREF_RE.exec(text)) !== null) { + const [fullMatch, pasalMatch, uuMatch] = match; + const matchStart = match.index; + + // Append preceding plain text + if (matchStart > lastIndex) { + tokens.push({ type: "text", value: text.slice(lastIndex, matchStart) }); + } + + if (pasalMatch) { + // Extract the pasal number from e.g. "Pasal 5A ayat (2)" → "5A" + const numMatch = pasalMatch.match(/(?:Pasal|pasal)\s+(\d+[A-Za-z]?)/); + const pasalNumber = numMatch ? numMatch[1] : pasalMatch; + tokens.push({ + type: "pasal", + value: pasalMatch, + pasalNumber, + href: `#pasal-${pasalNumber}`, + }); + } else if (uuMatch) { + const slugKey = citationToSlugKey(uuMatch); + if (slugKey && worksLookup[slugKey]) { + tokens.push({ + type: "uu", + value: uuMatch, + href: worksLookup[slugKey], + }); + } else { + // Unresolvable — emit as plain text, do not produce a broken link + tokens.push({ type: "text", value: uuMatch }); + } + } + + lastIndex = matchStart + fullMatch.length; + } + + // Append remaining plain text + if (lastIndex < text.length) { + tokens.push({ type: "text", value: text.slice(lastIndex) }); + } + + if (tokens.length === 0) { + tokens.push({ type: "text", value: text }); + } + + return tokens; +} +``` + +### Step 4: Run tests to verify they pass + +```bash +cd apps/web && npm run test -- --reporter=verbose src/lib/__tests__/crossref.test.ts +``` + +Expected: All tests PASS. + +### Step 5: Commit + +```bash +git add apps/web/src/lib/crossref.ts apps/web/src/lib/__tests__/crossref.test.ts +git commit -m "feat: add legal cross-reference tokenizer with tests" +``` + +--- + +## Task 2: Build the `RichPasalContent` client component + +**Files:** +- Create: `apps/web/src/components/reader/RichPasalContent.tsx` + +No tests for this task — it is a thin presentational wrapper over the already-tested `tokenize` function. Visual correctness verified manually. + +### Step 1: Create the component + +Create `apps/web/src/components/reader/RichPasalContent.tsx`: + +```tsx +"use client"; + +import { Link } from "@/i18n/routing"; +import { tokenize } from "@/lib/crossref"; + +interface RichPasalContentProps { + content: string; + worksLookup: Record; +} + +/** + * Renders pasal body text with inline hyperlinks for cross-references. + * + * - "Pasal N" / "Pasal N ayat (X)" → anchor link to #pasal-N (same page) + * - "Undang-Undang Nomor N Tahun YYYY" → Link to /peraturan/[type]/[slug] + * (only if the work exists in worksLookup; otherwise renders as plain text) + * + * Preserves whitespace-pre-wrap formatting. Pure render — no side effects. + */ +export default function RichPasalContent({ + content, + worksLookup, +}: RichPasalContentProps) { + const tokens = tokenize(content, worksLookup); + + return ( +
+ {tokens.map((token, i) => { + if (token.type === "text") { + return {token.value}; + } + + if (token.type === "pasal") { + return ( + + {token.value} + + ); + } + + if (token.type === "uu") { + return ( + + {token.value} + + ); + } + })} +
+ ); +} +``` + +**Why `` for pasal and `` for UU:** +- Pasal references are hash links (`#pasal-5`) — same page, no routing. A plain `` is correct. +- UU references are cross-page routes — `Link` from `@/i18n/routing` is required for locale prefix handling. + +### Step 2: Verify TypeScript compiles + +```bash +cd apps/web && npx tsc --noEmit +``` + +Expected: No errors related to `RichPasalContent.tsx` or `crossref.ts`. + +### Step 3: Commit + +```bash +git add apps/web/src/components/reader/RichPasalContent.tsx +git commit -m "feat: add RichPasalContent component for inline cross-reference links" +``` + +--- + +## Task 3: Fetch the works lookup map in the law detail page + +**Files:** +- Modify: `apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx` + +The law detail page is a large file. We add a single server-side query that fetches all works as a compact slug → pathname map, then pass it to `LawReaderSection` (or directly to `PasalBlock` / `PasalList`). + +### Step 1: Find where to insert the fetch + +Open `apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx`. Find the section where the main `work` is fetched (around line 277 where `pageUrl` is constructed). The `LawReaderSection` component or inline rendering of `PasalBlock` starts around line 430. + +Look for a pattern like: +```tsx +const pageUrl = `https://pasal.id/peraturan/${type.toLowerCase()}/${slug}`; +``` +or where the Supabase client is created. + +### Step 2: Add the works lookup query + +In the server component body (NOT inside a client component), add: + +```typescript +// Fetch compact works lookup for cross-reference resolution. +// Selects only slug + regulation_types.code — compact enough to pass as prop. +const supabase = await createClient(); +const { data: worksData } = await supabase + .from("works") + .select("slug, regulation_types!inner(code)") + .not("slug", "is", null); + +// Build lookup: "uu-13-2003" → "/peraturan/uu/uu-13-2003" +const worksLookup: Record = {}; +for (const w of worksData ?? []) { + const rt = w.regulation_types as { code: string } | null; + if (w.slug && rt?.code) { + const typeCode = rt.code.toLowerCase(); + worksLookup[w.slug] = `/peraturan/${typeCode}/${w.slug}`; + } +} +``` + +**Note on Supabase typing:** The `.select("slug, regulation_types!inner(code)")` join may return `regulation_types` as an array. Check the actual type. If it's an array, use `rt[0]?.code`. If TypeScript complains, add an explicit generic: +```typescript +.select<"slug, regulation_types!inner(code)", { slug: string; regulation_types: { code: string } }>("slug, regulation_types!inner(code)") +``` + +**Note on performance:** The `works` table has at most a few thousand rows. This query returns two columns — compact enough to be negligible. ISR means this page is cached; the query runs once per cache period, not per user. + +### Step 3: Pass `worksLookup` down + +Find where `LawReaderSection` (or equivalent inline rendering) is called, and add `worksLookup` to its props. If there is no `LawReaderSection` component and `PasalBlock`/`PasalList` are rendered directly, pass `worksLookup` directly to those. + +Search for `; +``` + +Find where `PasalBlock` is rendered inside `PasalList` and pass `worksLookup={worksLookup}` to it. + +### Step 2: Update `PasalBlock` to accept `worksLookup` and use `RichPasalContent` + +Open `apps/web/src/components/reader/PasalBlock.tsx`. + +**Add to `PasalBlockProps`:** +```typescript +worksLookup: Record; +``` + +**Add to destructured props:** +```typescript +export default function PasalBlock({ pasal, pathname, pageUrl, worksLookup }: PasalBlockProps) { +``` + +**Replace line 51:** +```tsx +// Before: +
{content}
+ +// After: + +``` + +**Add the import at the top of the file:** +```typescript +import RichPasalContent from "@/components/reader/RichPasalContent"; +``` + +**Note:** `PasalBlock` uses `useTranslations` which makes it a quasi-client component in spirit, but it is NOT marked `"use client"`. Check whether it already has this directive. If it does, the import of `RichPasalContent` is fine. If it doesn't, it stays a Server Component — which is also fine, because `RichPasalContent` itself is `"use client"` and can be imported into Server Components. + +### Step 3: Verify TypeScript compiles and tests pass + +```bash +cd apps/web && npx tsc --noEmit && npm run test +``` + +Expected: 0 type errors, all 11+ tests pass. + +### Step 4: Commit + +```bash +git add apps/web/src/components/reader/PasalList.tsx apps/web/src/components/reader/PasalBlock.tsx +git commit -m "feat: wire worksLookup through PasalList and PasalBlock to enable inline cross-reference links" +``` + +--- + +## Task 5: Manual verification + +No automated tests for rendering — verify visually. + +### Step 1: Start the dev server + +```bash +cd apps/web && npm run dev +``` + +### Step 2: Open a law detail page + +Navigate to `http://localhost:3000/peraturan/uu/uu-13-2003` (or any UU with cross-references in its pasal content). + +### Step 3: Check expected behavior + +- [ ] Body text renders normally (no layout change, `whitespace-pre-wrap` preserved) +- [ ] "Pasal N" patterns appear as green (`text-primary`) underlined links +- [ ] Clicking a "Pasal N" link scrolls to `#pasal-N` on the same page and triggers the green highlight flash (from `HashHighlighter`) +- [ ] A UU citation that exists in the DB (e.g. "Undang-Undang Nomor 13 Tahun 2003" referenced inside another law) appears as a link to `/peraturan/uu/uu-13-2003` +- [ ] A UU citation that does NOT exist in the DB renders as plain text (no broken link) +- [ ] The page is not broken for pasals with no cross-references + +### Step 4: Check print layout + +- [ ] Links are still readable when printing (browsers underline links in print by default — acceptable) + +### Step 5: Final commit (if any fixes were needed) + +```bash +git add -A && git commit -m "fix: address visual issues found during manual verification" +``` + +--- + +## Task 6: Run full test suite and lint + +```bash +cd apps/web && npm run test && npm run lint +``` + +Expected: All tests pass, no lint errors. + +If lint errors appear, fix them before proceeding. + +```bash +git add -A && git commit -m "fix: resolve lint warnings" +``` + +--- + +## Done + +Worktree: `.worktrees/feature-crossref-links` +Branch: `feature/crossref-links` + +When complete, use the **superpowers:finishing-a-development-branch** skill to create the PR.