@hanology/cham-browser 0.4.71 → 0.4.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanology/cham-browser",
3
- "version": "0.4.71",
3
+ "version": "0.4.73",
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",
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'fs'
3
+ import { resolve } from 'path'
4
+
5
+ const appVue = readFileSync(
6
+ resolve(__dirname, '../src/App.vue'),
7
+ 'utf-8',
8
+ )
9
+
10
+ describe('App.vue structure', () => {
11
+ it('wraps router-view in Suspense', () => {
12
+ expect(appVue).toContain('<Suspense')
13
+ })
14
+
15
+ it('Suspense has a fallback slot with route-loading', () => {
16
+ expect(appVue).toContain('#fallback')
17
+ expect(appVue).toContain('route-loading')
18
+ })
19
+
20
+ it('component uses route.fullPath as key', () => {
21
+ expect(appVue).toMatch(/:key="route\.fullPath"/)
22
+ })
23
+
24
+ it('renders <component :is="Component" /> inside Suspense', () => {
25
+ expect(appVue).toContain(':is="Component"')
26
+ })
27
+
28
+ it('has page-fade CSS transition classes', () => {
29
+ expect(appVue).toContain('.page-fade-enter-active')
30
+ expect(appVue).toContain('.page-fade-leave-active')
31
+ })
32
+
33
+ it('does NOT wrap Suspense in Transition (causes async nav failures)', () => {
34
+ // Transition + Suspense combination breaks async component navigation
35
+ expect(appVue).not.toMatch(/<Transition[^>]*>[\s\S]*<Suspense/)
36
+ })
37
+ })
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import type { BookMeta, Piece } from '../src/types'
3
+
4
+ function makePiece(overrides: Partial<Piece> & { num: number }): Piece {
5
+ return {
6
+ bookId: 'test',
7
+ title: 'Test Piece',
8
+ author: 'Tester',
9
+ authorId: 'tester',
10
+ era: 'Tang',
11
+ genre: 'poetry',
12
+ verses: [{ text: '床前明月光' }],
13
+ sections: {},
14
+ annotations: [],
15
+ ...overrides,
16
+ }
17
+ }
18
+
19
+ function makeMeta(overrides: Partial<BookMeta> = {}): BookMeta {
20
+ return {
21
+ id: 'test',
22
+ title: 'Test Book',
23
+ genre: 'poetry',
24
+ count: 2,
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ describe('useBook', () => {
30
+ beforeEach(() => {
31
+ vi.resetModules()
32
+ })
33
+
34
+ it('getPiece returns piece by num after load', async () => {
35
+ const pieces = [
36
+ makePiece({ num: 1, title: 'First' }),
37
+ makePiece({ num: 2, title: 'Second' }),
38
+ ]
39
+ vi.stubGlobal('fetch', () =>
40
+ Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
41
+ )
42
+ vi.stubGlobal('import.meta', { env: { SSR: false } })
43
+
44
+ const { useBook } = await import('../src/composables/useBook')
45
+ const { load, getPiece, loaded } = useBook()
46
+ expect(loaded.value).toBe(false)
47
+ await load('test')
48
+ expect(loaded.value).toBe(true)
49
+ expect(getPiece(1)?.title).toBe('First')
50
+ expect(getPiece(2)?.title).toBe('Second')
51
+ expect(getPiece(3)).toBeUndefined()
52
+ })
53
+
54
+ it('getAdjacentNums returns prev and next', async () => {
55
+ const pieces = [
56
+ makePiece({ num: 1 }),
57
+ makePiece({ num: 3 }),
58
+ makePiece({ num: 7 }),
59
+ ]
60
+ vi.stubGlobal('fetch', () =>
61
+ Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
62
+ )
63
+ vi.stubGlobal('import.meta', { env: { SSR: false } })
64
+
65
+ const { useBook } = await import('../src/composables/useBook')
66
+ const { load, getAdjacentNums } = useBook()
67
+ await load('test')
68
+
69
+ expect(getAdjacentNums(1)).toEqual({ prev: null, next: 3 })
70
+ expect(getAdjacentNums(3)).toEqual({ prev: 1, next: 7 })
71
+ expect(getAdjacentNums(7)).toEqual({ prev: 3, next: null })
72
+ })
73
+
74
+ it('getPiecesByAuthor filters by author name', async () => {
75
+ const pieces = [
76
+ makePiece({ num: 1, author: '李白' }),
77
+ makePiece({ num: 2, author: '杜甫' }),
78
+ makePiece({ num: 3, author: '李白' }),
79
+ ]
80
+ vi.stubGlobal('fetch', () =>
81
+ Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
82
+ )
83
+ vi.stubGlobal('import.meta', { env: { SSR: false } })
84
+
85
+ const { useBook } = await import('../src/composables/useBook')
86
+ const { load, getPiecesByAuthor } = useBook()
87
+ await load('test')
88
+
89
+ const byLi = getPiecesByAuthor('李白')
90
+ expect(byLi).toHaveLength(2)
91
+ expect(byLi.every(p => p.author === '李白')).toBe(true)
92
+ })
93
+
94
+ it('cleans hard wraps in piece sections', async () => {
95
+ const pieces = [
96
+ makePiece({
97
+ num: 1,
98
+ sections: { body: '第一行\n第二行\n\n第三段\n第四段' },
99
+ }),
100
+ ]
101
+ vi.stubGlobal('fetch', () =>
102
+ Promise.resolve({ json: () => Promise.resolve({ meta: makeMeta(), pieces }) })
103
+ )
104
+ vi.stubGlobal('import.meta', { env: { SSR: false } })
105
+
106
+ const { useBook } = await import('../src/composables/useBook')
107
+ const { load, getPiece } = useBook()
108
+ await load('test')
109
+
110
+ const p = getPiece(1)!
111
+ // Single newlines within paragraphs should be removed, double newlines preserved
112
+ expect(p.sections.body).toBe('第一行第二行\n\n第三段第四段')
113
+ })
114
+ })
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { nextTick } from 'vue'
3
+
4
+ // useReadingMode accesses localStorage and document at module level,
5
+ // so we must mock them before importing.
6
+ const localStorageStore: Record<string, string> = {}
7
+ const attributeStore: Record<string, string> = {}
8
+
9
+ vi.stubGlobal('localStorage', {
10
+ getItem: (k: string) => localStorageStore[k] ?? null,
11
+ setItem: (k: string, v: string) => { localStorageStore[k] = v },
12
+ removeItem: (k: string) => { delete localStorageStore[k] },
13
+ clear: () => Object.keys(localStorageStore).forEach(k => delete localStorageStore[k]),
14
+ })
15
+
16
+ vi.stubGlobal('document', {
17
+ documentElement: {
18
+ setAttribute: (k: string, v: string) => { attributeStore[k] = v },
19
+ getAttribute: (k: string) => attributeStore[k] ?? null,
20
+ style: {
21
+ setProperty: vi.fn(),
22
+ },
23
+ },
24
+ })
25
+
26
+ describe('useReadingMode', () => {
27
+ let useReadingMode: typeof import('../src/composables/useReadingMode').useReadingMode
28
+
29
+ beforeEach(() => {
30
+ // Clear stores
31
+ Object.keys(localStorageStore).forEach(k => delete localStorageStore[k])
32
+ Object.keys(attributeStore).forEach(k => delete attributeStore[k])
33
+ // Import fresh each time to exercise module-level init
34
+ vi.resetModules()
35
+ })
36
+
37
+ it('defaults to vertical layout when no saved preference', async () => {
38
+ const mod = await import('../src/composables/useReadingMode')
39
+ useReadingMode = mod.useReadingMode
40
+ const { layout } = useReadingMode()
41
+ expect(layout.value).toBe('vertical')
42
+ })
43
+
44
+ it('restores saved horizontal layout after nextTick', async () => {
45
+ localStorageStore.layout = 'horizontal'
46
+ const mod = await import('../src/composables/useReadingMode')
47
+ useReadingMode = mod.useReadingMode
48
+ const { layout } = useReadingMode()
49
+ // Module-level nextTick runs during import, so by now layout is restored
50
+ await nextTick()
51
+ expect(layout.value).toBe('horizontal')
52
+ })
53
+
54
+ it('toggleLayout switches between horizontal and vertical', async () => {
55
+ const mod = await import('../src/composables/useReadingMode')
56
+ useReadingMode = mod.useReadingMode
57
+ const { layout, toggleLayout } = useReadingMode()
58
+ expect(layout.value).toBe('vertical')
59
+ toggleLayout()
60
+ expect(layout.value).toBe('horizontal')
61
+ toggleLayout()
62
+ expect(layout.value).toBe('vertical')
63
+ })
64
+
65
+ it('cycleTheme rotates through themes', async () => {
66
+ const mod = await import('../src/composables/useReadingMode')
67
+ useReadingMode = mod.useReadingMode
68
+ const { theme, cycleTheme } = useReadingMode()
69
+ const themes = ['light', 'sepia', 'dark', 'oled']
70
+ expect(theme.value).toBe('light')
71
+ for (let i = 1; i <= 4; i++) {
72
+ cycleTheme()
73
+ expect(theme.value).toBe(themes[i % 4])
74
+ }
75
+ })
76
+
77
+ it('layout watcher writes to localStorage (not immediate)', async () => {
78
+ localStorageStore.layout = 'horizontal'
79
+ const mod = await import('../src/composables/useReadingMode')
80
+ useReadingMode = mod.useReadingMode
81
+ const { layout, setLayout } = useReadingMode()
82
+ // Layout watcher should NOT have overwritten saved value immediately
83
+ expect(localStorageStore.layout).toBe('horizontal')
84
+ await nextTick()
85
+ expect(localStorageStore.layout).toBe('horizontal')
86
+ // Now change layout — watcher should fire
87
+ setLayout('vertical')
88
+ await nextTick()
89
+ expect(localStorageStore.layout).toBe('vertical')
90
+ })
91
+
92
+ it('exposes all expected reactive refs and setters', async () => {
93
+ const mod = await import('../src/composables/useReadingMode')
94
+ useReadingMode = mod.useReadingMode
95
+ const r = useReadingMode()
96
+ expect(r.theme).toBeDefined()
97
+ expect(r.layout).toBeDefined()
98
+ expect(r.mainFontSize).toBeDefined()
99
+ expect(r.bodyFontSize).toBeDefined()
100
+ expect(r.annotationsVisible).toBeDefined()
101
+ expect(r.annotationPane).toBeDefined()
102
+ expect(r.setTheme).toBeTypeOf('function')
103
+ expect(r.cycleTheme).toBeTypeOf('function')
104
+ expect(r.setLayout).toBeTypeOf('function')
105
+ expect(r.toggleLayout).toBeTypeOf('function')
106
+ expect(r.setMainFontSize).toBeTypeOf('function')
107
+ expect(r.setBodyFontSize).toBeTypeOf('function')
108
+ expect(r.setAnnotationsVisible).toBeTypeOf('function')
109
+ expect(r.toggleAnnotationsVisible).toBeTypeOf('function')
110
+ expect(r.setAnnotationPane).toBeTypeOf('function')
111
+ expect(r.toggleAnnotationPane).toBeTypeOf('function')
112
+ })
113
+ })
@@ -52,17 +52,15 @@ function onKey(event: KeyboardEvent) {
52
52
  <template>
53
53
  <div @keydown="onKey">
54
54
  <router-view v-slot="{ Component, route }">
55
- <Transition name="page-fade" mode="out-in">
56
- <Suspense :key="route.fullPath">
57
- <component :is="Component" />
58
- <template #fallback>
59
- <div class="route-loading">
60
- <img v-if="logoUrl" :src="logoUrl" alt="" class="route-loading-logo" />
61
- <div v-else class="route-loading-seal">文</div>
62
- </div>
63
- </template>
64
- </Suspense>
65
- </Transition>
55
+ <Suspense>
56
+ <component :is="Component" :key="route.fullPath" />
57
+ <template #fallback>
58
+ <div class="route-loading">
59
+ <img v-if="logoUrl" :src="logoUrl" alt="" class="route-loading-logo" />
60
+ <div v-else class="route-loading-seal">文</div>
61
+ </div>
62
+ </template>
63
+ </Suspense>
66
64
  </router-view>
67
65
  <!-- 橫排模式才顯示浮動設定鈕 -->
68
66
  <ReadingToolbar v-if="!isVertical" />
@@ -2,6 +2,8 @@
2
2
  import { computed } from 'vue'
3
3
  import type { Annotation, VerseLine, PieceSource } from '../types'
4
4
  import { buildVerseAnnotations, renderAnnotatedText, resolveHoveredAnnotations } from '../composables/useAnnotationRenderer'
5
+ import { parseAnnotationBlock } from '../utils/annotationParser'
6
+ import PronunciationGroup from './PronunciationGroup.vue'
5
7
 
6
8
  const props = defineProps<{
7
9
  num: number
@@ -54,6 +56,10 @@ const sourceLabel = (() => {
54
56
  const r = props.source?.range as Record<string, string> | undefined
55
57
  return r?.chapter || ''
56
58
  })()
59
+
60
+ const annotationEntries = computed(() =>
61
+ props.annotationText ? parseAnnotationBlock(props.annotationText) : []
62
+ )
57
63
  </script>
58
64
 
59
65
  <template>
@@ -69,7 +75,22 @@ const sourceLabel = (() => {
69
75
  v-html="verseHtml(i)"
70
76
  />
71
77
  </div>
72
- <div v-if="annotationText" class="part-annotations">
78
+ <div v-if="annotationText && annotationEntries.length > 0" class="part-annotations">
79
+ <div v-for="entry in annotationEntries" :key="entry.num" class="part-ann-entry">
80
+ <div class="part-ann-head">
81
+ <span class="part-ann-num">{{ entry.numDisplay }}</span>
82
+ <span class="part-ann-term">{{ entry.term }}</span>
83
+ <PronunciationGroup
84
+ v-for="seg in entry.pronSegments"
85
+ :key="seg.lang"
86
+ :segment="seg"
87
+ class="part-ann-pron"
88
+ />
89
+ </div>
90
+ <div v-if="entry.definition" class="part-ann-def">{{ entry.definition }}</div>
91
+ </div>
92
+ </div>
93
+ <div v-else-if="annotationText" class="part-annotations">
73
94
  <div v-for="line in annotationText.split('\n')" :key="line" class="part-ann-line">{{ line }}</div>
74
95
  </div>
75
96
  </div>
@@ -114,6 +135,59 @@ const sourceLabel = (() => {
114
135
  margin-top: 16px;
115
136
  padding-top: 12px;
116
137
  border-top: 1px dashed var(--border-light);
138
+ text-align: start;
139
+ }
140
+
141
+ .part-ann-entry {
142
+ padding: 12px 0;
143
+ border-bottom: 1px solid var(--border-light);
144
+ }
145
+ .part-ann-entry:last-child {
146
+ border-bottom: none;
147
+ padding-bottom: 0;
148
+ }
149
+
150
+ .part-ann-head {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ align-items: baseline;
154
+ gap: 6px 10px;
155
+ margin-bottom: 4px;
156
+ }
157
+
158
+ .part-ann-num {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ width: 22px;
163
+ height: 22px;
164
+ border-radius: 4px;
165
+ background: var(--vermillion);
166
+ color: var(--paper);
167
+ font-family: var(--sans);
168
+ font-size: 12px;
169
+ font-weight: 700;
170
+ flex-shrink: 0;
171
+ }
172
+
173
+ .part-ann-term {
174
+ font-weight: 700;
175
+ font-size: 1.05em;
176
+ color: var(--ink);
177
+ padding: 2px 8px;
178
+ background: var(--surface-warm);
179
+ border-radius: 3px;
180
+ }
181
+
182
+ .part-ann-pron {
183
+ margin-left: 2px;
184
+ }
185
+
186
+ .part-ann-def {
187
+ color: var(--ink-mid);
188
+ line-height: 2;
189
+ white-space: pre-line;
190
+ padding-left: 32px;
117
191
  }
118
192
 
119
193
  .part-ann-line {
@@ -188,4 +262,36 @@ const sourceLabel = (() => {
188
262
  border-top: none;
189
263
  border-left: 1px dashed var(--border-light);
190
264
  }
265
+
266
+ .part-block--vertical .part-ann-entry {
267
+ margin-bottom: 0;
268
+ margin-left: 16px;
269
+ padding: 0;
270
+ border-bottom: none;
271
+ }
272
+
273
+ .part-block--vertical .part-ann-head {
274
+ align-items: flex-start;
275
+ gap: 4px;
276
+ }
277
+
278
+ .part-block--vertical .part-ann-num {
279
+ width: auto;
280
+ height: auto;
281
+ border-radius: 0;
282
+ background: none;
283
+ color: var(--vermillion);
284
+ font-size: inherit;
285
+ }
286
+
287
+ .part-block--vertical .part-ann-term {
288
+ background: none;
289
+ padding: 0;
290
+ font-size: inherit;
291
+ }
292
+
293
+ .part-block--vertical .part-ann-def {
294
+ padding-left: 0;
295
+ margin-left: 12px;
296
+ }
191
297
  </style>