Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b9c067f
fix(reader): load all pasals SSR for structured (BAB-based) laws
daffaromero Feb 27, 2026
0939875
fix(reader): broaden hasStructure check to cover all structural node …
daffaromero Feb 28, 2026
446166f
fix: filter empty structural nodes (phantom BAB sections)
daffaromero Feb 28, 2026
35f080f
fix: handle arbitrarily deep BAB nesting in phantom-BAB filter
daffaromero Feb 28, 2026
fd3244d
fix: filter phantom LAMPIRAN child BABs by applying hasDescendantPasa…
daffaromero Feb 28, 2026
c54c552
chore: ignore .worktrees directory
daffaromero Feb 27, 2026
c0fd841
docs: add cross-reference links implementation plan
daffaromero Feb 27, 2026
cda87aa
feat: add legal cross-reference tokenizer with tests
daffaromero Feb 27, 2026
051a636
fix: strengthen Perpu/Perppu test and remove unreachable dead code
daffaromero Feb 27, 2026
569c5b0
feat: add RichPasalContent component for inline cross-reference links
daffaromero Feb 27, 2026
736bf4b
fix: improve link accessibility and use Fragment for text tokens in R…
daffaromero Feb 27, 2026
c52ea50
feat: wire worksLookup through page and reader components for cross-r…
daffaromero Feb 27, 2026
2ebbaba
fix: remove unused Token type import from crossref test
daffaromero Feb 27, 2026
74c0024
style: dotted underline on crossref links, solid on hover
daffaromero Feb 27, 2026
a38c246
test: expand crossref tokenizer coverage for edge cases
daffaromero Feb 27, 2026
44a9b42
fix: capital-A Ayat and correct slug format for cross-UU links
daffaromero Feb 27, 2026
2054cda
fix: case-insensitive regex for UNDANG-UNDANG and PASAL variants
daffaromero Feb 27, 2026
0cfddfe
fix(crossref): align slug key format with DB trigger output
daffaromero Feb 28, 2026
3fa3aaa
fix(crossref): update stale JSDoc example to correct slug format
daffaromero Feb 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ supabase/.temp/
.cursor/
.vercel

# Git worktrees
.worktrees/

# OS
.DS_Store
Thumbs.db
Expand Down
86 changes: 78 additions & 8 deletions apps/web/src/app/[locale]/peraturan/[type]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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<string, { slug: string | null; regulation_types: { code: string } | { code: string }[] }>("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<string, string> = {};
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")
Expand Down Expand Up @@ -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<number, number[]>();
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),
);
Expand All @@ -321,7 +390,7 @@ async function LawReaderSection({
)}

{allBabPasals.map((pasal) => (
<PasalBlock key={pasal.id} pasal={pasal} pathname={pathname} pageUrl={pageUrl} />
<PasalBlock key={pasal.id} pasal={pasal} pathname={pathname} pageUrl={pageUrl} worksLookup={worksLookup} />
))}
</section>
);
Expand All @@ -336,10 +405,11 @@ async function LawReaderSection({
totalPasals={totalPasalCount || 0}
pathname={pathname}
pageUrl={pageUrl}
worksLookup={worksLookup}
/>
) : (
allPasals.map((pasal) => (
<PasalBlock key={pasal.id} pasal={pasal} pathname={pathname} pageUrl={pageUrl} />
<PasalBlock key={pasal.id} pasal={pasal} pathname={pathname} pageUrl={pageUrl} worksLookup={worksLookup} />
))
)}
</>
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/reader/PasalBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -17,9 +18,10 @@ interface PasalBlockProps {
pasal: PasalNode;
pathname: string;
pageUrl: string;
worksLookup: Record<string, string>;
}

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}`;
Expand Down Expand Up @@ -48,7 +50,7 @@ export default function PasalBlock({ pasal, pathname, pageUrl }: PasalBlockProps
<CopyButton text={content} />
</div>
</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap">{content}</div>
<RichPasalContent content={content} worksLookup={worksLookup} />
</article>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/components/reader/PasalList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface PasalListProps {
totalPasals: number;
pathname: string;
pageUrl: string;
worksLookup: Record<string, string>;
}

export default function PasalList({
Expand All @@ -33,6 +34,7 @@ export default function PasalList({
totalPasals,
pathname,
pageUrl,
worksLookup,
}: PasalListProps) {
const t = useTranslations("reader");
const [pasals, setPasals] = useState<DocumentNode[]>(initialPasals);
Expand Down Expand Up @@ -102,6 +104,7 @@ export default function PasalList({
pasal={pasal}
pathname={pathname}
pageUrl={pageUrl}
worksLookup={worksLookup}
/>
))}

Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/components/reader/RichPasalContent.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

/**
* 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 (
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{tokens.map((token, i) => {
if (token.type === "text") {
return <Fragment key={i}>{token.value}</Fragment>;
}

if (token.type === "pasal") {
return (
<a
key={i}
href={token.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}
</a>
);
}

if (token.type === "uu") {
return (
<Link
key={i}
// 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<typeof Link>[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}
</Link>
);
}

return null;
})}
</div>
);
}
Loading