@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
+ [![npm version](https://img.shields.io/npm/v/@hanology/cham-browser.svg)](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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanology/cham-browser",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "CHAM — browser-compatible parser, serializer, and site generator for Classical Han Annotated Markdown",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,7 +12,11 @@ const props = defineProps<{
12
12
  style?: Record<string, string>
13
13
  }>()
14
14
 
15
- const emit = defineEmits<{ close: [] }>()
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
- <div class="ann-header">
52
- <span class="ann-kind">{{ ann.kind === 'pronunciation' ? '音' : '義' }}</span>
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
- <div v-else class="ann-body">{{ ann.text }}</div>
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
 
@@ -105,6 +105,10 @@ function onTap(event: MouseEvent) {
105
105
  text-align: end;
106
106
  letter-spacing: 0;
107
107
  }
108
+ :deep(.ann-num-long) {
109
+ font-size: 0.38em;
110
+ letter-spacing: -1px;
111
+ }
108
112
  :deep(.ann-target:hover) {
109
113
  background: rgba(194, 58, 43, 0.08);
110
114
  }
@@ -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
- html += `<ruby class="ann-target ${kinds}" data-ann-ids="${ids}">${body}<rp></rp><rt class="ann-num">${numText}</rt><rp></rp></ruby>`
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) { hide() } else { show(event, annotations) }
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) tooltip.hide()
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 dismissTooltip() { tooltip.hide() }
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
- <SectionBlock
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
- :label="block.label"
282
+ label="注釋"
282
283
  :special="false"
283
- :text="block.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-layers-section">
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-for="block in layerAnnotationBlocks"
392
- :key="block.label"
407
+ v-if="piece.sections.annotations"
393
408
  num=""
394
- :label="block.label"
409
+ label="注釋"
395
410
  :special="false"
396
- :text="block.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-section {
536
- flex-shrink: 0;
569
+ .v-layers-inline {
537
570
  writing-mode: vertical-rl;
538
571
  text-orientation: mixed;
539
- padding: 0 16px;
540
- display: flex;
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-layers-section {
682
+ .h-ann-section {
653
683
  margin-bottom: 16px;
654
- padding: 16px;
655
- background: var(--surface);
656
- border-radius: 8px;
657
- border: 1px solid var(--border-light);
684
+ }
685
+
686
+ .h-layers-inline {
687
+ padding: 12px 0;
688
+ margin-bottom: 8px;
658
689
  }
659
690
 
660
691
  .h-source-link {