@hanology/cham-browser 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @hanology/cham-browser
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@hanology/cham-browser)
|
|
4
|
+
|
|
5
|
+
Site generator for [CHAM (Classical Han with Annotations Markup)](https://github.com/hanologyorg/cham-format) — generates a complete static website from CHAM content.
|
|
6
|
+
|
|
7
|
+
Includes parser, serializer, transformation pipeline, Vue 3 frontend template, and CLI.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @hanology/cham-browser
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Generate a static site from CHAM content
|
|
19
|
+
npx cham-browser --config config.yaml
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### config.yaml
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
# Branding
|
|
26
|
+
name: 漢流
|
|
27
|
+
nameEn: Hanology
|
|
28
|
+
subtitle: 古典詩文圖書館
|
|
29
|
+
|
|
30
|
+
# Content paths (relative to config.yaml)
|
|
31
|
+
libraryDir: library/content
|
|
32
|
+
authorsFile: library/data/authors.yaml
|
|
33
|
+
|
|
34
|
+
# Build options
|
|
35
|
+
outputDir: dist
|
|
36
|
+
pretty: true
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
### Parser & Serializer (browser-compatible)
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { parse, serialize } from '@hanology/cham-browser'
|
|
45
|
+
|
|
46
|
+
const doc = parse(chamSource)
|
|
47
|
+
const output = serialize(doc)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Pipeline (pure functions, no Node.js fs)
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import {
|
|
54
|
+
buildPieceFromCham,
|
|
55
|
+
buildBookData,
|
|
56
|
+
buildLibraryIndex,
|
|
57
|
+
buildAuthorsJson,
|
|
58
|
+
buildDynastiesJson,
|
|
59
|
+
} from '@hanology/cham-browser'
|
|
60
|
+
|
|
61
|
+
const piece = buildPieceFromCham(
|
|
62
|
+
chamSource, bookConfig, authors, bookId,
|
|
63
|
+
proseFiles, layerFiles,
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Architecture
|
|
68
|
+
|
|
69
|
+
| Layer | Description |
|
|
70
|
+
|-------|-------------|
|
|
71
|
+
| `pipeline.ts` | Pure transformation functions (CHAM → JSON) |
|
|
72
|
+
| `cli.ts` | I/O adapter: reads files, calls pipeline, runs vite-ssg |
|
|
73
|
+
| `template/` | Vue 3 + vite-ssg frontend (components, views, styles) |
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
Node.js 20+
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/package.json
CHANGED
|
@@ -12,7 +12,11 @@ const props = defineProps<{
|
|
|
12
12
|
style?: Record<string, string>
|
|
13
13
|
}>()
|
|
14
14
|
|
|
15
|
-
const emit = defineEmits<{
|
|
15
|
+
const emit = defineEmits<{
|
|
16
|
+
close: []
|
|
17
|
+
tooltipEnter: []
|
|
18
|
+
tooltipLeave: []
|
|
19
|
+
}>()
|
|
16
20
|
const { layout } = useReadingMode()
|
|
17
21
|
const isMobile = computed(() => window.innerWidth < 768)
|
|
18
22
|
|
|
@@ -27,17 +31,24 @@ function layerLabel(ann: Annotation): string {
|
|
|
27
31
|
}
|
|
28
32
|
return ''
|
|
29
33
|
}
|
|
34
|
+
|
|
35
|
+
function onBackdropTouchMove() {
|
|
36
|
+
emit('close')
|
|
37
|
+
}
|
|
30
38
|
</script>
|
|
31
39
|
|
|
32
40
|
<template>
|
|
33
41
|
<Teleport to="body">
|
|
34
42
|
<Transition name="ann-fade">
|
|
35
|
-
<div v-if="visible && annotations.length" class="ann-backdrop" @click="emit('close')">
|
|
43
|
+
<div v-if="visible && annotations.length" class="ann-backdrop" @click="emit('close')" @touchmove="onBackdropTouchMove">
|
|
36
44
|
<div
|
|
37
45
|
class="ann-tooltip"
|
|
38
46
|
:class="{ 'ann-vertical': layout === 'vertical', 'ann-mobile-bottom': isMobile }"
|
|
39
47
|
:style="style"
|
|
40
48
|
@click.stop
|
|
49
|
+
@touchmove.stop
|
|
50
|
+
@mouseenter="emit('tooltipEnter')"
|
|
51
|
+
@mouseleave="emit('tooltipLeave')"
|
|
41
52
|
>
|
|
42
53
|
<button v-if="isMobile" class="ann-handle" @click="emit('close')">
|
|
43
54
|
<span class="ann-handle-bar" />
|
|
@@ -48,12 +59,10 @@ function layerLabel(ann: Annotation): string {
|
|
|
48
59
|
class="ann-entry"
|
|
49
60
|
:class="ann.kind"
|
|
50
61
|
>
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
<span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
|
|
54
|
-
</div>
|
|
62
|
+
<span class="ann-kind">{{ ann.kind === 'pronunciation' ? '音' : '義' }}</span>
|
|
63
|
+
<span v-if="layerLabel(ann)" class="ann-layer">{{ layerLabel(ann) }}</span>
|
|
55
64
|
<PronunciationGroup v-if="getSegment(ann)" :segment="getSegment(ann)!" />
|
|
56
|
-
<
|
|
65
|
+
<span v-else class="ann-body">{{ ann.text }}</span>
|
|
57
66
|
</div>
|
|
58
67
|
</div>
|
|
59
68
|
</div>
|
|
@@ -135,7 +144,6 @@ function layerLabel(ann: Annotation): string {
|
|
|
135
144
|
display: inline-flex;
|
|
136
145
|
align-items: center;
|
|
137
146
|
gap: 6px;
|
|
138
|
-
margin-bottom: 2px;
|
|
139
147
|
}
|
|
140
148
|
.ann-layer {
|
|
141
149
|
font-size: 11px;
|
|
@@ -153,7 +161,6 @@ function layerLabel(ann: Annotation): string {
|
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
.ann-body {
|
|
156
|
-
margin-top: 4px;
|
|
157
164
|
line-height: 1.8;
|
|
158
165
|
}
|
|
159
166
|
|
|
@@ -168,6 +175,7 @@ function layerLabel(ann: Annotation): string {
|
|
|
168
175
|
.ann-vertical .ann-entry {
|
|
169
176
|
margin-bottom: 0;
|
|
170
177
|
margin-left: 12px;
|
|
178
|
+
display: inline;
|
|
171
179
|
}
|
|
172
180
|
.ann-vertical .ann-kind {
|
|
173
181
|
margin-right: 0;
|
|
@@ -175,7 +183,6 @@ function layerLabel(ann: Annotation): string {
|
|
|
175
183
|
vertical-align: baseline;
|
|
176
184
|
}
|
|
177
185
|
.ann-vertical .ann-body {
|
|
178
|
-
margin-top: 0;
|
|
179
186
|
margin-left: 6px;
|
|
180
187
|
}
|
|
181
188
|
|
|
@@ -56,7 +56,8 @@ export function renderAnnotatedText(text: string, spans: AnnSpan[], useRuby = fa
|
|
|
56
56
|
const numText = toChineseNumber(annCounter)
|
|
57
57
|
const body = esc(text.slice(span.start, span.end))
|
|
58
58
|
if (useRuby) {
|
|
59
|
-
|
|
59
|
+
const rtCls = numText.length > 1 ? 'ann-num ann-num-long' : 'ann-num'
|
|
60
|
+
html += `<ruby class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<rp></rp><rt class="${rtCls}">${numText}</rt><rp></rp></ruby>`
|
|
60
61
|
} else {
|
|
61
62
|
html += `<span class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<sup class="ann-num">${numText}</sup></span>`
|
|
62
63
|
}
|
|
@@ -151,7 +152,18 @@ export function useAnnotationTooltip() {
|
|
|
151
152
|
|
|
152
153
|
function hide() { visible.value = false }
|
|
153
154
|
function toggle(event: MouseEvent, annotations: Annotation[]) {
|
|
154
|
-
if (visible.value) {
|
|
155
|
+
if (visible.value) {
|
|
156
|
+
const currentIds = items.value.map(a => a.id).sort().join(',')
|
|
157
|
+
const newIds = annotations.map(a => a.id).sort().join(',')
|
|
158
|
+
if (currentIds === newIds) {
|
|
159
|
+
// Same annotation: dismiss on mobile only (desktop uses hover to manage)
|
|
160
|
+
if (window.innerWidth < 768) hide()
|
|
161
|
+
} else {
|
|
162
|
+
show(event, annotations)
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
show(event, annotations)
|
|
166
|
+
}
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
return { visible, items, style, show, hide, toggle }
|
|
@@ -152,16 +152,33 @@ const proseSections = computed(() => {
|
|
|
152
152
|
return result
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
+
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
|
156
|
+
function cancelHide() {
|
|
157
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null }
|
|
158
|
+
}
|
|
159
|
+
function scheduleHide(delay = 150) {
|
|
160
|
+
cancelHide()
|
|
161
|
+
hideTimer = setTimeout(() => { tooltip.hide(); hideTimer = null }, delay)
|
|
162
|
+
}
|
|
163
|
+
|
|
155
164
|
function handleAnnotationHover(event: MouseEvent, annotations: Annotation[]) {
|
|
165
|
+
cancelHide()
|
|
156
166
|
tooltip.show(event, annotations)
|
|
157
167
|
}
|
|
158
168
|
function handleAnnotationLeave() {
|
|
159
|
-
if (window.innerWidth >= 768)
|
|
169
|
+
if (window.innerWidth >= 768) scheduleHide()
|
|
160
170
|
}
|
|
161
171
|
function handleAnnotationTap(event: MouseEvent, annotations: Annotation[]) {
|
|
172
|
+
cancelHide()
|
|
162
173
|
tooltip.toggle(event, annotations)
|
|
163
174
|
}
|
|
164
|
-
function
|
|
175
|
+
function handleTooltipEnter() {
|
|
176
|
+
cancelHide()
|
|
177
|
+
}
|
|
178
|
+
function handleTooltipLeave() {
|
|
179
|
+
if (window.innerWidth >= 768) scheduleHide()
|
|
180
|
+
}
|
|
181
|
+
function dismissTooltip() { cancelHide(); tooltip.hide() }
|
|
165
182
|
const { getAuthor, loadShared } = useData()
|
|
166
183
|
await loadShared()
|
|
167
184
|
|
|
@@ -259,32 +276,33 @@ function tcy(n: number): string {
|
|
|
259
276
|
/>
|
|
260
277
|
</section>
|
|
261
278
|
|
|
262
|
-
<
|
|
263
|
-
num=""
|
|
264
|
-
label="注釋"
|
|
265
|
-
:special="false"
|
|
266
|
-
:text="piece.sections.annotations || ''"
|
|
267
|
-
:is-annotations="true"
|
|
268
|
-
:vertical="true"
|
|
269
|
-
class="v-section"
|
|
270
|
-
/>
|
|
271
|
-
|
|
272
|
-
<div v-if="hasLayers" class="v-layers-section">
|
|
273
|
-
<AnnotationLayerSelector
|
|
274
|
-
:layers="annotationLayers"
|
|
275
|
-
v-model:activeIds="activeLayerIds"
|
|
276
|
-
/>
|
|
279
|
+
<div class="v-section">
|
|
277
280
|
<SectionBlock
|
|
278
|
-
v-for="block in layerAnnotationBlocks"
|
|
279
|
-
:key="block.label"
|
|
280
281
|
num=""
|
|
281
|
-
|
|
282
|
+
label="注釋"
|
|
282
283
|
:special="false"
|
|
283
|
-
:text="
|
|
284
|
+
:text="piece.sections.annotations || ''"
|
|
284
285
|
:is-annotations="true"
|
|
285
286
|
:vertical="true"
|
|
286
|
-
class="v-section"
|
|
287
287
|
/>
|
|
288
|
+
<template v-if="hasLayers">
|
|
289
|
+
<div class="v-layers-inline">
|
|
290
|
+
<AnnotationLayerSelector
|
|
291
|
+
:layers="annotationLayers"
|
|
292
|
+
v-model:activeIds="activeLayerIds"
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
<SectionBlock
|
|
296
|
+
v-for="block in layerAnnotationBlocks"
|
|
297
|
+
:key="block.label"
|
|
298
|
+
num=""
|
|
299
|
+
:label="block.label"
|
|
300
|
+
:special="false"
|
|
301
|
+
:text="block.text"
|
|
302
|
+
:is-annotations="true"
|
|
303
|
+
:vertical="true"
|
|
304
|
+
/>
|
|
305
|
+
</template>
|
|
288
306
|
</div>
|
|
289
307
|
|
|
290
308
|
<SectionBlock
|
|
@@ -320,6 +338,8 @@ function tcy(n: number): string {
|
|
|
320
338
|
:layer-labels="layerLabels"
|
|
321
339
|
:style="tooltip.style"
|
|
322
340
|
@close="dismissTooltip"
|
|
341
|
+
@tooltip-enter="handleTooltipEnter"
|
|
342
|
+
@tooltip-leave="handleTooltipLeave"
|
|
323
343
|
/>
|
|
324
344
|
|
|
325
345
|
<Teleport to="body">
|
|
@@ -382,20 +402,32 @@ function tcy(n: number): string {
|
|
|
382
402
|
</div>
|
|
383
403
|
|
|
384
404
|
<div class="h-sections">
|
|
385
|
-
<div v-if="hasLayers" class="h-
|
|
386
|
-
<AnnotationLayerSelector
|
|
387
|
-
:layers="annotationLayers"
|
|
388
|
-
v-model:activeIds="activeLayerIds"
|
|
389
|
-
/>
|
|
405
|
+
<div v-if="piece.sections.annotations || hasLayers" class="h-ann-section">
|
|
390
406
|
<SectionBlock
|
|
391
|
-
v-
|
|
392
|
-
:key="block.label"
|
|
407
|
+
v-if="piece.sections.annotations"
|
|
393
408
|
num=""
|
|
394
|
-
|
|
409
|
+
label="注釋"
|
|
395
410
|
:special="false"
|
|
396
|
-
:text="
|
|
411
|
+
:text="piece.sections.annotations"
|
|
397
412
|
:is-annotations="true"
|
|
398
413
|
/>
|
|
414
|
+
<template v-if="hasLayers">
|
|
415
|
+
<div class="h-layers-inline">
|
|
416
|
+
<AnnotationLayerSelector
|
|
417
|
+
:layers="annotationLayers"
|
|
418
|
+
v-model:activeIds="activeLayerIds"
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
<SectionBlock
|
|
422
|
+
v-for="block in layerAnnotationBlocks"
|
|
423
|
+
:key="block.label"
|
|
424
|
+
num=""
|
|
425
|
+
:label="block.label"
|
|
426
|
+
:special="false"
|
|
427
|
+
:text="block.text"
|
|
428
|
+
:is-annotations="true"
|
|
429
|
+
/>
|
|
430
|
+
</template>
|
|
399
431
|
</div>
|
|
400
432
|
|
|
401
433
|
<SectionBlock
|
|
@@ -430,6 +462,8 @@ function tcy(n: number): string {
|
|
|
430
462
|
:layer-labels="layerLabels"
|
|
431
463
|
:style="tooltip.style"
|
|
432
464
|
@close="dismissTooltip"
|
|
465
|
+
@tooltip-enter="handleTooltipEnter"
|
|
466
|
+
@tooltip-leave="handleTooltipLeave"
|
|
433
467
|
/>
|
|
434
468
|
|
|
435
469
|
<Teleport to="body">
|
|
@@ -532,15 +566,11 @@ function tcy(n: number): string {
|
|
|
532
566
|
flex-shrink: 0;
|
|
533
567
|
}
|
|
534
568
|
|
|
535
|
-
.v-layers-
|
|
536
|
-
flex-shrink: 0;
|
|
569
|
+
.v-layers-inline {
|
|
537
570
|
writing-mode: vertical-rl;
|
|
538
571
|
text-orientation: mixed;
|
|
539
|
-
padding: 0
|
|
540
|
-
|
|
541
|
-
flex-direction: column;
|
|
542
|
-
gap: 8px;
|
|
543
|
-
align-items: flex-start;
|
|
572
|
+
padding: 12px 0 4px;
|
|
573
|
+
border-top: 1px solid var(--border-light);
|
|
544
574
|
}
|
|
545
575
|
|
|
546
576
|
.v-source-link {
|
|
@@ -649,12 +679,13 @@ function tcy(n: number): string {
|
|
|
649
679
|
margin: 0 auto; padding-bottom: 80px;
|
|
650
680
|
}
|
|
651
681
|
|
|
652
|
-
.h-
|
|
682
|
+
.h-ann-section {
|
|
653
683
|
margin-bottom: 16px;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.h-layers-inline {
|
|
687
|
+
padding: 12px 0;
|
|
688
|
+
margin-bottom: 8px;
|
|
658
689
|
}
|
|
659
690
|
|
|
660
691
|
.h-source-link {
|