From b9c067f1934881588961c1ed7ba12b137208ea9b Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 01:16:20 +0700 Subject: [PATCH 01/19] fix(reader): load all pasals SSR for structured (BAB-based) laws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usePagination flag was based solely on pasal count (>= 100), but client-side infinite scroll doesn't work when pasals are rendered per-BAB server-side — only the initial 30 SSR pasals were ever shown under their BABs, leaving all subsequent BABs empty. Fix: skip client pagination when the law has BABs/aturan/lampiran structure nodes, and always fetch the full pasal set SSR instead. Flat laws (no BABs) with 100+ pasals still use infinite scroll. Co-authored-by: Claude --- .../src/app/[locale]/peraturan/[type]/[slug]/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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..83de592 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -252,12 +252,18 @@ async function LawReaderSection({ .order("id"), ]); - const usePagination = (totalPasalCount || 0) >= 100; + // Structured (BAB-based) 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 BABs) + // with a large pasal count. + const hasBABs = (structure || []).some( + (n) => n.node_type === "bab" || n.node_type === "aturan" || n.node_type === "lampiran", + ); + const usePagination = (totalPasalCount || 0) >= 100 && !hasBABs; 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") From 0939875397420e641fa7d473ef21c98c1ad09fc1 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 21:32:50 +0700 Subject: [PATCH 02/19] fix(reader): broaden hasStructure check to cover all structural node types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original hasBABs check only tested for bab/aturan/lampiran, but the BAB rendering path fires on any structural node (babNodes.length > 0 includes bagian and paragraf nodes too). A law with bagian-only structure and 100+ pasals would still regress under the previous check. Replace hasBABs with hasStructure = structure.length > 0 — aligns the pagination guard directly with the rendering condition. Co-authored-by: Claude --- .../app/[locale]/peraturan/[type]/[slug]/page.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 83de592..d3b3078 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -252,13 +252,12 @@ async function LawReaderSection({ .order("id"), ]); - // Structured (BAB-based) 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 BABs) - // with a large pasal count. - const hasBABs = (structure || []).some( - (n) => n.node_type === "bab" || n.node_type === "aturan" || n.node_type === "lampiran", - ); - const usePagination = (totalPasalCount || 0) >= 100 && !hasBABs; + // 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; From 446166f6e4249abbda53421b3b22e04ba72c4648 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 23:58:39 +0700 Subject: [PATCH 03/19] fix: filter empty structural nodes (phantom BAB sections) Laws like UU 6/2023 (Ciptaker) wrap a full law as LAMPIRAN. The parser picks up the LAMPIRAN's table of contents as real BAB nodes, producing heading-only sections with no Pasal content in the reader. Add a lightweight parallel query fetching all parent_id values for pasals of the current work. Build structuralIdsWithPasals Set. Filter babNodes so only top-level structural nodes (BAB/aturan/lampiran) with at least one pasal directly or via a direct child section are rendered. Sub-sections (Bagian/Paragraf, parent_id != null) are kept unconditionally. Co-authored-by: Claude --- .../[locale]/peraturan/[type]/[slug]/page.tsx | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) 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 d3b3078..a479ef6 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 }] = await Promise.all([ supabase .from("document_nodes") .select("id", { count: "exact", head: true }) @@ -250,6 +250,15 @@ 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), ]); // Structured laws must always load all pasals SSR so the BAB-grouping logic has the @@ -292,19 +301,46 @@ 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. + // 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 || []; + // Filter out structural nodes (BABs, Bagians) 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. + // Only top-level structural nodes (BAB / aturan / lampiran — those without a parent) are + // filtered; sub-sections (Bagian, Paragraf) are kept as-is under their parent BAB. + const babNodes = allStructuralNodes.filter((node) => { + // Keep sub-sections unconditionally — they're filtered indirectly via their parent BAB. + if (node.parent_id !== null) return true; + // For top-level structural nodes, keep only those that have at least one pasal + // directly or through any of their direct children (Bagian/Paragraf). + const childIds = new Set( + allStructuralNodes.filter((n) => n.parent_id === node.id).map((n) => n.id), + ); + return ( + structuralIdsWithPasals.has(node.id) || + [...childIds].some((id) => structuralIdsWithPasals.has(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), ); From 35f080ffc36068e3e316f93cc4f052db80dbc9fd Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sun, 1 Mar 2026 00:13:32 +0700 Subject: [PATCH 04/19] fix: handle arbitrarily deep BAB nesting in phantom-BAB filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous filter only checked direct children of each top-level BAB node when determining whether it had pasal content. This missed the BAB → Bagian → Paragraf → Pasal nesting depth documented in the schema, causing those BABs to be silently filtered out. Replace with a parent→children map + recursive hasDescendantPasal() that walks the full subtree at any depth, so a BAB is only filtered if no structural node in its entire subtree is a direct parent of a pasal. Co-authored-by: Claude --- .../[locale]/peraturan/[type]/[slug]/page.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) 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 a479ef6..1c355ea 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -301,7 +301,7 @@ 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. + // 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), @@ -311,6 +311,25 @@ async function LawReaderSection({ 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) 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 @@ -320,15 +339,9 @@ async function LawReaderSection({ const babNodes = allStructuralNodes.filter((node) => { // Keep sub-sections unconditionally — they're filtered indirectly via their parent BAB. if (node.parent_id !== null) return true; - // For top-level structural nodes, keep only those that have at least one pasal - // directly or through any of their direct children (Bagian/Paragraf). - const childIds = new Set( - allStructuralNodes.filter((n) => n.parent_id === node.id).map((n) => n.id), - ); - return ( - structuralIdsWithPasals.has(node.id) || - [...childIds].some((id) => structuralIdsWithPasals.has(id)) - ); + // For top-level structural nodes, keep only those with at least one pasal at any depth + // (handles BAB → Bagian → Paragraf → Pasal nesting, not just direct children). + return hasDescendantPasal(node.id); }); const mainContent = ( From fd3244d05a387c1a94af53f61427bdea6ec161b5 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sun, 1 Mar 2026 00:20:51 +0700 Subject: [PATCH 05/19] fix: filter phantom LAMPIRAN child BABs by applying hasDescendantPasal to all structural nodes Remove the unconditional short-circuit that passed any structural node with a non-null parent_id through the babNodes filter. Phantom TOC-BABs inside a LAMPIRAN have parent_id = lampiran_db_id (non-null), so the guard was letting them through despite having zero pasal descendants. Applying hasDescendantPasal() to every structural node regardless of depth fixes UU 6/2023 (Cipta Kerja): the duplicated TOC BABs parsed from the LAMPIRAN TOC pages are now correctly filtered out while real BABs and their Bagian/Paragraf sub-sections remain (they ARE in structuralIdsWithPasals). Co-authored-by: Claude --- .../[locale]/peraturan/[type]/[slug]/page.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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 1c355ea..4b48afd 100644 --- a/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx @@ -330,19 +330,20 @@ async function LawReaderSection({ return false; } - // Filter out structural nodes (BABs, Bagians) that have no pasal content in the DB. + // 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. - // Only top-level structural nodes (BAB / aturan / lampiran — those without a parent) are - // filtered; sub-sections (Bagian, Paragraf) are kept as-is under their parent BAB. - const babNodes = allStructuralNodes.filter((node) => { - // Keep sub-sections unconditionally — they're filtered indirectly via their parent BAB. - if (node.parent_id !== null) return true; - // For top-level structural nodes, keep only those with at least one pasal at any depth - // (handles BAB → Bagian → Paragraf → Pasal nesting, not just direct children). - return hasDescendantPasal(node.id); - }); + // + // 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 = ( <> From c54c552ce1537081eb6f9c18d6471368c275b76c Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 22:38:42 +0700 Subject: [PATCH 06/19] chore: ignore .worktrees directory Co-authored-by: Claude --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From c0fd841c52eadf726f3df5faf1e9e2670528d503 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 22:41:21 +0700 Subject: [PATCH 07/19] docs: add cross-reference links implementation plan Co-authored-by: Claude --- docs/plans/2026-02-27-crossref-links.md | 615 ++++++++++++++++++++++++ 1 file changed, 615 insertions(+) create mode 100644 docs/plans/2026-02-27-crossref-links.md 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. From cda87aa79b8de5d583e221b54758c4f8e8872d45 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 22:44:15 +0700 Subject: [PATCH 08/19] feat: add legal cross-reference tokenizer with tests Co-authored-by: Claude --- apps/web/src/lib/__tests__/crossref.test.ts | 112 ++++++++++++++++ apps/web/src/lib/crossref.ts | 139 ++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 apps/web/src/lib/__tests__/crossref.test.ts create mode 100644 apps/web/src/lib/crossref.ts 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..f8842fb --- /dev/null +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -0,0 +1,112 @@ +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 + ); + 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); + 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: "" }]); + }); +}); diff --git a/apps/web/src/lib/crossref.ts b/apps/web/src/lib/crossref.ts new file mode 100644 index 0000000..e3fdff4 --- /dev/null +++ b/apps/web/src/lib/crossref.ts @@ -0,0 +1,139 @@ +/** + * 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" + */ +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; +} From 051a636dabefe3d6911d834f2990321bd5a54b72 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 22:49:23 +0700 Subject: [PATCH 09/19] fix: strengthen Perpu/Perppu test and remove unreachable dead code Co-authored-by: Claude --- apps/web/src/lib/__tests__/crossref.test.ts | 9 +++++++-- apps/web/src/lib/crossref.ts | 4 ---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/__tests__/crossref.test.ts b/apps/web/src/lib/__tests__/crossref.test.ts index f8842fb..d53ebfb 100644 --- a/apps/web/src/lib/__tests__/crossref.test.ts +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -91,8 +91,13 @@ describe("tokenize", () => { 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); - expect(r1).toBeDefined(); - expect(r2).toBeDefined(); + // 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", () => { diff --git a/apps/web/src/lib/crossref.ts b/apps/web/src/lib/crossref.ts index e3fdff4..e0d0eea 100644 --- a/apps/web/src/lib/crossref.ts +++ b/apps/web/src/lib/crossref.ts @@ -131,9 +131,5 @@ export function tokenize(text: string, worksLookup: Record): Tok tokens.push({ type: "text", value: text.slice(lastIndex) }); } - if (tokens.length === 0) { - tokens.push({ type: "text", value: text }); - } - return tokens; } From 569c5b0eb95e0044ebfd7c9794fdd47c00377f4b Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 22:51:46 +0700 Subject: [PATCH 10/19] feat: add RichPasalContent component for inline cross-reference links Co-authored-by: Claude --- .../components/reader/RichPasalContent.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/web/src/components/reader/RichPasalContent.tsx diff --git a/apps/web/src/components/reader/RichPasalContent.tsx b/apps/web/src/components/reader/RichPasalContent.tsx new file mode 100644 index 0000000..adad279 --- /dev/null +++ b/apps/web/src/components/reader/RichPasalContent.tsx @@ -0,0 +1,61 @@ +"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 ( + [0]["href"]} + className="text-primary underline-offset-2 hover:underline" + > + {token.value} + + ); + } + + return null; + })} +
+ ); +} From 736bf4b4798b60dbc7213d6443456caf9699bb7f Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 22:54:56 +0700 Subject: [PATCH 11/19] fix: improve link accessibility and use Fragment for text tokens in RichPasalContent Co-authored-by: Claude --- apps/web/src/components/reader/RichPasalContent.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/reader/RichPasalContent.tsx b/apps/web/src/components/reader/RichPasalContent.tsx index adad279..26af8a5 100644 --- a/apps/web/src/components/reader/RichPasalContent.tsx +++ b/apps/web/src/components/reader/RichPasalContent.tsx @@ -1,5 +1,6 @@ "use client"; +import { Fragment } from "react"; import { Link } from "@/i18n/routing"; import { tokenize } from "@/lib/crossref"; @@ -27,7 +28,7 @@ export default function RichPasalContent({
{tokens.map((token, i) => { if (token.type === "text") { - return {token.value}; + return {token.value}; } if (token.type === "pasal") { @@ -35,7 +36,7 @@ export default function RichPasalContent({ {token.value} @@ -46,8 +47,10 @@ export default function RichPasalContent({ return ( [0]["href"]} - className="text-primary underline-offset-2 hover:underline" + className="text-primary underline-offset-4 hover:underline rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" > {token.value} From c52ea5087e68a1e4e40afc50d418a95ac1cb93c8 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 22:57:24 +0700 Subject: [PATCH 12/19] feat: wire worksLookup through page and reader components for cross-reference links Co-authored-by: Claude --- .../[locale]/peraturan/[type]/[slug]/page.tsx | 21 ++++++++++++++++--- apps/web/src/components/reader/PasalBlock.tsx | 6 ++++-- apps/web/src/components/reader/PasalList.tsx | 3 +++ 3 files changed, 25 insertions(+), 5 deletions(-) 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 4b48afd..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 }, { data: pasalParentIds }] = 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 }) @@ -259,8 +259,22 @@ async function LawReaderSection({ .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), ]); + // 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: @@ -376,7 +390,7 @@ async function LawReaderSection({ )} {allBabPasals.map((pasal) => ( - + ))} ); @@ -391,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} /> ))} From 2ebbabaf25f0c436c05b99701bdb5af128bed7c7 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 23:02:00 +0700 Subject: [PATCH 13/19] fix: remove unused Token type import from crossref test Co-authored-by: Claude --- apps/web/src/lib/__tests__/crossref.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/__tests__/crossref.test.ts b/apps/web/src/lib/__tests__/crossref.test.ts index d53ebfb..5254496 100644 --- a/apps/web/src/lib/__tests__/crossref.test.ts +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { tokenize, type Token } from "@/lib/crossref"; +import { tokenize } from "@/lib/crossref"; const lookup: Record = { "uu-13-2003": "/peraturan/uu/uu-13-2003", From 74c00242eaab40c22fce6e194e9e088be2866196 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Fri, 27 Feb 2026 23:54:13 +0700 Subject: [PATCH 14/19] style: dotted underline on crossref links, solid on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves discoverability of cross-reference hyperlinks in pasal body text — always-dotted underline at rest makes links visible without the heavy weight of a permanent solid underline on dense legal prose. Co-authored-by: Claude --- apps/web/src/components/reader/RichPasalContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/reader/RichPasalContent.tsx b/apps/web/src/components/reader/RichPasalContent.tsx index 26af8a5..de983a1 100644 --- a/apps/web/src/components/reader/RichPasalContent.tsx +++ b/apps/web/src/components/reader/RichPasalContent.tsx @@ -36,7 +36,7 @@ export default function RichPasalContent({ {token.value} @@ -50,7 +50,7 @@ export default function RichPasalContent({ // token.href is a string path from DB lookup (e.g. "/peraturan/uu/uu-13-2003"). // next-intl's Link expects a typed route — cast is safe at runtime. href={token.href as Parameters[0]["href"]} - className="text-primary underline-offset-4 hover:underline rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + 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} From a38c246d8c5765baff288f71a866266460934a65 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 00:02:14 +0700 Subject: [PATCH 15/19] test: expand crossref tokenizer coverage for edge cases Add tests for Perpres/Perda citations, optional Nomor keyword, mixed Pasal+UU in one string, trailing text after last reference, and unknown regulation types falling back to plain text. Co-authored-by: Claude --- apps/web/src/lib/__tests__/crossref.test.ts | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apps/web/src/lib/__tests__/crossref.test.ts b/apps/web/src/lib/__tests__/crossref.test.ts index 5254496..a8fe914 100644 --- a/apps/web/src/lib/__tests__/crossref.test.ts +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -114,4 +114,61 @@ describe("tokenize", () => { 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." + ); + }); }); From 44a9b427b50e8e11b1c845197e702d0052421223 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 01:29:09 +0700 Subject: [PATCH 16/19] fix: capital-A Ayat and correct slug format for cross-UU links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Match both 'ayat' and 'Ayat' in the Pasal regex — scanned PDFs vary in capitalization (e.g. 'Pasal 90 Ayat (3)' was not linked before) - citationToSlugKey now produces 'uu-no-13-tahun-2003' format to match the slug column generated by the DB trigger (was producing 'uu-13-2003') - Update all test fixtures and add new Ayat capital-case test Co-authored-by: Claude --- apps/web/src/lib/__tests__/crossref.test.ts | 36 +++++++++++++-------- apps/web/src/lib/crossref.ts | 13 ++++---- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/web/src/lib/__tests__/crossref.test.ts b/apps/web/src/lib/__tests__/crossref.test.ts index a8fe914..32e025d 100644 --- a/apps/web/src/lib/__tests__/crossref.test.ts +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest"; import { tokenize } from "@/lib/crossref"; const lookup: Record = { - "uu-13-2003": "/peraturan/uu/uu-13-2003", - "pp-74-2008": "/peraturan/pp/pp-74-2008", + "uu-no-13-tahun-2003": "/peraturan/uu/uu-no-13-tahun-2003", + "pp-no-74-tahun-2008": "/peraturan/pp/pp-no-74-tahun-2008", }; describe("tokenize", () => { @@ -58,7 +58,7 @@ describe("tokenize", () => { { type: "uu", value: "Undang-Undang Nomor 13 Tahun 2003", - href: "/peraturan/uu/uu-13-2003", + href: "/peraturan/uu/uu-no-13-tahun-2003", }, { type: "text" }, ]); @@ -82,13 +82,13 @@ describe("tokenize", () => { ); expect(result).toMatchObject([ { type: "text" }, - { type: "uu", href: "/peraturan/pp/pp-74-2008" }, + { type: "uu", href: "/peraturan/pp/pp-no-74-tahun-2008" }, { type: "text" }, ]); }); it("handles both Perpu and Perppu spellings", () => { - const lookup2 = { "perppu-1-2022": "/peraturan/perppu/perppu-1-2022" }; + const lookup2 = { "perppu-no-1-tahun-2022": "/peraturan/perppu/perppu-no-1-tahun-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 @@ -96,8 +96,8 @@ describe("tokenize", () => { 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" }); + expect(uuTokens1[0]).toMatchObject({ href: "/peraturan/perppu/perppu-no-1-tahun-2022" }); + expect(uuTokens2[0]).toMatchObject({ href: "/peraturan/perppu/perppu-no-1-tahun-2022" }); }); it("handles multiple references in one string", () => { @@ -116,21 +116,21 @@ describe("tokenize", () => { }); it("detects Peraturan Presiden cross-reference", () => { - const lookup2 = { "perpres-12-2010": "/peraturan/perpres/perpres-12-2010" }; + const lookup2 = { "perpres-no-12-tahun-2010": "/peraturan/perpres/perpres-no-12-tahun-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: "uu", href: "/peraturan/perpres/perpres-no-12-tahun-2010" }, { type: "text" }, ]); }); it("detects Peraturan Daerah cross-reference", () => { - const lookup2 = { "perda-5-2015": "/peraturan/perda/perda-5-2015" }; + const lookup2 = { "perda-no-5-tahun-2015": "/peraturan/perda/perda-no-5-tahun-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: "uu", href: "/peraturan/perda/perda-no-5-tahun-2015" }, { type: "text" }, ]); }); @@ -139,7 +139,7 @@ describe("tokenize", () => { // 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: "uu", href: "/peraturan/uu/uu-no-13-tahun-2003" }, { type: "text", value: " berlaku." }, ]); }); @@ -154,7 +154,7 @@ describe("tokenize", () => { expect(pasalTokens).toHaveLength(1); expect(uuTokens).toHaveLength(1); expect(pasalTokens[0]).toMatchObject({ pasalNumber: "5" }); - expect(uuTokens[0]).toMatchObject({ href: "/peraturan/uu/uu-13-2003" }); + expect(uuTokens[0]).toMatchObject({ href: "/peraturan/uu/uu-no-13-tahun-2003" }); }); it("preserves trailing plain text after last reference", () => { @@ -171,4 +171,14 @@ describe("tokenize", () => { "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" }, + ]); + }); }); diff --git a/apps/web/src/lib/crossref.ts b/apps/web/src/lib/crossref.ts index e0d0eea..e56685b 100644 --- a/apps/web/src/lib/crossref.ts +++ b/apps/web/src/lib/crossref.ts @@ -14,7 +14,7 @@ export type Token = * 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)" + * - "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" @@ -23,8 +23,8 @@ export type Token = * - "Perppu Nomor 1 Tahun 2022" / "Perpu Nomor 1 Tahun 2022" */ 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 1: Pasal N [ayat (X)] — [Aa]yat handles both capitalizations found in scanned PDFs + "((?:Pasal|pasal)\\s+\\d+[A-Za-z]?(?:\\s+[Aa]yat\\s+\\([0-9a-zA-Z]+\\))?)" + "|" + // Group 2: Full regulation citation "((?:Undang-Undang|Peraturan\\s+Pemerintah|Peraturan\\s+Presiden|Peraturan\\s+Daerah|Perppu|Perpu)" + @@ -55,7 +55,8 @@ function extractNumberYear(citation: string): { number: string; year: string } | } /** - * Builds a slug key like "uu-13-2003" from a citation string. + * Builds a slug key like "uu-no-13-tahun-2003" from a citation string. + * Matches the format generated by the DB trigger (generate_work_slug()). * Returns null if the citation cannot be mapped. */ function citationToSlugKey(citation: string): string | null { @@ -71,14 +72,14 @@ function citationToSlugKey(citation: string): string | null { const parsed = extractNumberYear(citation); if (!parsed) return null; - return `${typePrefix}-${parsed.number}-${parsed.year}`; + return `${typePrefix}-no-${parsed.number}-tahun-${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" } + * @param worksLookup Map of slug key → absolute pathname, e.g. { "uu-no-13-tahun-2003": "/peraturan/uu/uu-no-13-tahun-2003" } */ export function tokenize(text: string, worksLookup: Record): Token[] { if (!text) return [{ type: "text", value: "" }]; From 2054cda0f4572666addc3f67c8bfe79d23568a9e Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 01:38:20 +0700 Subject: [PATCH 17/19] fix: case-insensitive regex for UNDANG-UNDANG and PASAL variants Scanned PDFs frequently use all-caps UNDANG-UNDANG, PASAL, PERATURAN etc. Add 'i' flag to CROSSREF_RE so all capitalisation variants are matched. Also adds two new tests covering UNDANG-UNDANG and PASAL all-caps forms. Co-authored-by: Claude --- apps/web/src/lib/__tests__/crossref.test.ts | 25 +++++++++++++++++++++ apps/web/src/lib/crossref.ts | 19 +++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/__tests__/crossref.test.ts b/apps/web/src/lib/__tests__/crossref.test.ts index 32e025d..ab875c1 100644 --- a/apps/web/src/lib/__tests__/crossref.test.ts +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -181,4 +181,29 @@ describe("tokenize", () => { { 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-no-13-tahun-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 index e56685b..28b9787 100644 --- a/apps/web/src/lib/crossref.ts +++ b/apps/web/src/lib/crossref.ts @@ -17,19 +17,22 @@ export type Token = * - "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" / "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)] — [Aa]yat handles both capitalizations found in scanned PDFs - "((?:Pasal|pasal)\\s+\\d+[A-Za-z]?(?:\\s+[Aa]yat\\s+\\([0-9a-zA-Z]+\\))?)" + + // 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+[Tt]ahun\\s+\\d{4})", - "g" + "(?:\\s+Nomor)?\\s+\\d+\\s+Tahun\\s+\\d{4})", + "gi" ); /** @@ -49,7 +52,7 @@ const TYPE_PREFIX_MAP: [RegExp, string][] = [ * "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})/); + const m = citation.match(/(\d+)\s+Tahun\s+(\d{4})/i); if (!m) return null; return { number: m[1], year: m[2] }; } @@ -101,8 +104,8 @@ export function tokenize(text: string, worksLookup: Record): Tok } 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]?)/); + // 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", From 0cfddfe428ffa1f8d213c3a6edd285c3ed81b9b0 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 21:41:26 +0700 Subject: [PATCH 18/19] fix(crossref): align slug key format with DB trigger output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit citationToSlugKey() was returning 'uu-no-13-tahun-2003' but the DB trigger generate_work_slug() (migration 053) produces 'uu-13-2003'. The worksLookup map is keyed by raw DB slugs, so every cross-document citation was silently falling back to plain text — never resolving to a link. Fix: remove '-no-' and '-tahun-' from the returned key format so it matches {type}-{number}-{year}. Update tests to use the correct slug fixtures. Co-authored-by: Claude --- apps/web/src/lib/__tests__/crossref.test.ts | 30 +++++++++++---------- apps/web/src/lib/crossref.ts | 8 +++--- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/__tests__/crossref.test.ts b/apps/web/src/lib/__tests__/crossref.test.ts index ab875c1..3057e78 100644 --- a/apps/web/src/lib/__tests__/crossref.test.ts +++ b/apps/web/src/lib/__tests__/crossref.test.ts @@ -1,9 +1,11 @@ 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-no-13-tahun-2003": "/peraturan/uu/uu-no-13-tahun-2003", - "pp-no-74-tahun-2008": "/peraturan/pp/pp-no-74-tahun-2008", + "uu-13-2003": "/peraturan/uu/uu-13-2003", + "pp-74-2008": "/peraturan/pp/pp-74-2008", }; describe("tokenize", () => { @@ -58,7 +60,7 @@ describe("tokenize", () => { { type: "uu", value: "Undang-Undang Nomor 13 Tahun 2003", - href: "/peraturan/uu/uu-no-13-tahun-2003", + href: "/peraturan/uu/uu-13-2003", }, { type: "text" }, ]); @@ -82,13 +84,13 @@ describe("tokenize", () => { ); expect(result).toMatchObject([ { type: "text" }, - { type: "uu", href: "/peraturan/pp/pp-no-74-tahun-2008" }, + { type: "uu", href: "/peraturan/pp/pp-74-2008" }, { type: "text" }, ]); }); it("handles both Perpu and Perppu spellings", () => { - const lookup2 = { "perppu-no-1-tahun-2022": "/peraturan/perppu/perppu-no-1-tahun-2022" }; + 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 @@ -96,8 +98,8 @@ describe("tokenize", () => { const uuTokens2 = r2.filter((t) => t.type === "uu"); expect(uuTokens1).toHaveLength(1); expect(uuTokens2).toHaveLength(1); - expect(uuTokens1[0]).toMatchObject({ href: "/peraturan/perppu/perppu-no-1-tahun-2022" }); - expect(uuTokens2[0]).toMatchObject({ href: "/peraturan/perppu/perppu-no-1-tahun-2022" }); + 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", () => { @@ -116,21 +118,21 @@ describe("tokenize", () => { }); it("detects Peraturan Presiden cross-reference", () => { - const lookup2 = { "perpres-no-12-tahun-2010": "/peraturan/perpres/perpres-no-12-tahun-2010" }; + 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-no-12-tahun-2010" }, + { type: "uu", href: "/peraturan/perpres/perpres-12-2010" }, { type: "text" }, ]); }); it("detects Peraturan Daerah cross-reference", () => { - const lookup2 = { "perda-no-5-tahun-2015": "/peraturan/perda/perda-no-5-tahun-2015" }; + 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-no-5-tahun-2015" }, + { type: "uu", href: "/peraturan/perda/perda-5-2015" }, { type: "text" }, ]); }); @@ -139,7 +141,7 @@ describe("tokenize", () => { // 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-no-13-tahun-2003" }, + { type: "uu", href: "/peraturan/uu/uu-13-2003" }, { type: "text", value: " berlaku." }, ]); }); @@ -154,7 +156,7 @@ describe("tokenize", () => { expect(pasalTokens).toHaveLength(1); expect(uuTokens).toHaveLength(1); expect(pasalTokens[0]).toMatchObject({ pasalNumber: "5" }); - expect(uuTokens[0]).toMatchObject({ href: "/peraturan/uu/uu-no-13-tahun-2003" }); + expect(uuTokens[0]).toMatchObject({ href: "/peraturan/uu/uu-13-2003" }); }); it("preserves trailing plain text after last reference", () => { @@ -192,7 +194,7 @@ describe("tokenize", () => { { type: "uu", value: "UNDANG-UNDANG Nomor 13 Tahun 2003", - href: "/peraturan/uu/uu-no-13-tahun-2003", + href: "/peraturan/uu/uu-13-2003", }, { type: "text" }, ]); diff --git a/apps/web/src/lib/crossref.ts b/apps/web/src/lib/crossref.ts index 28b9787..df1caf1 100644 --- a/apps/web/src/lib/crossref.ts +++ b/apps/web/src/lib/crossref.ts @@ -58,8 +58,10 @@ function extractNumberYear(citation: string): { number: string; year: string } | } /** - * Builds a slug key like "uu-no-13-tahun-2003" from a citation string. - * Matches the format generated by the DB trigger (generate_work_slug()). + * 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 { @@ -75,7 +77,7 @@ function citationToSlugKey(citation: string): string | null { const parsed = extractNumberYear(citation); if (!parsed) return null; - return `${typePrefix}-no-${parsed.number}-tahun-${parsed.year}`; + return `${typePrefix}-${parsed.number}-${parsed.year}`; } /** From 3fa3aaa113b81de77fd61b7feddab2ad46870d41 Mon Sep 17 00:00:00 2001 From: Daffa Romero Date: Sat, 28 Feb 2026 21:53:15 +0700 Subject: [PATCH 19/19] fix(crossref): update stale JSDoc example to correct slug format Co-authored-by: Claude --- apps/web/src/lib/crossref.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/crossref.ts b/apps/web/src/lib/crossref.ts index df1caf1..3370a97 100644 --- a/apps/web/src/lib/crossref.ts +++ b/apps/web/src/lib/crossref.ts @@ -84,7 +84,7 @@ function citationToSlugKey(citation: string): string | null { * 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-no-13-tahun-2003": "/peraturan/uu/uu-no-13-tahun-2003" } + * @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: "" }];