@glossarist/concept-browser 0.2.5 → 0.2.7
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 +1 -1
- package/scripts/generate-data.mjs +44 -17
- package/src/__tests__/data-integration.test.ts +3 -2
- package/src/__tests__/uri-router.test.ts +12 -9
- package/src/adapters/UriRouter.ts +2 -2
- package/src/adapters/factory.ts +2 -2
- package/src/components/AppSidebar.vue +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/use-site-config.ts +13 -9
- package/src/router/index.ts +2 -2
- package/src/views/PageView.vue +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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": {
|
|
@@ -90,12 +90,35 @@ function sourcesToJsonLd(sources) {
|
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function refsToJsonLd(refs) {
|
|
93
|
+
function refsToJsonLd(refs, refMaps) {
|
|
94
94
|
if (!refs || !Array.isArray(refs)) return [];
|
|
95
|
-
return refs.map(r =>
|
|
96
|
-
'@id': r.id,
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
return refs.map(r => {
|
|
96
|
+
if (r.id) return { '@id': r.id, 'gl:term': r.term };
|
|
97
|
+
if (r.term && refMaps) {
|
|
98
|
+
const uri = resolveRefUri(r.term, refMaps);
|
|
99
|
+
if (uri) return { '@id': uri, 'gl:term': r.term };
|
|
100
|
+
}
|
|
101
|
+
return { '@id': r.id || r.term, 'gl:term': r.term };
|
|
102
|
+
}).filter(r => r['@id']);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveRefUri(term, refMaps) {
|
|
106
|
+
const base = refMaps.uriBase;
|
|
107
|
+
const urnPrefix = 'urn:iso:std:iso:';
|
|
108
|
+
if (term.startsWith(urnPrefix)) {
|
|
109
|
+
const rest = term.slice(urnPrefix.length);
|
|
110
|
+
const match = rest.match(/^(\d+):(.+)$/);
|
|
111
|
+
if (match) {
|
|
112
|
+
const dsId = refMaps.urnStandardMap[match[1]];
|
|
113
|
+
if (dsId) return `${base}/${dsId}/concept/${match[2]}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const ievMatch = term.match(/^IEV:(\d+[-\d]+)$/);
|
|
117
|
+
if (ievMatch) {
|
|
118
|
+
const dsId = refMaps.refPrefixMap['IEV'];
|
|
119
|
+
if (dsId) return `${base}/${dsId}/concept/${ievMatch[1]}`;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
function buildRefMaps(config) {
|
|
@@ -119,12 +142,14 @@ function buildRefMaps(config) {
|
|
|
119
142
|
if (xref.refPrefixMap) Object.assign(refPrefixMap, xref.refPrefixMap);
|
|
120
143
|
if (xref.urnStandardMap) Object.assign(urnStandardMap, xref.urnStandardMap);
|
|
121
144
|
|
|
122
|
-
|
|
145
|
+
const uriBase = config.uriBase || `https://${config.domain}`;
|
|
146
|
+
return { refPrefixMap, urnStandardMap, uriBase };
|
|
123
147
|
}
|
|
124
148
|
|
|
125
|
-
function extractInlineRefs(localizedData,
|
|
149
|
+
function extractInlineRefs(localizedData, refMaps) {
|
|
126
150
|
const refs = [];
|
|
127
151
|
const texts = [];
|
|
152
|
+
const { refPrefixMap, urnStandardMap, uriBase } = refMaps;
|
|
128
153
|
|
|
129
154
|
if (localizedData.definition) {
|
|
130
155
|
const defs = Array.isArray(localizedData.definition) ? localizedData.definition : [localizedData.definition];
|
|
@@ -140,17 +165,17 @@ function extractInlineRefs(localizedData, refPrefixMap, urnStandardMap) {
|
|
|
140
165
|
|
|
141
166
|
for (const m of fullText.matchAll(/\{\{([^,}]+),\s*IEV:([^}]+)\}\}/g)) {
|
|
142
167
|
const datasetId = refPrefixMap['IEV'];
|
|
143
|
-
if (datasetId) refs.push({ id:
|
|
168
|
+
if (datasetId) refs.push({ id: `${uriBase}/${datasetId}/concept/${m[2]}`, term: m[1].trim() });
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
for (const m of fullText.matchAll(/\{urn:iso:std:iso:(\d+):([^,}]+),([^,}]+)(?:,([^}]+))?\}/g)) {
|
|
147
172
|
const datasetId = urnStandardMap[m[1]];
|
|
148
|
-
if (datasetId) refs.push({ id:
|
|
173
|
+
if (datasetId) refs.push({ id: `${uriBase}/${datasetId}/concept/${m[2]}`, term: (m[4] || m[3]).trim() });
|
|
149
174
|
}
|
|
150
175
|
|
|
151
176
|
for (const m of fullText.matchAll(/\{\{urn:iso:std:iso:(\d+):([^,}]+),([^,}]+)(?:,([^}]+))?\}\}/g)) {
|
|
152
177
|
const datasetId = urnStandardMap[m[1]];
|
|
153
|
-
if (datasetId) refs.push({ id:
|
|
178
|
+
if (datasetId) refs.push({ id: `${uriBase}/${datasetId}/concept/${m[2]}`, term: (m[4] || m[3]).trim() });
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
const seen = new Set();
|
|
@@ -165,9 +190,10 @@ const LANG_CODES = ['eng', 'ara', 'deu', 'fra', 'spa', 'ita', 'jpn', 'kor', 'pol
|
|
|
165
190
|
|
|
166
191
|
function yamlToJsonLd(conceptYaml, register, refMaps) {
|
|
167
192
|
const termid = String(conceptYaml.termid);
|
|
193
|
+
const base = refMaps.uriBase;
|
|
168
194
|
const doc = {
|
|
169
195
|
'@context': 'https://glossarist.org/ns/context.jsonld',
|
|
170
|
-
'@id':
|
|
196
|
+
'@id': `${base}/${register}/concept/${termid}`,
|
|
171
197
|
'@type': 'gl:Concept',
|
|
172
198
|
'gl:identifier': termid,
|
|
173
199
|
};
|
|
@@ -178,7 +204,7 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
|
|
|
178
204
|
if (!lc) continue;
|
|
179
205
|
|
|
180
206
|
const lDoc = {
|
|
181
|
-
'@id':
|
|
207
|
+
'@id': `${base}/${register}/concept/${termid}/${lang}`,
|
|
182
208
|
'@type': 'gl:LocalizedConcept',
|
|
183
209
|
'gl:languageCode': lang,
|
|
184
210
|
};
|
|
@@ -204,11 +230,11 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
|
|
|
204
230
|
}));
|
|
205
231
|
}
|
|
206
232
|
if (lc.references && lc.references.length > 0) {
|
|
207
|
-
lDoc['gl:references'] = refsToJsonLd(lc.references);
|
|
233
|
+
lDoc['gl:references'] = refsToJsonLd(lc.references, refMaps);
|
|
208
234
|
} else if (refMaps) {
|
|
209
|
-
const inlineRefs = extractInlineRefs(lc, refMaps
|
|
235
|
+
const inlineRefs = extractInlineRefs(lc, refMaps);
|
|
210
236
|
if (inlineRefs.length > 0) {
|
|
211
|
-
lDoc['gl:references'] = refsToJsonLd(inlineRefs);
|
|
237
|
+
lDoc['gl:references'] = refsToJsonLd(inlineRefs, refMaps);
|
|
212
238
|
}
|
|
213
239
|
}
|
|
214
240
|
|
|
@@ -515,7 +541,7 @@ function processDataset(dir, register, opts) {
|
|
|
515
541
|
fs.writeFileSync(
|
|
516
542
|
path.join(DATA, register, 'graph-nodes.json'),
|
|
517
543
|
JSON.stringify({
|
|
518
|
-
uriPrefix:
|
|
544
|
+
uriPrefix: `${refMaps.uriBase}/${register}/concept/`,
|
|
519
545
|
registerId: register,
|
|
520
546
|
nodes: graphNodeEntries,
|
|
521
547
|
}),
|
|
@@ -583,7 +609,7 @@ function processDataset(dir, register, opts) {
|
|
|
583
609
|
conceptUrlTemplate: '{baseUrl}/concepts/{conceptId}.json',
|
|
584
610
|
indexUrl: '{baseUrl}/index.json',
|
|
585
611
|
contextUrl: 'https://glossarist.org/ns/context.jsonld',
|
|
586
|
-
uriBase:
|
|
612
|
+
uriBase: refMaps.uriBase,
|
|
587
613
|
status: 'valid',
|
|
588
614
|
schemaVersion: '1.0.0',
|
|
589
615
|
tags: opts.tags,
|
|
@@ -872,6 +898,7 @@ function stripFrontmatter(text) {
|
|
|
872
898
|
const pageProcessors = {
|
|
873
899
|
news: processNewsPage,
|
|
874
900
|
page: processContentPage,
|
|
901
|
+
about: processContentPage,
|
|
875
902
|
};
|
|
876
903
|
|
|
877
904
|
function synthesizePages(config) {
|
|
@@ -56,6 +56,7 @@ describe('AdapterFactory', () => {
|
|
|
56
56
|
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
57
57
|
id: 'test',
|
|
58
58
|
datasetUri: 'https://glossarist.org/test/*',
|
|
59
|
+
uriBase: 'https://glossarist.org',
|
|
59
60
|
title: 'Test',
|
|
60
61
|
languages: ['eng'],
|
|
61
62
|
chunkSize: 500,
|
|
@@ -106,7 +107,7 @@ describe('AdapterFactory', () => {
|
|
|
106
107
|
|
|
107
108
|
// Load IEV
|
|
108
109
|
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
109
|
-
id: 'iev', datasetUri: 'urn:iec:std:iec:60050:*', title: 'IEV', languages: ['eng'], chunkSize: 500,
|
|
110
|
+
id: 'iev', datasetUri: 'urn:iec:std:iec:60050:*', uriBase: 'https://glossarist.org', title: 'IEV', languages: ['eng'], chunkSize: 500,
|
|
110
111
|
}));
|
|
111
112
|
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
112
113
|
registerId: 'iev', conceptCount: 0, chunkSize: 500, chunks: [], concepts: [],
|
|
@@ -115,7 +116,7 @@ describe('AdapterFactory', () => {
|
|
|
115
116
|
|
|
116
117
|
// Load TC 204
|
|
117
118
|
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
118
|
-
id: 'isotc204', datasetUri: 'urn:iso:std:iso:14812:*', title: 'TC 204', languages: ['eng'], chunkSize: 500,
|
|
119
|
+
id: 'isotc204', datasetUri: 'urn:iso:std:iso:14812:*', uriBase: 'https://glossarist.org', title: 'TC 204', languages: ['eng'], chunkSize: 500,
|
|
119
120
|
}));
|
|
120
121
|
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
121
122
|
registerId: 'isotc204', conceptCount: 0, chunkSize: 500, chunks: [], concepts: [],
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { UriRouter } from '../adapters/UriRouter';
|
|
3
3
|
|
|
4
|
+
const MOCK_MANIFEST = { uriBase: 'https://glossarist.org' } as any;
|
|
5
|
+
|
|
4
6
|
describe('UriRouter', () => {
|
|
5
7
|
it('resolves URIs for registered datasets', () => {
|
|
6
8
|
const router = new UriRouter();
|
|
7
|
-
router.registerDataset('iev', '/data/iev');
|
|
9
|
+
router.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
|
|
8
10
|
|
|
9
11
|
const resolved = router.resolveUri('https://glossarist.org/iev/concept/103-01-02');
|
|
10
12
|
expect(resolved).toEqual({ registerId: 'iev', conceptId: '103-01-02' });
|
|
@@ -12,7 +14,7 @@ describe('UriRouter', () => {
|
|
|
12
14
|
|
|
13
15
|
it('resolves URIs with multi-part concept IDs', () => {
|
|
14
16
|
const router = new UriRouter();
|
|
15
|
-
router.registerDataset('isotc204', '/data/isotc204');
|
|
17
|
+
router.registerDataset('isotc204', '/data/isotc204', MOCK_MANIFEST);
|
|
16
18
|
|
|
17
19
|
const resolved = router.resolveUri('https://glossarist.org/isotc204/concept/3.1.1.1');
|
|
18
20
|
expect(resolved).toEqual({ registerId: 'isotc204', conceptId: '3.1.1.1' });
|
|
@@ -20,36 +22,37 @@ describe('UriRouter', () => {
|
|
|
20
22
|
|
|
21
23
|
it('returns null for unknown register', () => {
|
|
22
24
|
const router = new UriRouter();
|
|
23
|
-
router.registerDataset('iev', '/data/iev');
|
|
25
|
+
router.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
|
|
24
26
|
|
|
25
27
|
expect(router.resolveUri('https://glossarist.org/unknown/concept/123')).toBeNull();
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
it('returns null for non-matching URI pattern', () => {
|
|
29
31
|
const router = new UriRouter();
|
|
30
|
-
router.registerDataset('iev', '/data/iev');
|
|
32
|
+
router.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
|
|
31
33
|
|
|
32
34
|
expect(router.resolveUri('https://example.com/other')).toBeNull();
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
it('builds URIs from register and concept ID', () => {
|
|
36
38
|
const router = new UriRouter();
|
|
39
|
+
router.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
|
|
37
40
|
expect(router.buildUri('iev', '103-01-02')).toBe('https://glossarist.org/iev/concept/103-01-02');
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
it('lists all registered IDs', () => {
|
|
41
44
|
const router = new UriRouter();
|
|
42
|
-
router.registerDataset('iev', '/data/iev');
|
|
43
|
-
router.registerDataset('isotc211', '/data/isotc211');
|
|
45
|
+
router.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
|
|
46
|
+
router.registerDataset('isotc211', '/data/isotc211', MOCK_MANIFEST);
|
|
44
47
|
|
|
45
48
|
expect(router.getRegisteredIds()).toEqual(['iev', 'isotc211']);
|
|
46
49
|
});
|
|
47
50
|
|
|
48
51
|
it('resolves across multiple registers', () => {
|
|
49
52
|
const router = new UriRouter();
|
|
50
|
-
router.registerDataset('iev', '/data/iev');
|
|
51
|
-
router.registerDataset('isotc211', '/data/isotc211');
|
|
52
|
-
router.registerDataset('isotc204', '/data/isotc204');
|
|
53
|
+
router.registerDataset('iev', '/data/iev', MOCK_MANIFEST);
|
|
54
|
+
router.registerDataset('isotc211', '/data/isotc211', MOCK_MANIFEST);
|
|
55
|
+
router.registerDataset('isotc204', '/data/isotc204', MOCK_MANIFEST);
|
|
53
56
|
|
|
54
57
|
expect(router.resolveUri('https://glossarist.org/iev/concept/102-01-01')?.registerId).toBe('iev');
|
|
55
58
|
expect(router.resolveUri('https://glossarist.org/isotc211/concept/10')?.registerId).toBe('isotc211');
|
|
@@ -9,7 +9,7 @@ export class UriRouter {
|
|
|
9
9
|
this.registerMap.set(registerId, {
|
|
10
10
|
baseUrl,
|
|
11
11
|
manifest: manifest ?? null,
|
|
12
|
-
uriBase: manifest?.uriBase ?? '
|
|
12
|
+
uriBase: manifest?.uriBase ?? '',
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -31,7 +31,7 @@ export class UriRouter {
|
|
|
31
31
|
|
|
32
32
|
buildUri(registerId: string, conceptId: string): string {
|
|
33
33
|
const info = this.registerMap.get(registerId);
|
|
34
|
-
const uriBase = info?.uriBase ?? '
|
|
34
|
+
const uriBase = info?.uriBase ?? '';
|
|
35
35
|
return `${uriBase}/${registerId}/concept/${conceptId}`;
|
|
36
36
|
}
|
|
37
37
|
|
package/src/adapters/factory.ts
CHANGED
|
@@ -50,8 +50,8 @@ export class AdapterFactory {
|
|
|
50
50
|
const uriPatterns = [
|
|
51
51
|
manifest.datasetUri,
|
|
52
52
|
...(manifest.uriAliases ?? []),
|
|
53
|
-
|
|
54
|
-
];
|
|
53
|
+
manifest.uriBase ? `${manifest.uriBase}/${registerId}/*` : undefined,
|
|
54
|
+
].filter(Boolean) as string[];
|
|
55
55
|
this.resolver.registerDataset(registerId, uriPatterns);
|
|
56
56
|
|
|
57
57
|
return adapter;
|
|
@@ -130,7 +130,7 @@ function isActive(page: { route: string; datasetScoped?: boolean }): boolean {
|
|
|
130
130
|
<div class="text-[11px] text-ink-300">
|
|
131
131
|
Built with the
|
|
132
132
|
<a
|
|
133
|
-
:href="(siteConfig?.features?.poweredBy as any)?.url || 'https://glossarist
|
|
133
|
+
:href="(siteConfig?.features?.poweredBy as any)?.url || 'https://github.com/glossarist/concept-browser'"
|
|
134
134
|
target="_blank"
|
|
135
135
|
rel="noopener"
|
|
136
136
|
class="concept-link"
|
package/src/config/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { PageConfig } from './types';
|
|
|
4
4
|
export interface RuntimeSiteConfig {
|
|
5
5
|
id: string;
|
|
6
6
|
domain: string;
|
|
7
|
+
uriBase?: string;
|
|
7
8
|
title: string;
|
|
8
9
|
subtitle?: string;
|
|
9
10
|
description?: string;
|
|
@@ -106,37 +107,40 @@ async function loadConfig(): Promise<RuntimeSiteConfig | null> {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
function synthesizeGlobalPages(features?: Record<string, unknown>, pages?: PageConfig[]): PageConfig[] {
|
|
109
|
-
|
|
110
|
+
const declared = pages?.filter(p => !p.datasetScoped) ?? [];
|
|
111
|
+
const declaredRoutes = new Set(declared.map(p => p.route));
|
|
110
112
|
|
|
111
113
|
const result: PageConfig[] = [
|
|
112
114
|
{ type: 'custom', route: '', title: 'Home', icon: 'home' },
|
|
113
115
|
];
|
|
114
|
-
if (features?.search !== false) {
|
|
116
|
+
if (features?.search !== false && !declaredRoutes.has('search')) {
|
|
115
117
|
result.push({ type: 'custom', route: 'search', title: 'Search', icon: 'search' });
|
|
116
118
|
}
|
|
117
|
-
if (features?.graph !== false) {
|
|
119
|
+
if (features?.graph !== false && !declaredRoutes.has('graph')) {
|
|
118
120
|
result.push({ type: 'custom', route: 'graph', title: 'Graph', icon: 'graph' });
|
|
119
121
|
}
|
|
120
|
-
if (features?.news) {
|
|
122
|
+
if (features?.news && !declaredRoutes.has('news')) {
|
|
121
123
|
result.push({ type: 'news', route: 'news', title: 'News', icon: 'newspaper' });
|
|
122
124
|
}
|
|
123
|
-
|
|
125
|
+
|
|
126
|
+
return [...result, ...declared];
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
function synthesizeDatasetPages(features?: Record<string, unknown>, pages?: PageConfig[]): PageConfig[] {
|
|
127
130
|
const declared = pages?.filter(p => p.datasetScoped) ?? [];
|
|
128
|
-
|
|
131
|
+
const declaredRoutes = new Set(declared.map(p => p.route));
|
|
129
132
|
|
|
130
133
|
const result: PageConfig[] = [
|
|
131
134
|
{ type: 'custom', route: '', title: 'Concepts', icon: 'list', datasetScoped: true },
|
|
132
135
|
];
|
|
133
|
-
if (features?.stats !== false) {
|
|
136
|
+
if (features?.stats !== false && !declaredRoutes.has('stats')) {
|
|
134
137
|
result.push({ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', datasetScoped: true });
|
|
135
138
|
}
|
|
136
|
-
if (features?.about !== false) {
|
|
139
|
+
if (features?.about !== false && !declaredRoutes.has('about')) {
|
|
137
140
|
result.push({ type: 'about', route: 'about', title: 'About', icon: 'info', datasetScoped: true });
|
|
138
141
|
}
|
|
139
|
-
|
|
142
|
+
|
|
143
|
+
return [...result, ...declared];
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
export function useSiteConfig() {
|
package/src/router/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ const routes: RouteRecordRaw[] = [
|
|
|
27
27
|
{
|
|
28
28
|
path: '/dataset/:registerId/about',
|
|
29
29
|
name: 'about',
|
|
30
|
-
component: () => import('../views/
|
|
30
|
+
component: () => import('../views/PageView.vue'),
|
|
31
31
|
props: true,
|
|
32
32
|
},
|
|
33
33
|
{
|
|
@@ -53,7 +53,7 @@ const routes: RouteRecordRaw[] = [
|
|
|
53
53
|
{
|
|
54
54
|
path: '/about',
|
|
55
55
|
name: 'about-global',
|
|
56
|
-
component: () => import('../views/
|
|
56
|
+
component: () => import('../views/PageView.vue'),
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
path: '/stats',
|
package/src/views/PageView.vue
CHANGED
|
@@ -8,7 +8,14 @@ interface PageData {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const route = useRoute();
|
|
11
|
-
const slug = computed(() =>
|
|
11
|
+
const slug = computed(() => {
|
|
12
|
+
if (route.params.slug) return route.params.slug as string;
|
|
13
|
+
if (route.params.page) return route.params.page as string;
|
|
14
|
+
if (route.name === 'about' || route.name === 'about-global') return 'about';
|
|
15
|
+
const path = route.path.replace(/^\//, '').replace(/\/$/, '');
|
|
16
|
+
const lastSegment = path.split('/').pop() || '';
|
|
17
|
+
return lastSegment;
|
|
18
|
+
});
|
|
12
19
|
const data = ref<PageData | null>(null);
|
|
13
20
|
const loading = ref(true);
|
|
14
21
|
const notFound = ref(false);
|