@glossarist/concept-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -678,8 +678,103 @@ function processNewsPage(config, page) {
678
678
  console.log(`Generated news index: ${index.length} posts, ${postFiles.length} files copied to public/news/`);
679
679
  }
680
680
 
681
+ // --- Markdown-lite renderer (isomorphic, same logic as src/utils/markdown-lite.ts) ---
682
+
683
+ function renderMarkdown(input) {
684
+ const INLINE_PATTERNS = [
685
+ [/\*\*(.+?)\*\*/g, m => `<strong>${m[1]}</strong>`],
686
+ [/(?<!\*)\*([^*]+?)\*(?!\*)/g, m => `<em>${m[1]}</em>`],
687
+ [/`([^`]+?)`/g, m => `<code>${m[1]}</code>`],
688
+ [/\[([^\]]+)\]\(([^)]+)\)/g, m => `<a href="${m[2]}" target="_blank">${m[1]}</a>`],
689
+ ];
690
+ function renderInline(text) {
691
+ for (const [re, fn] of INLINE_PATTERNS) {
692
+ text = text.replace(re, (...args) => fn(args));
693
+ }
694
+ return text;
695
+ }
696
+ function escapeHtml(s) {
697
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
698
+ }
699
+
700
+ const blocks = [];
701
+ const lines = input.split('\n');
702
+ let i = 0;
703
+ while (i < lines.length) {
704
+ const line = lines[i];
705
+ if (line.trimStart().startsWith('```')) {
706
+ const lang = line.trim().slice(3);
707
+ const codeLines = [];
708
+ i++;
709
+ while (i < lines.length && !lines[i].trimStart().startsWith('```')) { codeLines.push(lines[i]); i++; }
710
+ i++;
711
+ blocks.push(`<pre><code${lang ? ` class="language-${lang}"` : ''}>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
712
+ continue;
713
+ }
714
+ const hm = line.match(/^(#{1,4})\s+(.+)/);
715
+ if (hm) { blocks.push(`<h${hm[1].length + 1}>${renderInline(hm[2])}</h${hm[1].length + 1}>`); i++; continue; }
716
+ if (/^---+\s*$/.test(line)) { blocks.push('<hr>'); i++; continue; }
717
+ if (/^\s*[-*]\s+/.test(line)) {
718
+ const items = [];
719
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push(`<li>${renderInline(lines[i].replace(/^\s*[-*]\s+/, ''))}</li>`); i++; }
720
+ blocks.push(`<ul>${items.join('')}</ul>`); continue;
721
+ }
722
+ if (/^\s*\d+\.\s+/.test(line)) {
723
+ const items = [];
724
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { items.push(`<li>${renderInline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`); i++; }
725
+ blocks.push(`<ol>${items.join('')}</ol>`); continue;
726
+ }
727
+ if (/^>\s?/.test(line)) {
728
+ const ql = [];
729
+ while (i < lines.length && /^>\s?/.test(lines[i])) { ql.push(lines[i].replace(/^>\s?/, '')); i++; }
730
+ blocks.push(`<blockquote>${renderInline(ql.join(' '))}</blockquote>`); continue;
731
+ }
732
+ if (!line.trim()) { i++; continue; }
733
+ const pl = [];
734
+ while (i < lines.length && lines[i].trim() && !/^#{1,4}\s/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) && !/^\s*\d+\.\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !lines[i].trimStart().startsWith('```')) { pl.push(lines[i]); i++; }
735
+ if (pl.length) blocks.push(`<p>${renderInline(pl.join(' '))}</p>`);
736
+ }
737
+ return blocks.join('\n');
738
+ }
739
+
740
+ function processContentPage(config, page) {
741
+ if (!page.source) {
742
+ console.warn(` Skipping content page '${page.route}': no source file`);
743
+ return;
744
+ }
745
+ const srcPath = path.resolve(ROOT, page.source);
746
+ if (!fs.existsSync(srcPath)) {
747
+ console.warn(` Skipping content page '${page.route}': source not found (${srcPath})`);
748
+ return;
749
+ }
750
+ const raw = fs.readFileSync(srcPath, 'utf8');
751
+ const ext = path.extname(srcPath).toLowerCase();
752
+ let html;
753
+ if (ext === '.html' || ext === '.htm') {
754
+ html = raw;
755
+ } else {
756
+ const stripped = stripFrontmatter(raw);
757
+ html = renderMarkdown(stripped);
758
+ }
759
+
760
+ const pagesDir = path.join(PUBLIC, 'pages');
761
+ fs.mkdirSync(pagesDir, { recursive: true });
762
+ writeJson(path.join(pagesDir, `${page.route}.json`), { title: page.title, html });
763
+ console.log(` Generated content page: ${page.route} (${ext})`);
764
+ }
765
+
766
+ function stripFrontmatter(text) {
767
+ const lines = text.split('\n');
768
+ if (lines[0] !== '---') return text;
769
+ let end = -1;
770
+ for (let i = 1; i < lines.length; i++) { if (lines[i] === '---') { end = i; break; } }
771
+ if (end < 0) return text;
772
+ return lines.slice(end + 1).join('\n').trim();
773
+ }
774
+
681
775
  const pageProcessors = {
682
776
  news: processNewsPage,
777
+ page: processContentPage,
683
778
  };
684
779
 
685
780
  function synthesizePages(config) {
@@ -6,7 +6,7 @@ const { config } = useSiteConfig();
6
6
 
7
7
  const poweredBy = computed(() => {
8
8
  const pb = config.value?.features?.poweredBy as { title?: string; url?: string } | undefined;
9
- return { title: pb?.title || 'Glossarist', url: pb?.url || 'https://glossarist.org' };
9
+ return { title: pb?.title || 'Glossarist Concept Browser', url: pb?.url || 'https://github.com/glossarist/concept-browser' };
10
10
  });
11
11
 
12
12
  const socialLinks = computed(() => {
@@ -51,7 +51,7 @@ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
51
51
  >{{ link.label }}</a>
52
52
  <span class="text-ink-200">|</span>
53
53
  <span class="text-xs">
54
- Powered by <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.title }}</a>
54
+ Built with the <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.title }}</a>
55
55
  </span>
