1+ import { readFileSync , existsSync , readdirSync } from "node:fs" ;
2+ import { join } from "node:path" ;
3+ import type { LinkTarget } from "./linkify.ts" ;
4+ import type { Corpus } from "./corpus.ts" ;
5+ import { VOLUME_IDS } from "../dic/pipeline.ts" ;
6+ import {
7+ buildSectionEntryIndex ,
8+ entryAnchor ,
9+ sectionForTarget ,
10+ targetPageHref ,
11+ volumeSectionPath ,
12+ } from "./volume-paths.ts" ;
13+
14+ /** Paths under `dist/` (site root, not repo root). */
15+ export interface BuiltPageRef {
16+ rel : string ;
17+ vol : string ;
18+ /** 1-based syllable section; null = hub or monolith. */
19+ section : number | null ;
20+ }
21+
22+ const VOL_RE = / ^ ( 0 [ 1 - 9 ] | 1 [ 0 - 9 ] | 2 [ 0 - 6 ] ) $ / ;
23+
24+ export function extractElementIds ( html : string ) : Set < string > {
25+ const ids = new Set < string > ( ) ;
26+ for ( const m of html . matchAll ( / \b i d = " ( [ ^ " ] + ) " / g) ) ids . add ( m [ 1 ] ! ) ;
27+ return ids ;
28+ }
29+
30+ /** Normalize site-internal dictionary URL → page path + optional entry anchor. */
31+ export function parseDictionaryHref (
32+ href : string ,
33+ siteBase = "/koktai/" ,
34+ ) : { page : string ; vol : string ; section : number | null ; anchor : string | null } | null {
35+ const base = siteBase . endsWith ( "/" ) ? siteBase : `${ siteBase } /` ;
36+ let path = href . trim ( ) ;
37+ if ( path . startsWith ( "http://" ) || path . startsWith ( "https://" ) ) {
38+ try {
39+ path = new URL ( path ) . pathname ;
40+ } catch {
41+ return null ;
42+ }
43+ }
44+ const hashIdx = path . indexOf ( "#" ) ;
45+ const anchor = hashIdx >= 0 ? path . slice ( hashIdx + 1 ) : null ;
46+ path = hashIdx >= 0 ? path . slice ( 0 , hashIdx ) : path ;
47+ if ( ! path . startsWith ( base ) ) return null ;
48+ const rest = path . slice ( base . length ) ;
49+ const m = rest . match ( / ^ ( (?: 0 [ 1 - 9 ] | 1 [ 0 - 9 ] | 2 [ 0 - 6 ] ) ) (?: \/ i n d e x \. h t m l | \/ ( \d + ) \/ i n d e x \. h t m l ) ? $ / ) ;
50+ if ( ! m ) return null ;
51+ const vol = m [ 1 ] ! ;
52+ const section = m [ 2 ] ? Number . parseInt ( m [ 2 ] , 10 ) : null ;
53+ if ( anchor && ! / ^ ( w | c ) - \d + $ / . test ( anchor ) ) {
54+ if ( ! anchor . startsWith ( "s-" ) ) return { page : rest , vol, section, anchor : null } ;
55+ }
56+ return { page : rest + ( anchor ? `#${ anchor } ` : "" ) , vol, section, anchor } ;
57+ }
58+
59+ export function listBuiltDictionaryPages ( distRoot : string ) : BuiltPageRef [ ] {
60+ const out : BuiltPageRef [ ] = [ ] ;
61+ for ( const vol of VOLUME_IDS ) {
62+ const volDir = join ( distRoot , vol ) ;
63+ if ( ! existsSync ( volDir ) ) continue ;
64+ const hubIndex = join ( volDir , "index.html" ) ;
65+ if ( existsSync ( hubIndex ) ) {
66+ out . push ( { rel : `${ vol } /index.html` , vol, section : null } ) ;
67+ }
68+ for ( const name of readdirSync ( volDir ) ) {
69+ if ( ! / ^ \d + $ / . test ( name ) ) continue ;
70+ const secPath = join ( volDir , name , "index.html" ) ;
71+ if ( existsSync ( secPath ) ) {
72+ out . push ( { rel : `${ vol } /${ name } /index.html` , vol, section : Number ( name ) } ) ;
73+ }
74+ }
75+ }
76+ return out ;
77+ }
78+
79+ /** Every word/sinogram must map to a section; anchors must match line numbers. */
80+ export function verifyCorpusSectionRouting ( corpus : Corpus ) : string [ ] {
81+ const errors : string [ ] = [ ] ;
82+ for ( const vol of VOLUME_IDS ) {
83+ const data = corpus . volumes . get ( vol ) ;
84+ if ( ! data ) continue ;
85+ for ( const w of data . words ) {
86+ const sec = corpus . sectionOf ( vol , w . chapterZhuyin ) ;
87+ if ( sec <= 0 ) {
88+ errors . push ( `${ vol } word line ${ w . line } : no section for chapter ${ w . chapterZhuyin } ` ) ;
89+ }
90+ }
91+ for ( const s of data . sinograms ) {
92+ const sec = corpus . sectionOf ( vol , s . chapterZhuyin ) ;
93+ if ( sec <= 0 ) {
94+ errors . push ( `${ vol } sinogram line ${ s . line } : no section for chapter ${ s . chapterZhuyin } ` ) ;
95+ }
96+ }
97+ }
98+ return errors ;
99+ }
100+
101+ /** Hrefs emitted by `targetPageHref` must land on the section that owns the anchor. */
102+ export function verifyTargetHrefRouting (
103+ corpus : Corpus ,
104+ hrefBase = "/koktai/" ,
105+ ) : string [ ] {
106+ const index = buildSectionEntryIndex ( corpus ) ;
107+ const errors : string [ ] = [ ] ;
108+ const check = ( target : LinkTarget ) => {
109+ const href = targetPageHref ( hrefBase , target , corpus ) ;
110+ const parsed = parseDictionaryHref ( href , hrefBase ) ;
111+ if ( ! parsed ?. anchor ) {
112+ errors . push ( `target ${ target . k } :${ target . v } :${ target . l } → href missing anchor: ${ href } ` ) ;
113+ return ;
114+ }
115+ const expectedSec = index . volumes [ target . v ] ?. [ parsed . anchor ] ;
116+ if ( expectedSec === undefined ) {
117+ errors . push ( `target ${ target . k } :${ target . v } :${ target . l } → anchor ${ parsed . anchor } not in entry index` ) ;
118+ return ;
119+ }
120+ if ( parsed . section !== expectedSec ) {
121+ errors . push (
122+ `target ${ target . k } :${ target . v } :${ target . l } → section ${ parsed . section } !== expected ${ expectedSec } (${ href } )` ,
123+ ) ;
124+ }
125+ const viaFn = sectionForTarget ( corpus , target ) ;
126+ if ( viaFn !== expectedSec ) {
127+ errors . push (
128+ `sectionForTarget ${ target . k } :${ target . v } :${ target . l } → ${ viaFn } !== index ${ expectedSec } ` ,
129+ ) ;
130+ }
131+ } ;
132+ for ( const vol of VOLUME_IDS ) {
133+ const data = corpus . volumes . get ( vol ) ;
134+ if ( ! data ) continue ;
135+ for ( const w of data . words ) check ( { k : "w" , v : vol , l : w . line } ) ;
136+ for ( const s of data . sinograms ) check ( { k : "c" , v : vol , l : s . line } ) ;
137+ }
138+ return errors ;
139+ }
140+
141+ export function collectHrefTargetsFromHtml ( html : string ) : { href : string ; fromKk : boolean } [ ] {
142+ const out : { href : string ; fromKk : boolean } [ ] = [ ] ;
143+ for ( const m of html . matchAll ( / < a \s [ ^ > ] * \b h r e f = " ( [ ^ " ] + ) " [ ^ > ] * > / gi) ) {
144+ const tag = m [ 0 ] ! ;
145+ out . push ( { href : m [ 1 ] ! , fromKk : / \b c l a s s = " [ ^ " ] * k k / . test ( tag ) } ) ;
146+ }
147+ return out ;
148+ }
149+
150+ /** Scan built HTML: entry anchors must exist on the resolved target page. */
151+ export function verifyBuiltHtmlAnchors (
152+ distRoot : string ,
153+ siteBase = "/koktai/" ,
154+ ) : string [ ] {
155+ const pages = listBuiltDictionaryPages ( distRoot ) ;
156+ if ( pages . length === 0 ) return [ "no dictionary pages under dist/ (run astro build)" ] ;
157+
158+ const idsByPage = new Map < string , Set < string > > ( ) ;
159+ for ( const p of pages ) {
160+ const full = join ( distRoot , p . rel ) ;
161+ const html = readFileSync ( full , "utf8" ) ;
162+ idsByPage . set ( p . rel , extractElementIds ( html ) ) ;
163+ }
164+
165+ const errors : string [ ] = [ ] ;
166+ const resolveTargetIds = ( vol : string , section : number | null ) : Set < string > | undefined => {
167+ if ( section !== null ) {
168+ return idsByPage . get ( volumeSectionPath ( vol , section ) ) ;
169+ }
170+ return idsByPage . get ( `${ vol } /index.html` ) ;
171+ } ;
172+
173+ for ( const p of pages ) {
174+ const html = readFileSync ( join ( distRoot , p . rel ) , "utf8" ) ;
175+ for ( const { href, fromKk } of collectHrefTargetsFromHtml ( html ) ) {
176+ const parsed = parseDictionaryHref ( href , siteBase ) ;
177+ if ( ! parsed ) continue ;
178+ if ( ! VOL_RE . test ( parsed . vol ) ) continue ;
179+ if ( ! fromKk ) continue ;
180+ if ( ! parsed . anchor ) continue ;
181+
182+ const targetIds = resolveTargetIds ( parsed . vol , parsed . section ) ;
183+ if ( ! targetIds ) {
184+ errors . push ( `${ p . rel } : link ${ href } → missing target page` ) ;
185+ continue ;
186+ }
187+ if ( ! targetIds . has ( parsed . anchor ) ) {
188+ errors . push ( `${ p . rel } : link ${ href } → id ${ parsed . anchor } not on target` ) ;
189+ }
190+ }
191+ }
192+ return errors ;
193+ }
194+
195+ export function expectedSectionHtmlPath ( vol : string , section : number ) : string {
196+ return volumeSectionPath ( vol , section ) ;
197+ }
0 commit comments