56
56
  </div>
57
57
  </div>
@@ -31,6 +31,7 @@ const datasetEntries = computed(() => {
31
31
  });
32
32
 
33
33
  const currentManifest = computed(() => store.manifests.get(currentDataset.value));
34
+ const showDatasetNav = computed(() => !!currentManifest.value || !!siteConfig.value?.defaultDataset);
34
35
 
35
36
  function closeMobile() { ui.sidebarOpen = false; }
36
37
 
@@ -42,7 +43,8 @@ function goToDataset(id: string) {
42
43
  function pageRoute(page: { route: string; datasetScoped?: boolean }): string {
43
44
  if (!page.route) return '/';
44
45
  if (page.datasetScoped) {
45
- return `/dataset/${currentDataset.value}/${page.route}`;
46
+ const dsId = currentDataset.value || siteConfig.value?.defaultDataset || '';
47
+ return `/dataset/${dsId}/${page.route}`;
46
48
  }
47
49
  return `/${page.route}`;
48
50
  }
@@ -84,8 +86,8 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
84
86
  </nav>
85
87
 
86
88
  <!-- Dataset-level navigation (shown when viewing a dataset) -->
87
- <div v-if="currentManifest" class="mb-6">
88
- <div class="section-label">{{ currentManifest.title }}</div>
89
+ <div v-if="showDatasetNav" class="mb-6">
90
+ <div class="section-label">{{ currentManifest?.title || siteConfig?.title || 'Dataset' }}</div>
89
91
  <nav class="space-y-0.5">
90
92
  <router-link
91
93
  v-for="page in datasetPages"
@@ -126,13 +128,13 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
126
128
  <!-- Powered by -->
127
129
  <div class="mt-6 pt-4 border-t border-ink-100/60">
128
130
  <div class="text-[11px] text-ink-300">
129
- Powered by
131
+ Built with the
130
132
  <a
131
133
  :href="(siteConfig?.features?.poweredBy as any)?.url || 'https://glossarist.org'"
132
134
  target="_blank"
133
135
  rel="noopener"
134
136
  class="concept-link"
135
- >{{ (siteConfig?.features?.poweredBy as any)?.title || 'Glossarist' }}</a>
137
+ >{{ (siteConfig?.features?.poweredBy as any)?.title || 'Glossarist Concept Browser' }}</a>
136
138
  </div>
137
139
  </div>
138
140
  </div>
@@ -1,13 +1,18 @@
1
- import { ref, onMounted, watch } from 'vue';
1
+ import { ref, computed, onMounted, watch } from 'vue';
2
2
  import { useVocabularyStore } from '../stores/vocabulary';
3
+ import { useSiteConfig } from '../config/use-site-config';
3
4
 
4
- export function useDatasetLoader(registerId: () => string) {
5
+ export function useDatasetLoader(registerId: () => string | undefined) {
5
6
  const store = useVocabularyStore();
7
+ const { config } = useSiteConfig();
6
8
  const loading = ref(false);
7
9
  const localError = ref<string | null>(null);
8
10
 
11
+ const resolvedId = computed(() => registerId() || config.value?.defaultDataset || '');
12
+
9
13
  async function ensureLoaded() {
10
- const id = registerId();
14
+ const id = resolvedId.value;
15
+ if (!id) return;
11
16
  const adapter = store.datasets.get(id);
12
17
  if (adapter?.index) return;
13
18
  loading.value = true;
@@ -21,7 +26,7 @@ export function useDatasetLoader(registerId: () => string) {
21
26
  }
22
27
 
23
28
  onMounted(ensureLoaded);
24
- watch(registerId, ensureLoaded);
29
+ watch(resolvedId, ensureLoaded);
25
30
 
26
- return { loading, localError, ensureLoaded };
31
+ return { loading, localError, ensureLoaded, resolvedId };
27
32
  }
@@ -121,7 +121,7 @@ export const FORMAT_LABELS: Record<string, string> = {
121
121
 
122
122
  // === Pages ===
123
123
 
124
- export type PageType = 'news' | 'contributors' | 'about' | 'stats' | 'custom';
124
+ export type PageType = 'page' | 'news' | 'contributors' | 'about' | 'stats' | 'custom';
125
125
 
126
126
  export interface PageConfig {
127
127
  type: PageType;
@@ -50,11 +50,33 @@ const routes: RouteRecordRaw[] = [
50
50
  name: 'contributors',
51
51
  component: () => import('../views/ContributorsView.vue'),
52
52
  },
53
+ {
54
+ path: '/about',
55
+ name: 'about-global',
56
+ component: () => import('../views/AboutView.vue'),
57
+ },
58
+ {
59
+ path: '/stats',
60
+ name: 'stats-global',
61
+ component: () => import('../views/StatsView.vue'),
62
+ },
53
63
  {
54
64
  path: '/resolve/:uri(.*)',
55
65
  name: 'resolve',
56
66
  component: () => import('../views/ResolveView.vue'),
57
67
  },
68
+ // Catch-all for custom content pages (lowest priority)
69
+ {
70
+ path: '/dataset/:registerId/:page',
71
+ name: 'dataset-page',
72
+ component: () => import('../views/PageView.vue'),
73
+ props: true,
74
+ },
75
+ {
76
+ path: '/:slug',
77
+ name: 'page',
78
+ component: () => import('../views/PageView.vue'),
79
+ },
58
80
  ];
59
81
 
60
82
  const router = createRouter({
package/src/style.css CHANGED
@@ -160,51 +160,77 @@
160
160
  }
161
161
 
162
162
  /* Prose content (news posts, etc.) */
163
+ .prose-page,
163
164
  .prose-news {
164
165
  @apply text-sm text-ink-700 leading-relaxed;
165
166
  }
167
+ .prose-page h2,
166
168
  .prose-news h2 {
167
169
  @apply font-serif text-xl text-ink-800 mt-6 mb-3;
168
170
  }
171
+ .prose-page h3,
169
172
  .prose-news h3 {
170
173
  @apply font-serif text-lg text-ink-800 mt-5 mb-2;
171
174
  }
175
+ .prose-page h4,
172
176
  .prose-news h4 {
173
177
  @apply font-semibold text-ink-800 mt-4 mb-2;
174
178
  }
179
+ .prose-page p,
175
180
  .prose-news p {
176
181
  @apply mb-4;
177
182
  }
183
+ .prose-page ul, .prose-page ol,
178
184
  .prose-news ul, .prose-news ol {
179
185
  @apply mb-4 pl-5;
180
186
  }
187
+ .prose-page ul,
181
188
  .prose-news ul {
182
189
  @apply list-disc;
183
190
  }
191
+ .prose-page ol,
184
192
  .prose-news ol {
185
193
  @apply list-decimal;
186
194
  }
195
+ .prose-page li,
187
196
  .prose-news li {
188
197
  @apply mb-1;
189
198
  }
199
+ .prose-news li.list-level-2 {
200
+ @apply pl-3;
201
+ list-style-type: circle;
202
+ }
203
+ .prose-page a,
190
204
  .prose-news a {
191
205
  @apply concept-link;
192
206
  }
207
+ .prose-page strong,
193
208
  .prose-news strong {
194
209
  @apply font-semibold text-ink-800;
195
210
  }
211
+ .prose-page em,
196
212
  .prose-news em {
197
213
  @apply italic;
198
214
  }
215
+ .prose-page code,
199
216
  .prose-news code {
200
217
  @apply bg-ink-50 text-ink-600 px-1 rounded text-xs font-mono;
201
218
  }
219
+ .prose-page pre,
202
220
  .prose-news pre {
203
221
  @apply bg-ink-50 rounded-lg p-4 mb-4 overflow-x-auto text-xs font-mono;
204
222
  }
223
+ .prose-page pre code,
205
224
  .prose-news pre code {
206
225
  @apply bg-transparent px-0 text-ink-700;
207
226
  }
227
+ .prose-page blockquote,
228
+ .prose-news blockquote {
229
+ @apply border-l-4 border-ink-200 pl-4 italic text-ink-500 mb-4;
230
+ }
231
+ .prose-page hr {
232
+ @apply border-ink-100 my-6;
233
+ }
208
234
  }
209
235
 
210
236
  @layer utilities {
@@ -53,11 +53,14 @@ export function renderAsciiDocLite(text: string): string {
53
53
  }
54
54
 
55
55
  // Unordered list item
56
- if (trimmed.match(/^\*\s+/)) {
56
+ if (trimmed.match(/^\*+\s+/)) {
57
57
  flushParagraph(output);
58
58
  const items: string[] = [];
59
- while (i < lines.length && lines[i].trim().match(/^\*\s+/)) {
60
- items.push(`<li>${inlineFormat(lines[i].trim().replace(/^\*\s+/, ''))}</li>`);
59
+ while (i < lines.length && lines[i].trim().match(/^\*+\s+/)) {
60
+ const itemLine = lines[i].trim();
61
+ const stars = itemLine.match(/^(\*+)\s+/)?.[1].length ?? 1;
62
+ const text = itemLine.replace(/^\*+\s+/, '');
63
+ items.push(`<li class="list-level-${stars}">${inlineFormat(text)}</li>`);
61
64
  i++;
62
65
  }
63
66
  output.push(`<ul>${items.join('')}</ul>`);
@@ -0,0 +1,109 @@
1
+ const INLINE_PATTERNS: [RegExp, (m: RegExpMatchArray) => string][] = [
2
+ [/\*\*(.+?)\*\*/g, m => `<strong>${m[1]}</strong>`],
3
+ [/(?<!\*)\*([^*]+?)\*(?!\*)/g, m => `<em>${m[1]}</em>`],
4
+ [/`([^`]+?)`/g, m => `<code>${m[1]}</code>`],
5
+ [/\[([^\]]+)\]\(([^)]+)\)/g, m => `<a href="${m[2]}" target="_blank">${m[1]}</a>`],
6
+ ];
7
+
8
+ function renderInline(text: string): string {
9
+ for (const [re, fn] of INLINE_PATTERNS) {
10
+ text = text.replace(re, (...args) => fn(args as any));
11
+ }
12
+ return text;
13
+ }
14
+
15
+ export function renderMarkdown(input: string): string {
16
+ const blocks: string[] = [];
17
+ const lines = input.split('\n');
18
+ let i = 0;
19
+
20
+ while (i < lines.length) {
21
+ const line = lines[i];
22
+
23
+ // Code fence
24
+ if (line.trimStart().startsWith('```')) {
25
+ const lang = line.trim().slice(3);
26
+ const codeLines: string[] = [];
27
+ i++;
28
+ while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
29
+ codeLines.push(lines[i]);
30
+ i++;
31
+ }
32
+ i++; // skip closing fence
33
+ blocks.push(`<pre><code${lang ? ` class="language-${lang}"` : ''}>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
34
+ continue;
35
+ }
36
+
37
+ // Heading
38
+ const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
39
+ if (headingMatch) {
40
+ const level = headingMatch[1].length + 1; // h2-h5 (h1 reserved for page title)
41
+ blocks.push(`<h${level}>${renderInline(headingMatch[2])}</h${level}>`);
42
+ i++;
43
+ continue;
44
+ }
45
+
46
+ // Horizontal rule
47
+ if (/^---+\s*$/.test(line)) {
48
+ blocks.push('<hr>');
49
+ i++;
50
+ continue;
51
+ }
52
+
53
+ // Unordered list
54
+ if (/^\s*[-*]\s+/.test(line)) {
55
+ const items: string[] = [];
56
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
57
+ items.push(`<li>${renderInline(lines[i].replace(/^\s*[-*]\s+/, ''))}</li>`);
58
+ i++;
59
+ }
60
+ blocks.push(`<ul>${items.join('')}</ul>`);
61
+ continue;
62
+ }
63
+
64
+ // Ordered list
65
+ if (/^\s*\d+\.\s+/.test(line)) {
66
+ const items: string[] = [];
67
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
68
+ items.push(`<li>${renderInline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
69
+ i++;
70
+ }
71
+ blocks.push(`<ol>${items.join('')}</ol>`);
72
+ continue;
73
+ }
74
+
75
+ // Blockquote
76
+ if (/^>\s?/.test(line)) {
77
+ const quoteLines: string[] = [];
78
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
79
+ quoteLines.push(lines[i].replace(/^>\s?/, ''));
80
+ i++;
81
+ }
82
+ blocks.push(`<blockquote>${renderInline(quoteLines.join(' '))}</blockquote>`);
83
+ continue;
84
+ }
85
+
86
+ // Blank line
87
+ if (!line.trim()) {
88
+ i++;
89
+ continue;
90
+ }
91
+
92
+ // Paragraph
93
+ const paraLines: string[] = [];
94
+ while (i < lines.length && lines[i].trim() && !/^#{1,4}\s/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) && !/^\s*\d+\.\s+/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !line.trimStart().startsWith('```')) {
95
+ paraLines.push(lines[i]);
96
+ i++;
97
+ if (i >= lines.length) break;
98
+ }
99
+ if (paraLines.length) {
100
+ blocks.push(`<p>${renderInline(paraLines.join(' '))}</p>`);
101
+ }
102
+ }
103
+
104
+ return blocks.join('\n');
105
+ }
106
+
107
+ function escapeHtml(s: string): string {
108
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
109
+ }
@@ -5,13 +5,13 @@ import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
6
6
  import { langName, langLabel } from '../utils/lang';
7
7
 
8
- const props = defineProps<{ registerId: string }>();
8
+ const props = defineProps<{ registerId?: string }>();
9
9
 
10
10
  const store = useVocabularyStore();
11
11
  const { getColor } = useDsStyle();
12
- const { loading, localError, ensureLoaded } = useDatasetLoader(() => props.registerId);
12
+ const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
13
13
 
14
- const manifest = computed(() => store.manifests.get(props.registerId));
14
+ const manifest = computed(() => store.manifests.get(resolvedId.value));
15
15
  </script>
16
16
 
17
17
  <template>
@@ -20,7 +20,7 @@ const manifest = computed(() => store.manifests.get(props.registerId));
20
20
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
21
21
  <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
22
22
  <span class="text-ink-200">/</span>
23
- <router-link :to="{ name: 'dataset', params: { registerId } }" class="hover:text-ink-700 transition-colors">{{ manifest?.title || registerId }}</router-link>
23
+ <router-link :to="{ name: 'dataset', params: { registerId: resolvedId } }" class="hover:text-ink-700 transition-colors">{{ manifest?.title || resolvedId }}</router-link>
24
24
  <span class="text-ink-200">/</span>
25
25
  <span class="text-ink-700">About</span>
26
26
  </nav>
@@ -109,8 +109,8 @@ const manifest = computed(() => store.manifests.get(props.registerId));
109
109
  :key="tag"
110
110
  class="badge"
111
111
  :style="{
112
- backgroundColor: getColor(registerId) + '15',
113
- color: getColor(registerId),
112
+ backgroundColor: getColor(resolvedId) + '15',
113
+ color: getColor(resolvedId),
114
114
  }"
115
115
  >
116
116
  {{ tag }}
@@ -64,7 +64,11 @@ function stripFrontmatter(text: string): string {
64
64
  function formatDate(dateStr: string) {
65
65
  if (!dateStr) return '';
66
66
  try {
67
- return new Date(dateStr).toLocaleDateString('en-US', {
67
+ // Handle Jekyll-style dates: "2024-06-19 00:00:00 +0800"
68
+ const normalized = dateStr
69
+ .replace(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s+([+-]\d{2})(\d{2})$/, '$1T$2$3:$4')
70
+ .replace(/^(\d{4}-\d{2}-\d{2})\s+([+-]\d{2})(\d{2})$/, '$1T00:00:00$2:$3');
71
+ return new Date(normalized).toLocaleDateString('en-US', {
68
72
  year: 'numeric', month: 'long', day: 'numeric',
69
73
  });
70
74
  } catch {
@@ -136,7 +140,7 @@ function formatDate(dateStr: string) {
136
140
  <p v-if="activeSlug !== post.slug && post.excerpt" class="text-ink-500 text-sm mt-2 leading-relaxed">{{ post.excerpt }}</p>
137
141
  </button>
138
142
 
139
- <div v-if="activeSlug === post.slug" class="card border-t-0 rounded-t-none p-6 pt-2 -mt-1">
143
+ <div v-if="activeSlug === post.slug" class="card rounded-t-none -mt-1 p-6 pt-3 border-t border-ink-100/40">
140
144
  <div v-if="activeLoading" class="animate-pulse space-y-2">
141
145
  <div class="h-4 bg-ink-100 rounded w-full"></div>
142
146
  <div class="h-4 bg-ink-100 rounded w-5/6"></div>
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue';
3
+ import { useRoute } from 'vue-router';
4
+
5
+ interface PageData {
6
+ title: string;
7
+ html: string;
8
+ }
9
+
10
+ const route = useRoute();
11
+ const slug = computed(() => (route.params.slug || route.params.page || '') as string);
12
+ const data = ref<PageData | null>(null);
13
+ const loading = ref(true);
14
+ const notFound = ref(false);
15
+
16
+ onMounted(async () => {
17
+ try {
18
+ const resp = await fetch(`/pages/${slug.value}.json`);
19
+ if (resp.ok) {
20
+ data.value = await resp.json();
21
+ } else {
22
+ notFound.value = true;
23
+ }
24
+ } catch {
25
+ notFound.value = true;
26
+ }
27
+ loading.value = false;
28
+ });
29
+ </script>
30
+
31
+ <template>
32
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
33
+ <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
34
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
35
+ <span class="text-ink-200">/</span>
36
+ <span class="text-ink-700">{{ data?.title || slug }}</span>
37
+ </nav>
38
+
39
+ <template v-if="loading">
40
+ <div class="animate-pulse space-y-6">
41
+ <div class="h-8 bg-ink-100 rounded w-48"></div>
42
+ <div class="h-4 bg-ink-100 rounded w-full"></div>
43
+ <div class="h-4 bg-ink-100 rounded w-5/6"></div>
44
+ <div class="h-4 bg-ink-100 rounded w-4/6"></div>
45
+ </div>
46
+ </template>
47
+
48
+ <template v-else-if="notFound">
49
+ <div class="card p-8 text-center">
50
+ <h1 class="font-serif text-2xl text-ink-800 mb-2">Page Not Found</h1>
51
+ <p class="text-ink-500 mb-4">The page "{{ slug }}" does not exist.</p>
52
+ <router-link :to="{ name: 'home' }" class="btn-primary">Go Home</router-link>
53
+ </div>
54
+ </template>
55
+
56
+ <template v-else-if="data">
57
+ <h1 class="font-serif text-3xl text-ink-800 mb-6">{{ data.title }}</h1>
58
+ <div class="prose-page" v-html="data.html"></div>
59
+ </template>
60
+ </div>
61
+ </template>
@@ -5,13 +5,13 @@ import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
6
6
  import { langName, langLabel } from '../utils/lang';
7
7
 
8
- const props = defineProps<{ registerId: string }>();
8
+ const props = defineProps<{ registerId?: string }>();
9
9
 
10
10
  const store = useVocabularyStore();
11
11
  const { getColor } = useDsStyle();
12
- const { loading, localError, ensureLoaded } = useDatasetLoader(() => props.registerId);
12
+ const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
13
13
 
14
- const manifest = computed(() => store.manifests.get(props.registerId));
14
+ const manifest = computed(() => store.manifests.get(resolvedId.value));
15
15
 
16
16
  interface LangStat {
17
17
  lang: string;
@@ -49,7 +49,7 @@ const maxTerms = computed(() => Math.max(...stats.value.langs.map(l => l.terms),
49
49
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
50
50
  <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
51
51
  <span class="text-ink-200">/</span>
52
- <router-link :to="{ name: 'dataset', params: { registerId } }" class="hover:text-ink-700 transition-colors">{{ manifest?.title || registerId }}</router-link>
52
+ <router-link :to="{ name: 'dataset', params: { registerId: resolvedId } }" class="hover:text-ink-700 transition-colors">{{ manifest?.title || resolvedId }}</router-link>
53
53
  <span class="text-ink-200">/</span>
54
54
  <span class="text-ink-700">Statistics</span>
55
55
  </nav>
@@ -105,7 +105,7 @@ const maxTerms = computed(() => Math.max(...stats.value.langs.map(l => l.terms),
105
105
  class="h-full rounded-full transition-all duration-500"
106
106
  :style="{
107
107
  width: (s.terms / maxTerms * 100) + '%',
108
- backgroundColor: getColor(registerId),
108
+ backgroundColor: getColor(resolvedId),
109
109
  }"
110
110
  ></div>
111
111
  </div>