@glossarist/concept-browser 0.7.42 → 0.7.44

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/cli/index.mjs CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { loadSiteConfig } from '../scripts/load-site-config.mjs';
23
- import { execSync } from 'child_process';
23
+ import { existsSync } from 'fs';
24
24
  import { resolve, dirname } from 'path';
25
25
  import { fileURLToPath } from 'url';
26
26
 
@@ -166,22 +166,21 @@ Environment:
166
166
  }
167
167
  }
168
168
 
169
- // Run vite build using the package's vite.config.ts
169
+ // Run vite build using the package's vite.config.ts via programmatic API
170
170
  console.log(`\n=== BUILD SPA ===\n`);
171
171
  const viteConfig = resolve(pkgRoot, 'vite.config.ts');
172
- const viteBin = [resolve(pkgRoot, 'node_modules', '.bin', 'vite'), 'vite'].find(p => {
173
- try { execSync(`${p} --version`, { stdio: 'pipe' }); return true; } catch { return false; }
174
- });
175
- execSync(`${viteBin} build --config ${viteConfig}`, {
176
- stdio: 'inherit',
177
- env: { ...process.env },
172
+ const { build: viteBuild } = await import('vite');
173
+ await viteBuild({
174
+ configFile: viteConfig,
175
+ root: pkgRoot,
176
+ mode: 'production',
178
177
  });
179
178
 
180
- // Run postbuild (404 page)
181
- try {
182
- const postbuild = resolve(pkgRoot, 'scripts', 'generate-404.js');
183
- execSync(`node ${postbuild}`, { stdio: 'inherit' });
184
- } catch {}
179
+ // Run postbuild (404 page) via dynamic import
180
+ const postbuild = resolve(pkgRoot, 'scripts', 'generate-404.js');
181
+ if (existsSync(postbuild)) {
182
+ await import(`file://${postbuild}`);
183
+ }
185
184
 
186
185
  return;
187
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.42",
3
+ "version": "0.7.44",
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": {
@@ -27,6 +27,7 @@
27
27
  "favicons": "^7.2.0",
28
28
  "glossarist": "^0.3.7",
29
29
  "js-yaml": "^4.1.0",
30
+ "jszip": "^3.10.1",
30
31
  "pinia": "^2.3.1",
31
32
  "postcss": "^8.5.3",
32
33
  "sharp": "^0.34.5",
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getGroups } from '../lib/concept-groups.mjs';
3
+
4
+ describe('getGroups', () => {
5
+ it('returns explicit eng.groups when present', () => {
6
+ expect(getGroups({ eng: { groups: ['g1', 'g2'] }, termid: 1 })).toEqual(['g1', 'g2']);
7
+ });
8
+
9
+ it('derives groups from _domains with ref_type=section', () => {
10
+ expect(getGroups({
11
+ termid: 1,
12
+ _domains: [
13
+ { ref_type: 'section', concept_id: 'section-102-01' },
14
+ { ref_type: 'section', concept_id: 'section-102-02' },
15
+ ],
16
+ })).toEqual(['102-01', '102-02']);
17
+ });
18
+
19
+ it('ignores _domains entries without ref_type=section', () => {
20
+ expect(getGroups({
21
+ termid: 1,
22
+ _domains: [
23
+ { ref_type: 'other', concept_id: 'x' },
24
+ { ref_type: 'section', concept_id: 'section-103' },
25
+ ],
26
+ })).toEqual(['103']);
27
+ });
28
+
29
+ it('falls through when _domains has no section entries', () => {
30
+ expect(getGroups({
31
+ termid: '103-01-02',
32
+ _domains: [{ ref_type: 'other', concept_id: 'x' }],
33
+ })).toEqual(['103']);
34
+ });
35
+
36
+ it('derives group from termid with NNN- prefix (e.g. IEV)', () => {
37
+ expect(getGroups({ termid: '103-01-02' })).toEqual(['103']);
38
+ });
39
+
40
+ it('derives group from dotted termid (e.g. VIM)', () => {
41
+ expect(getGroups({ termid: '1.2.3.4' })).toEqual(['1.2.3']);
42
+ });
43
+
44
+ it('returns empty array when no derivation matches', () => {
45
+ expect(getGroups({ termid: 'abc' })).toEqual([]);
46
+ });
47
+
48
+ it('returns empty array when termid is missing', () => {
49
+ expect(getGroups({})).toEqual([]);
50
+ });
51
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { assertLocalPathSafe } from '../lib/local-path-safety.mjs';
6
+
7
+ function makeTmpTree() {
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cb-fetch-'));
9
+ const datasetsDir = path.join(root, '.datasets');
10
+ const sourceDir = path.join(root, 'source-data');
11
+ fs.mkdirSync(datasetsDir);
12
+ fs.mkdirSync(path.join(sourceDir, 'concepts'), { recursive: true });
13
+ fs.writeFileSync(path.join(sourceDir, 'concepts', 'a.yaml'), 'termid: 1\n');
14
+ return { root, datasetsDir, sourceDir };
15
+ }
16
+
17
+ describe('assertLocalPathSafe', () => {
18
+ let tree;
19
+
20
+ beforeEach(() => { tree = makeTmpTree(); });
21
+ afterEach(() => {
22
+ fs.rmSync(tree.root, { recursive: true, force: true });
23
+ });
24
+
25
+ it('returns resolved path for a safe external location', () => {
26
+ const resolved = assertLocalPathSafe('foo', tree.sourceDir, {
27
+ root: tree.root,
28
+ datasetsDir: tree.datasetsDir,
29
+ });
30
+ // Returns the realpath (symlinks resolved); on macOS tmpdir resolves
31
+ // /var → /private/var, so compare against realpath, not path.resolve.
32
+ expect(resolved).toBe(fs.realpathSync(path.resolve(tree.root, tree.sourceDir)));
33
+ });
34
+
35
+ it('throws when localPath does not exist', () => {
36
+ expect(() =>
37
+ assertLocalPathSafe('foo', path.join(tree.root, 'nope'), {
38
+ root: tree.root,
39
+ datasetsDir: tree.datasetsDir,
40
+ })
41
+ ).toThrow(/does not exist/);
42
+ });
43
+
44
+ it('throws when localPath equals .datasets/<id>', () => {
45
+ const staged = path.join(tree.datasetsDir, 'foo');
46
+ fs.mkdirSync(staged, { recursive: true });
47
+ expect(() =>
48
+ assertLocalPathSafe('foo', staged, {
49
+ root: tree.root,
50
+ datasetsDir: tree.datasetsDir,
51
+ })
52
+ ).toThrow(/same physical location/);
53
+ });
54
+
55
+ it('throws when localPath is nested inside .datasets/<id>', () => {
56
+ const staged = path.join(tree.datasetsDir, 'foo');
57
+ fs.mkdirSync(path.join(staged, 'subdir'), { recursive: true });
58
+ expect(() =>
59
+ assertLocalPathSafe('foo', path.join(staged, 'subdir'), {
60
+ root: tree.root,
61
+ datasetsDir: tree.datasetsDir,
62
+ })
63
+ ).toThrow(/nested inside/);
64
+ });
65
+
66
+ it('throws when localPath contains .datasets/<id> (parent-of-staging hazard)', () => {
67
+ // localPath = root itself, datasetsDir = root/.datasets — staging ops
68
+ // (rm -rf .datasets/<id>) would touch files inside localPath.
69
+ expect(() =>
70
+ assertLocalPathSafe('foo', tree.root, {
71
+ root: tree.root,
72
+ datasetsDir: tree.datasetsDir,
73
+ })
74
+ ).toThrow(/contains .datasets/);
75
+ });
76
+
77
+ it('throws when localPath is a symlink to .datasets/<id> (the reported bug)', () => {
78
+ const staged = path.join(tree.datasetsDir, 'foo');
79
+ fs.mkdirSync(staged, { recursive: true });
80
+ const symlinkPath = path.join(tree.root, 'evil-link');
81
+ fs.symlinkSync(staged, symlinkPath);
82
+ expect(() =>
83
+ assertLocalPathSafe('foo', symlinkPath, {
84
+ root: tree.root,
85
+ datasetsDir: tree.datasetsDir,
86
+ })
87
+ ).toThrow(/same physical location/);
88
+ });
89
+
90
+ it('does NOT modify the source directory (regression for data-loss bug)', () => {
91
+ const sentinel = path.join(tree.sourceDir, 'concepts', 'SENTINEL.yaml');
92
+ fs.writeFileSync(sentinel, 'termid: sentinel\n');
93
+ const beforeMtime = fs.statSync(sentinel).mtimeMs;
94
+
95
+ assertLocalPathSafe('foo', tree.sourceDir, {
96
+ root: tree.root,
97
+ datasetsDir: tree.datasetsDir,
98
+ });
99
+
100
+ // Source directory must be completely untouched after the safety check.
101
+ expect(fs.existsSync(sentinel)).toBe(true);
102
+ expect(fs.statSync(sentinel).mtimeMs).toBe(beforeMtime);
103
+ expect(fs.readdirSync(path.join(tree.sourceDir, 'concepts'))).toContain('SENTINEL.yaml');
104
+ });
105
+ });
@@ -1,23 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * fetch-datasets.mjs — Load datasets from .gcr files or clone source repos.
3
+ * fetch-datasets.mjs — Load datasets from .gcr files, local paths, or git repos.
4
4
  *
5
5
  * Reads site config (via load-site-config.mjs), for each dataset:
6
6
  * 1. If .gcr/{id}.gcr exists, extract to .datasets/{id}/
7
7
  * 2. Else download from gcrPackage URL and extract
8
- * 3. Else clone/update source repo into .datasets/{id}/
8
+ * 3. Else if localPath is set, use it in-place (NO copy, NO staging)
9
+ * 4. Else clone/update source repo into .datasets/{id}/
9
10
  *
10
11
  * After fetching, validates that all GCR dependencies are satisfiable
11
12
  * (either provided locally or routed externally).
12
13
  *
13
- * Supports localPath field in dataset config for local paths.
14
- * Supports GITHUB_TOKEN for private repos.
14
+ * No shell commands. All file ops use Node fs; ZIP uses JSZip; git uses
15
+ * execFileSync with array args (no shell interpolation).
15
16
  */
16
17
  import fs from 'fs';
17
18
  import path from 'path';
19
+ import JSZip from 'jszip';
18
20
  import { loadGcr } from 'glossarist';
19
- import { execSync } from 'child_process';
21
+ import { execFileSync } from 'child_process';
20
22
  import { loadSiteConfig } from './load-site-config.mjs';
23
+ import { assertLocalPathSafe } from './lib/local-path-safety.mjs';
21
24
 
22
25
  const ROOT = process.cwd();
23
26
  const DATASETS_DIR = path.join(ROOT, '.datasets');
@@ -39,23 +42,29 @@ async function downloadGcr(url, destPath) {
39
42
  console.log(` Saved to ${destPath} (${(buf.length / 1024).toFixed(0)} KB)`);
40
43
  }
41
44
 
42
- // --- GCR extraction ---
43
- function extractGcr(gcrPath, targetDir) {
44
- if (fs.existsSync(targetDir)) {
45
- fs.rmSync(targetDir, { recursive: true, force: true });
45
+ // --- GCR extraction (pure JSZip; no shell, cross-platform) ---
46
+ async function extractGcr(gcrPath, targetDir) {
47
+ const targetAbs = path.resolve(targetDir);
48
+ if (fs.existsSync(targetAbs)) {
49
+ fs.rmSync(targetAbs, { recursive: true, force: true });
46
50
  }
47
- fs.mkdirSync(targetDir, { recursive: true });
48
-
49
- try {
50
- execSync(`unzip -o -q "${gcrPath}" -d "${targetDir}"`, { stdio: 'pipe' });
51
- } catch {
52
- try {
53
- execSync(`python3 -c "import zipfile; zipfile.ZipFile('${gcrPath}').extractall('${targetDir}')"`, { stdio: 'pipe' });
54
- } catch (e2) {
55
- throw new Error(`Failed to extract ${gcrPath}`);
51
+ fs.mkdirSync(targetAbs, { recursive: true });
52
+
53
+ const buf = fs.readFileSync(gcrPath);
54
+ const zip = await JSZip.loadAsync(buf);
55
+ const entries = Object.values(zip.files);
56
+ for (const entry of entries) {
57
+ if (entry.dir) continue;
58
+ // zip-slip guard: refuse entries that escape targetDir
59
+ const dest = path.resolve(targetAbs, entry.name);
60
+ if (dest !== targetAbs && !dest.startsWith(targetAbs + path.sep)) {
61
+ throw new Error(`Refusing to extract entry outside target dir: ${entry.name}`);
56
62
  }
63
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
64
+ const content = await entry.async('nodebuffer');
65
+ fs.writeFileSync(dest, content);
57
66
  }
58
- console.log(` Extracted to ${targetDir}`);
67
+ console.log(` Extracted to ${targetAbs}`);
59
68
  }
60
69
 
61
70
  // --- Read GCR metadata from ZIP without extraction ---
@@ -96,7 +105,7 @@ function validateDependencies(config, gcrMetadata) {
96
105
  return errors;
97
106
  }
98
107
 
99
- // --- Git operations ---
108
+ // --- Git operations (execFileSync with array args — no shell) ---
100
109
  function cloneOrUpdate(sourceRepo, targetDir) {
101
110
  const env = { ...process.env };
102
111
  let repoUrl = sourceRepo;
@@ -104,24 +113,28 @@ function cloneOrUpdate(sourceRepo, targetDir) {
104
113
  repoUrl = sourceRepo.replace('https://', `https://x-access-token:${env.GITHUB_TOKEN}@`);
105
114
  }
106
115
 
107
- if (fs.existsSync(path.join(targetDir, '.git'))) {
116
+ const targetAbs = path.resolve(targetDir);
117
+
118
+ if (fs.existsSync(path.join(targetAbs, '.git'))) {
108
119
  console.log(` Updating existing clone...`);
109
120
  try {
110
- execSync('git fetch origin', { cwd: targetDir, stdio: 'pipe', env });
111
- execSync('git reset --hard origin/HEAD', { cwd: targetDir, stdio: 'pipe', env });
112
- execSync('git clean -fd', { cwd: targetDir, stdio: 'pipe', env });
121
+ execFileSync('git', ['fetch', 'origin'], { cwd: targetAbs, stdio: 'pipe', env });
122
+ execFileSync('git', ['reset', '--hard', 'origin/HEAD'], { cwd: targetAbs, stdio: 'pipe', env });
123
+ execFileSync('git', ['clean', '-fd'], { cwd: targetAbs, stdio: 'pipe', env });
113
124
  } catch {
114
125
  console.warn(` git update failed, re-cloning`);
115
- fs.rmSync(targetDir, { recursive: true, force: true });
116
- execSync(`git clone --depth 1 "${repoUrl}" "${targetDir}"`, { stdio: 'pipe', env });
126
+ fs.rmSync(targetAbs, { recursive: true, force: true });
127
+ execFileSync('git', ['clone', '--depth', '1', repoUrl, targetAbs], { stdio: 'pipe', env });
117
128
  }
118
129
  } else {
119
- fs.mkdirSync(targetDir, { recursive: true });
130
+ fs.mkdirSync(targetAbs, { recursive: true });
120
131
  console.log(` Cloning ${sourceRepo}...`);
121
- execSync(`git clone --depth 1 "${repoUrl}" "${targetDir}"`, { stdio: 'pipe', env });
132
+ execFileSync('git', ['clone', '--depth', '1', repoUrl, targetAbs], { stdio: 'pipe', env });
122
133
  }
123
134
  }
124
135
 
136
+ // --- localPath safety check: see scripts/lib/local-path-safety.mjs ---
137
+
125
138
  // --- Main ---
126
139
  console.log('Fetching glossarist datasets...\n');
127
140
 
@@ -137,7 +150,7 @@ for (const ds of config.datasets) {
137
150
  try {
138
151
  if (fs.existsSync(gcrPath)) {
139
152
  console.log(` Using local .gcr/${ds.id}.gcr`);
140
- extractGcr(gcrPath, targetDir);
153
+ await extractGcr(gcrPath, targetDir);
141
154
  } else if (ds.gcrPackage) {
142
155
  console.log(` Using GCR package: ${ds.gcrPackage}`);
143
156
  try {
@@ -148,29 +161,18 @@ for (const ds of config.datasets) {
148
161
  console.log();
149
162
  continue;
150
163
  }
151
- extractGcr(gcrPath, targetDir);
164
+ await extractGcr(gcrPath, targetDir);
165
+ } else if (ds.localPath) {
166
+ // localPath means "data is here, use in-place." No copy, no staging.
167
+ // generate-data.mjs reads from localPath directly via datasetDir(ds).
168
+ const localResolved = assertLocalPathSafe(ds.id, ds.localPath);
169
+ console.log(` Using localPath in-place: ${localResolved}`);
170
+ } else if (ds.sourceRepo) {
171
+ cloneOrUpdate(ds.sourceRepo, targetDir);
152
172
  } else {
153
- const envOverride = ds.localPath;
154
- if (envOverride) {
155
- console.log(` Using local path: ${envOverride}`);
156
- if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
157
- const localConcepts = path.join(envOverride, 'concepts');
158
- const targetConcepts = path.join(targetDir, 'concepts');
159
- if (fs.existsSync(localConcepts)) {
160
- if (fs.existsSync(targetConcepts)) fs.rmSync(targetConcepts, { recursive: true, force: true });
161
- execSync(`cp -r "${localConcepts}" "${targetConcepts}"`, { stdio: 'pipe' });
162
- }
163
- const registerYaml = path.join(envOverride, 'register.yaml');
164
- if (fs.existsSync(registerYaml)) {
165
- fs.copyFileSync(registerYaml, path.join(targetDir, 'register.yaml'));
166
- }
167
- } else if (ds.sourceRepo) {
168
- cloneOrUpdate(ds.sourceRepo, targetDir);
169
- } else {
170
- console.warn(` No source configured, skipping`);
171
- console.log();
172
- continue;
173
- }
173
+ console.warn(` No source configured, skipping`);
174
+ console.log();
175
+ continue;
174
176
  }
175
177
 
176
178
  // Read metadata for dependency validation (from GCR ZIP, not extracted dir)
@@ -3,12 +3,24 @@ import path from 'path';
3
3
  import yaml from 'js-yaml';
4
4
  import { naturalSort, Register, parseMention } from 'glossarist';
5
5
  import { loadSiteConfig } from './load-site-config.mjs';
6
-
6
+ import { getGroups } from './lib/concept-groups.mjs';
7
7
  const __dirname = path.dirname(new URL(import.meta.url).pathname);
8
8
  const ROOT = process.cwd();
9
9
  const PUBLIC = path.join(ROOT, 'public');
10
10
  const DATA = path.join(PUBLIC, 'data');
11
11
 
12
+ /**
13
+ * Resolve a dataset's source directory.
14
+ * - If `ds.localPath` is set, use it in-place (resolved against ROOT).
15
+ * No staging, no copy. fetch-datasets.mjs verifies the path is safe.
16
+ * - Otherwise fall back to the standard .datasets/<id>/ staging dir.
17
+ */
18
+ function datasetDir(ds) {
19
+ return ds.localPath
20
+ ? path.resolve(ROOT, ds.localPath)
21
+ : path.join(ROOT, '.datasets', ds.id);
22
+ }
23
+
12
24
  const DS_PALETTE = [
13
25
  '#3366ff', '#0d9488', '#d97706', '#8b5cf6',
14
26
  '#ec4899', '#059669', '#dc2626', '#6366f1',
@@ -574,24 +586,6 @@ function getPrimaryDesignation(conceptYaml) {
574
586
  return descs;
575
587
  }
576
588
 
577
- function getGroups(conceptYaml) {
578
- if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
579
- // Derive groups from domains (e.g. section-based grouping in G18)
580
- if (conceptYaml._domains) {
581
- const sectionIds = conceptYaml._domains
582
- .filter(d => d.ref_type === 'section' && d.concept_id)
583
- .map(d => d.concept_id.replace(/^section-/, ''));
584
- if (sectionIds.length) return sectionIds;
585
- }
586
- const termid = String(conceptYaml.termid);
587
- if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
588
- if (/^\d+\.\d+\.\d+/.test(termid)) {
589
- const parts = termid.split('.');
590
- return [`${parts[0]}.${parts[1]}.${parts[2]}`];
591
- }
592
- return [];
593
- }
594
-
595
589
  function escapeTurtle(s) {
596
590
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
597
591
  }
@@ -952,7 +946,8 @@ function processDataset(dir, register, opts) {
952
946
  }
953
947
 
954
948
  // Copy bulk format files from compiled/ directory (full GCR)
955
- const compiledDir = path.join(ROOT, '.datasets', register, 'compiled');
949
+ const sourceRoot = path.dirname(dir);
950
+ const compiledDir = path.join(sourceRoot, 'compiled');
956
951
  const bulkFormats = [];
957
952
  if (fs.existsSync(compiledDir)) {
958
953
  for (const file of fs.readdirSync(compiledDir)) {
@@ -1011,7 +1006,7 @@ function processDataset(dir, register, opts) {
1011
1006
  writeJson(path.join(DATA, register, 'manifest.json'), manifest);
1012
1007
 
1013
1008
  // Copy bibliography.yaml → bibliography.json
1014
- const bibPath = path.join(ROOT, '.datasets', register, 'bibliography.yaml');
1009
+ const bibPath = path.join(sourceRoot, 'bibliography.yaml');
1015
1010
  if (fs.existsSync(bibPath)) {
1016
1011
  const bibData = readYaml(bibPath);
1017
1012
  writeJson(path.join(DATA, register, 'bibliography.json'), bibData);
@@ -1019,7 +1014,7 @@ function processDataset(dir, register, opts) {
1019
1014
  }
1020
1015
 
1021
1016
  // Copy images/
1022
- const imagesSrcDir = path.join(ROOT, '.datasets', register, 'images');
1017
+ const imagesSrcDir = path.join(sourceRoot, 'images');
1023
1018
  if (fs.existsSync(imagesSrcDir) && fs.statSync(imagesSrcDir).isDirectory()) {
1024
1019
  const imagesDestDir = path.join(DATA, register, 'images');
1025
1020
  fs.mkdirSync(imagesDestDir, { recursive: true });
@@ -1048,8 +1043,8 @@ const registerCache = {};
1048
1043
 
1049
1044
  // Pre-load all register.yaml files (needed before buildRefMaps for URI pattern indexing)
1050
1045
  for (const ds of config.datasets) {
1051
- const registerDir = path.join(ROOT, '.datasets', ds.id);
1052
- const registerYamlPath = path.join(registerDir, 'register.yaml');
1046
+ const dsDir = datasetDir(ds);
1047
+ const registerYamlPath = path.join(dsDir, 'register.yaml');
1053
1048
  if (fs.existsSync(registerYamlPath)) {
1054
1049
  try {
1055
1050
  const raw = yaml.load(fs.readFileSync(registerYamlPath, 'utf8'));
@@ -1065,7 +1060,7 @@ const refMaps = buildRefMaps(config, registerCache);
1065
1060
  for (let i = 0; i < config.datasets.length; i++) {
1066
1061
  const ds = config.datasets[i];
1067
1062
 
1068
- const dir = path.join(ROOT, '.datasets', ds.id, 'concepts');
1063
+ const dir = path.join(datasetDir(ds), 'concepts');
1069
1064
  if (!fs.existsSync(dir)) {
1070
1065
  console.warn(`Skipping ${ds.id}: source directory not found (${dir})`);
1071
1066
  console.warn(` Run: npm run fetch-datasets`);
@@ -1107,8 +1102,8 @@ for (let i = 0; i < config.datasets.length; i++) {
1107
1102
  status: ds.editionStatus || reg?.status,
1108
1103
  ordering: reg?.ordering || null,
1109
1104
  sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
1110
- hasBibliography: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'bibliography.yaml')),
1111
- hasImages: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'images')),
1105
+ hasBibliography: fs.existsSync(path.join(datasetDir(ds), 'bibliography.yaml')),
1106
+ hasImages: fs.existsSync(path.join(datasetDir(ds), 'images')),
1112
1107
  });
1113
1108
  registry.push({
1114
1109
  id: ds.id,
@@ -0,0 +1,16 @@
1
+ export function getGroups(conceptYaml) {
2
+ if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
3
+ if (conceptYaml._domains) {
4
+ const sectionIds = conceptYaml._domains
5
+ .filter(d => d.ref_type === 'section' && d.concept_id)
6
+ .map(d => d.concept_id.replace(/^section-/, ''));
7
+ if (sectionIds.length) return sectionIds;
8
+ }
9
+ const termid = String(conceptYaml.termid);
10
+ if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
11
+ if (/^\d+\.\d+\.\d+/.test(termid)) {
12
+ const parts = termid.split('.');
13
+ return [`${parts[0]}.${parts[1]}.${parts[2]}`];
14
+ }
15
+ return [];
16
+ }
@@ -0,0 +1,68 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Assert that a dataset's `localPath` is safe to use as an in-place source.
6
+ *
7
+ * "Safe" means: the resolved physical location of `localPath` is disjoint
8
+ * from the staging directory (`.datasets/<id>`). If they overlap, staging
9
+ * operations (rm, extract, clone) would destroy the user's source data —
10
+ * the data-loss bug reported in v0.7.43.
11
+ *
12
+ * Returns the resolved absolute path on success; throws on any hazard.
13
+ *
14
+ * @param {string} datasetId
15
+ * @param {string} localPath - relative to `root` (or absolute)
16
+ * @param {{ root?: string, datasetsDir?: string }} [opts]
17
+ * @returns {string} resolved absolute path
18
+ */
19
+ /**
20
+ * Compute the canonical physical path of `p`, resolving symlinks on the
21
+ * existing prefix. If `p` itself exists, this is just `realpathSync(p)`.
22
+ * If not, we walk up to the nearest existing ancestor, realpath it, and
23
+ * re-append the non-existent tail. This is needed because macOS tmpdir
24
+ * (`/var/folders/...`) is a symlink to `/private/var/folders/...`; without
25
+ * this, prefix comparisons across the symlink boundary silently fail.
26
+ */
27
+ function physicalPath(p) {
28
+ if (fs.existsSync(p)) return fs.realpathSync(p);
29
+ const parent = path.dirname(p);
30
+ const parentReal = fs.existsSync(parent) ? fs.realpathSync(parent) : physicalPath(parent);
31
+ return path.join(parentReal, path.basename(p));
32
+ }
33
+
34
+ export function assertLocalPathSafe(datasetId, localPath, { root = process.cwd(), datasetsDir } = {}) {
35
+ const datasetsRoot = datasetsDir || path.join(root, '.datasets');
36
+ const localResolved = path.resolve(root, localPath);
37
+
38
+ if (!fs.existsSync(localResolved)) {
39
+ throw new Error(`localPath for ${datasetId} does not exist: ${localResolved}`);
40
+ }
41
+
42
+ const localReal = fs.realpathSync(localResolved);
43
+ const stagedAbs = path.join(datasetsRoot, datasetId);
44
+ const stagedReal = physicalPath(stagedAbs);
45
+
46
+ if (localReal === stagedReal) {
47
+ throw new Error(
48
+ `localPath for ${datasetId} resolves to the same physical location as .datasets/${datasetId} ` +
49
+ `(${localReal}). Refusing to operate — source and staging would clobber. ` +
50
+ `Use a path outside .datasets/.`
51
+ );
52
+ }
53
+ if (localReal.startsWith(stagedReal + path.sep)) {
54
+ throw new Error(
55
+ `localPath for ${datasetId} is nested inside .datasets/${datasetId}. ` +
56
+ `Refusing to operate — staging operations would destroy source data. ` +
57
+ `Use a path outside .datasets/.`
58
+ );
59
+ }
60
+ if (stagedReal.startsWith(localReal + path.sep)) {
61
+ throw new Error(
62
+ `localPath for ${datasetId} contains .datasets/${datasetId}. ` +
63
+ `Refusing to operate — staging operations would destroy source data. ` +
64
+ `Use a path outside localPath.`
65
+ );
66
+ }
67
+ return localReal;
68
+ }
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia } from 'pinia';
4
4
  import { createRouter, createMemoryHistory } from 'vue-router';
5
5
  import DatasetView from '../views/DatasetView.vue';
6
6
  import { useVocabularyStore } from '../stores/vocabulary';
7
- import type { Manifest, ConceptSummary } from '../adapters/types';
7
+ import type { Manifest, ConceptSummary, SectionNode } from '../adapters/types';
8
8
 
9
9
  function makeManifest(overrides: Partial<Manifest> = {}): Manifest {
10
10
  return {
@@ -230,4 +230,98 @@ describe('DatasetView', () => {
230
230
  expect(prevBtn).toBeDefined();
231
231
  expect(prevBtn!.attributes('disabled')).toBeDefined();
232
232
  });
233
+
234
+ describe('hierarchical section filter', () => {
235
+ const HIERARCHICAL_TREE: SectionNode[] = [
236
+ {
237
+ id: '102',
238
+ names: { eng: 'Mathematics' },
239
+ conceptCount: 0,
240
+ children: [
241
+ { id: '102-01', names: { eng: 'Sets' }, conceptCount: 2 },
242
+ {
243
+ id: '102-02',
244
+ names: { eng: 'Numbers' },
245
+ conceptCount: 2,
246
+ children: [
247
+ { id: '102-02-01', names: { eng: 'Reals' }, conceptCount: 1 },
248
+ ],
249
+ },
250
+ ],
251
+ },
252
+ { id: '103', names: { eng: 'Functions' }, conceptCount: 1 },
253
+ ];
254
+
255
+ function makeHierarchicalAdapter(concepts: ConceptSummary[], sections: SectionNode[]) {
256
+ const dense = concepts.filter(Boolean);
257
+ return {
258
+ registerId: 'test',
259
+ index: dense,
260
+ manifest: null,
261
+ getConceptCount: () => dense.length,
262
+ getConcepts: () => dense,
263
+ isRangeLoaded: () => true,
264
+ ensureChunksForRange: async () => {},
265
+ ensureAllChunksLoaded: async () => {},
266
+ getAdjacentConcepts: () => ({ prev: null, next: null }),
267
+ getSectionTree: () => sections,
268
+ } as any;
269
+ }
270
+
271
+ function mountHierarchical(concepts: ConceptSummary[], sectionId: string) {
272
+ const store = useVocabularyStore();
273
+ store.manifests.set('test', makeManifest());
274
+ store.datasets.set('test', makeHierarchicalAdapter(concepts, HIERARCHICAL_TREE));
275
+ return mount(DatasetView, {
276
+ global: { plugins: [pinia, router] },
277
+ props: { registerId: 'test' },
278
+ });
279
+ }
280
+
281
+ it('matches concepts in a child section when filtering by parent (closure)', async () => {
282
+ const concepts: ConceptSummary[] = [
283
+ { id: 'a', designations: { eng: 'set A' }, eng: 'set A', status: 'valid', groups: ['102-01'] },
284
+ { id: 'b', designations: { eng: 'unrelated' }, eng: 'unrelated', status: 'valid', groups: ['999'] },
285
+ ];
286
+ const wrapper = mountHierarchical(concepts, 'section-102');
287
+ await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
288
+ await flushPromises();
289
+ expect(wrapper.text()).toContain('set A');
290
+ expect(wrapper.text()).not.toContain('unrelated');
291
+ });
292
+
293
+ it('matches concepts at arbitrary depth (grandparent closure)', async () => {
294
+ const concepts: ConceptSummary[] = [
295
+ { id: 'deep', designations: { eng: 'real number' }, eng: 'real number', status: 'valid', groups: ['102-02-01'] },
296
+ { id: 'other', designations: { eng: 'other concept' }, eng: 'other concept', status: 'valid', groups: ['103'] },
297
+ ];
298
+ const wrapper = mountHierarchical(concepts, 'section-102');
299
+ await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
300
+ await flushPromises();
301
+ expect(wrapper.text()).toContain('real number');
302
+ expect(wrapper.text()).not.toContain('other concept');
303
+ });
304
+
305
+ it('still matches by concept ID prefix when no groups are set', async () => {
306
+ const concepts: ConceptSummary[] = [
307
+ { id: '102.3.4', designations: { eng: 'numbered term' }, eng: 'numbered term', status: 'valid' },
308
+ { id: '999.1', designations: { eng: 'different section' }, eng: 'different section', status: 'valid' },
309
+ ];
310
+ const wrapper = mountHierarchical(concepts, 'section-102');
311
+ await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
312
+ await flushPromises();
313
+ expect(wrapper.text()).toContain('numbered term');
314
+ expect(wrapper.text()).not.toContain('different section');
315
+ });
316
+
317
+ it('renders the section display name when filter is active', async () => {
318
+ const concepts: ConceptSummary[] = [
319
+ { id: 'a', designations: { eng: 'set A' }, eng: 'set A', status: 'valid', groups: ['102-01'] },
320
+ ];
321
+ const wrapper = mountHierarchical(concepts, 'section-102');
322
+ await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
323
+ await flushPromises();
324
+ expect(wrapper.text()).toContain('102 — Mathematics');
325
+ });
326
+ });
233
327
  });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sectionName, formatSectionLabel } from '../utils/section-display';
3
+
4
+ describe('sectionName', () => {
5
+ it('prefers the requested language', () => {
6
+ expect(sectionName({ id: 'x', names: { eng: 'X', fra: 'X (fra)' } }, 'fra')).toBe('X (fra)');
7
+ });
8
+
9
+ it('falls back to English when requested language missing', () => {
10
+ expect(sectionName({ id: 'x', names: { eng: 'X' } }, 'deu')).toBe('X');
11
+ });
12
+
13
+ it('falls back to id when no names at all', () => {
14
+ expect(sectionName({ id: 'x', names: {} }, 'fra')).toBe('x');
15
+ });
16
+
17
+ it('falls back to id when names object missing', () => {
18
+ expect(sectionName({ id: 'x' }, 'fra')).toBe('x');
19
+ });
20
+ });
21
+
22
+ describe('formatSectionLabel', () => {
23
+ it('returns just id when name is empty', () => {
24
+ expect(formatSectionLabel({ id: '102', names: {} }, 'eng')).toBe('102');
25
+ });
26
+
27
+ it('returns just id when name equals id', () => {
28
+ expect(formatSectionLabel({ id: '102', names: { eng: '102' } }, 'eng')).toBe('102');
29
+ });
30
+
31
+ it('returns just name when name equals id-with-spaces (EXPRESS schema convention)', () => {
32
+ expect(formatSectionLabel({ id: 'action_schema', names: { eng: 'action schema' } }, 'eng')).toBe('action schema');
33
+ });
34
+
35
+ it('returns "id — name" when name is meaningfully different', () => {
36
+ expect(formatSectionLabel({ id: '102', names: { eng: 'Mathematics' } }, 'eng')).toBe('102 — Mathematics');
37
+ });
38
+
39
+ it('localizes the name lookup', () => {
40
+ expect(formatSectionLabel({ id: '102', names: { eng: 'Math', fra: 'Maths' } }, 'fra')).toBe('102 — Maths');
41
+ });
42
+
43
+ it('falls back through English when requested language has no name', () => {
44
+ expect(formatSectionLabel({ id: '102', names: { eng: 'Math' } }, 'fra')).toBe('102 — Math');
45
+ });
46
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { SectionNode } from '../adapters/types';
3
+ import {
4
+ findSectionNode,
5
+ collectDescendantSectionIds,
6
+ toSectionNode,
7
+ toSectionTree,
8
+ } from '../utils/section-tree';
9
+
10
+ const TREE: SectionNode[] = [
11
+ {
12
+ id: '102',
13
+ names: { eng: 'Mathematics' },
14
+ conceptCount: 0,
15
+ children: [
16
+ { id: '102-01', names: { eng: 'Sets' }, conceptCount: 5 },
17
+ {
18
+ id: '102-02',
19
+ names: { eng: 'Numbers' },
20
+ conceptCount: 3,
21
+ children: [
22
+ { id: '102-02-01', names: { eng: 'Reals' }, conceptCount: 1 },
23
+ ],
24
+ },
25
+ ],
26
+ },
27
+ { id: '103', names: { eng: 'Functions' }, conceptCount: 0 },
28
+ ];
29
+
30
+ describe('findSectionNode', () => {
31
+ it('finds a root by id', () => {
32
+ expect(findSectionNode(TREE, '103')?.id).toBe('103');
33
+ });
34
+
35
+ it('finds a descendant by id (recursive)', () => {
36
+ expect(findSectionNode(TREE, '102-02-01')?.id).toBe('102-02-01');
37
+ });
38
+
39
+ it('returns null for unknown id', () => {
40
+ expect(findSectionNode(TREE, '999')).toBeNull();
41
+ });
42
+
43
+ it('returns null for empty tree', () => {
44
+ expect(findSectionNode([], '102')).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe('collectDescendantSectionIds', () => {
49
+ it('includes root and all descendants at arbitrary depth', () => {
50
+ const ids = collectDescendantSectionIds(TREE, '102');
51
+ expect([...ids].sort()).toEqual(['102', '102-01', '102-02', '102-02-01']);
52
+ });
53
+
54
+ it('returns single-element set for a leaf', () => {
55
+ expect([...collectDescendantSectionIds(TREE, '103')]).toEqual(['103']);
56
+ });
57
+
58
+ it('returns empty set for unknown root', () => {
59
+ expect(collectDescendantSectionIds(TREE, '999').size).toBe(0);
60
+ });
61
+ });
62
+
63
+ describe('toSectionNode', () => {
64
+ it('maps minimal input with id only', () => {
65
+ expect(toSectionNode({ id: 'x' })).toEqual({
66
+ id: 'x',
67
+ names: {},
68
+ conceptCount: 0,
69
+ });
70
+ });
71
+
72
+ it('preserves names and conceptCount', () => {
73
+ expect(toSectionNode({ id: 'x', names: { eng: 'X' }, conceptCount: 7 })).toEqual({
74
+ id: 'x',
75
+ names: { eng: 'X' },
76
+ conceptCount: 7,
77
+ });
78
+ });
79
+
80
+ it('defaults missing id to empty string', () => {
81
+ expect(toSectionNode({}).id).toBe('');
82
+ });
83
+
84
+ it('defaults missing conceptCount to 0', () => {
85
+ expect(toSectionNode({ id: 'x' }).conceptCount).toBe(0);
86
+ });
87
+
88
+ it('defaults missing names to empty object', () => {
89
+ expect(toSectionNode({ id: 'x' }).names).toEqual({});
90
+ });
91
+
92
+ it('recursively maps children', () => {
93
+ const node = toSectionNode({
94
+ id: 'p',
95
+ names: { eng: 'Parent' },
96
+ children: [{ id: 'c', names: { eng: 'Child' } }],
97
+ });
98
+ expect(node.children?.[0]).toEqual({
99
+ id: 'c',
100
+ names: { eng: 'Child' },
101
+ conceptCount: 0,
102
+ });
103
+ });
104
+
105
+ it('omits children array when source has none', () => {
106
+ expect(toSectionNode({ id: 'x' }).children).toBeUndefined();
107
+ });
108
+
109
+ it('omits children array when source children is empty', () => {
110
+ expect(toSectionNode({ id: 'x', children: [] }).children).toBeUndefined();
111
+ });
112
+ });
113
+
114
+ describe('toSectionTree', () => {
115
+ it('maps a list of roots', () => {
116
+ const tree = toSectionTree([
117
+ { id: 'a', names: { eng: 'A' } },
118
+ { id: 'b' },
119
+ ]);
120
+ expect(tree).toHaveLength(2);
121
+ expect(tree[0].id).toBe('a');
122
+ expect(tree[1].names).toEqual({});
123
+ });
124
+
125
+ it('returns empty array for empty input', () => {
126
+ expect(toSectionTree([])).toEqual([]);
127
+ });
128
+ });
@@ -3,6 +3,7 @@ import type { Concept, RelatedConcept } from 'glossarist';
3
3
  import type { DatasetAdapter } from './DatasetAdapter';
4
4
  import { UriRouter } from './UriRouter';
5
5
  import { slugify } from '../utils/slugify';
6
+ import { toSectionNode, toSectionTree } from '../utils/section-tree';
6
7
 
7
8
  interface DomainNodeJson {
8
9
  uri?: string;
@@ -14,12 +15,6 @@ interface DomainNodeJson {
14
15
  children?: DomainNodeJson[];
15
16
  }
16
17
 
17
- interface SectionJson {
18
- id: string;
19
- names?: Record<string, string>;
20
- children?: SectionJson[];
21
- }
22
-
23
18
  function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
24
19
  if (!rc.ref) return '';
25
20
  const ref = rc.ref;
@@ -136,7 +131,7 @@ export class GraphDataSource {
136
131
  getSectionTree(): SectionNode[] {
137
132
  const nodes = this.adapter.manifest?.sections;
138
133
  if (!nodes || nodes.length === 0) return [];
139
- return nodes.map(s => this.mapManifestSection(s));
134
+ return toSectionTree(nodes);
140
135
  }
141
136
 
142
137
  private mapDomainNode(dn: DomainNodeJson): GraphNode {
@@ -151,28 +146,17 @@ export class GraphDataSource {
151
146
  conceptCount: dn.conceptCount || 0,
152
147
  };
153
148
  if (dn.children && dn.children.length > 0) {
154
- node.children = dn.children.map((c) => this.mapSectionNode(c));
149
+ node.children = dn.children.map(c => this.domainNodeToSection(c));
155
150
  }
156
151
  return node;
157
152
  }
158
153
 
159
- private mapSectionNode(dn: DomainNodeJson): SectionNode {
160
- const node: SectionNode = {
161
- id: dn.id ?? '',
154
+ private domainNodeToSection(dn: DomainNodeJson): SectionNode {
155
+ return toSectionNode({
156
+ id: dn.id,
162
157
  names: dn.names || (dn.label ? { eng: dn.label } : {}),
163
- conceptCount: dn.conceptCount || 0,
164
- };
165
- if (dn.children && dn.children.length > 0) {
166
- node.children = dn.children.map((c) => this.mapSectionNode(c));
167
- }
168
- return node;
169
- }
170
-
171
- private mapManifestSection(s: SectionJson): SectionNode {
172
- const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
173
- if (s.children && s.children.length > 0) {
174
- node.children = s.children.map(c => this.mapManifestSection(c));
175
- }
176
- return node;
158
+ conceptCount: dn.conceptCount,
159
+ children: dn.children,
160
+ });
177
161
  }
178
162
  }
@@ -8,6 +8,8 @@ import { useSiteConfig } from '../config/use-site-config';
8
8
  import NavIcon from './NavIcon.vue';
9
9
  import { useI18n, locale } from '../i18n';
10
10
  import type { SectionNode } from '../adapters/types';
11
+ import { toSectionTree } from '../utils/section-tree';
12
+ import { formatSectionLabel, sectionName as sectionLocalized } from '../utils/section-display';
11
13
 
12
14
  const OntologySidebarSection = defineAsyncComponent(() => import('./OntologySidebarSection.vue'));
13
15
 
@@ -174,26 +176,15 @@ function toggleSectionNode(id: string) {
174
176
  function getDatasetSections(dsId: string): SectionNode[] {
175
177
  const m = store.manifests.get(dsId);
176
178
  if (!m?.sections?.length) return [];
177
- return m.sections.map(s => enrichSectionNode(s));
178
- }
179
-
180
- function enrichSectionNode(s: { id: string; names: Record<string, string>; children?: any[] }): SectionNode {
181
- const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
182
- if (s.children && s.children.length > 0) {
183
- node.children = s.children.map(c => enrichSectionNode(c));
184
- }
185
- return node;
179
+ return toSectionTree(m.sections);
186
180
  }
187
181
 
188
182
  function sectionLabel(section: SectionNode): string {
189
- const name = section.names[locale.value] || section.names.eng || '';
190
- return name || section.id;
183
+ return sectionLocalized(section, locale.value);
191
184
  }
192
185
 
193
186
  function sectionDisplay(section: SectionNode): string {
194
- const name = sectionLabel(section);
195
- if (name && name !== section.id) return `${section.id} — ${name}`;
196
- return name || section.id;
187
+ return formatSectionLabel(section, locale.value);
197
188
  }
198
189
 
199
190
  function goToSection(dsId: string, sectionId: string) {
@@ -0,0 +1,21 @@
1
+ import type { SectionNode } from '../adapters/types';
2
+
3
+ interface SectionLike {
4
+ id: string;
5
+ names?: Record<string, string>;
6
+ }
7
+
8
+ export function sectionName(section: SectionLike, lang: string): string {
9
+ const names = section.names || {};
10
+ return names[lang] || names.eng || section.id;
11
+ }
12
+
13
+ export function formatSectionLabel(section: SectionLike, lang: string): string {
14
+ const names = section.names || {};
15
+ const name = names[lang] || names.eng || '';
16
+ const bare = section.id;
17
+ if (!name) return bare;
18
+ if (name === bare) return name;
19
+ if (name === bare.replace(/_/g, ' ')) return name;
20
+ return `${bare} — ${name}`;
21
+ }
@@ -0,0 +1,53 @@
1
+ import type { SectionNode } from '../adapters/types';
2
+
3
+ interface SectionLike {
4
+ id?: string;
5
+ names?: Record<string, string>;
6
+ conceptCount?: number;
7
+ children?: SectionLike[];
8
+ }
9
+
10
+ export function findSectionNode(
11
+ tree: readonly SectionNode[],
12
+ id: string,
13
+ ): SectionNode | null {
14
+ for (const node of tree) {
15
+ if (node.id === id) return node;
16
+ if (node.children) {
17
+ const found = findSectionNode(node.children, id);
18
+ if (found) return found;
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function collectDescendantSectionIds(
25
+ tree: readonly SectionNode[],
26
+ rootId: string,
27
+ ): Set<string> {
28
+ const root = findSectionNode(tree, rootId);
29
+ if (!root) return new Set();
30
+ const ids = new Set<string>();
31
+ const walk = (n: SectionNode) => {
32
+ ids.add(n.id);
33
+ n.children?.forEach(walk);
34
+ };
35
+ walk(root);
36
+ return ids;
37
+ }
38
+
39
+ export function toSectionNode(s: SectionLike): SectionNode {
40
+ const node: SectionNode = {
41
+ id: s.id ?? '',
42
+ names: s.names || {},
43
+ conceptCount: s.conceptCount ?? 0,
44
+ };
45
+ if (s.children && s.children.length > 0) {
46
+ node.children = s.children.map(toSectionNode);
47
+ }
48
+ return node;
49
+ }
50
+
51
+ export function toSectionTree(items: readonly SectionLike[]): SectionNode[] {
52
+ return items.map(toSectionNode);
53
+ }
@@ -9,7 +9,9 @@ import { langName, langLabel, sortLanguages } from '../utils/lang';
9
9
  import ConceptCard from '../components/ConceptCard.vue';
10
10
  import { useI18n, locale } from '../i18n';
11
11
  import { useSiteConfig } from '../config/use-site-config';
12
- import type { SectionNode } from '../adapters/types';
12
+ import type { SectionNode, ConceptSummary } from '../adapters/types';
13
+ import { collectDescendantSectionIds, findSectionNode } from '../utils/section-tree';
14
+ import { formatSectionLabel } from '../utils/section-display';
13
15
 
14
16
  const props = defineProps<{ registerId: string }>();
15
17
 
@@ -167,20 +169,34 @@ const filtered = computed(() => {
167
169
  const q = filter.value.trim().toLowerCase();
168
170
  const lang = selectedLang.value;
169
171
  const sec = sectionQuery.value;
172
+ const closure = sectionClosure.value;
170
173
  return loadedConcepts.value.filter(c => {
171
174
  if (lang && !(lang in (c.designations ?? {}))) return false;
172
- if (sec && !conceptMatchesSection(c, sec)) return false;
175
+ if (sec && !conceptMatchesSection(c, sec.replace(/^section-/, ''), closure)) return false;
173
176
  if (!q) return true;
174
177
  return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
175
178
  });
176
179
  });
177
180
 
178
- function conceptMatchesSection(concept: import('../adapters/types').ConceptSummary, sectionPrefix: string): boolean {
179
- const prefix = sectionPrefix.replace(/^section-/, '');
180
- // Check explicit groups (e.g. G18 sections derived from domains)
181
- if (concept.groups?.length && concept.groups.includes(prefix)) return true;
182
- // Check concept ID prefix matching (e.g. VIML/VIM numbered sections)
183
- return concept.id.startsWith(prefix + '.') || concept.id.startsWith(prefix + '-');
181
+ const sectionClosure = computed<Set<string> | null>(() => {
182
+ const q = sectionQuery.value;
183
+ if (!q) return null;
184
+ const prefix = q.replace(/^section-/, '');
185
+ const tree = getSections();
186
+ const closure = collectDescendantSectionIds(tree, prefix);
187
+ return closure.size > 0 ? closure : null;
188
+ });
189
+
190
+ function conceptMatchesSection(concept: ConceptSummary, sectionId: string, closure: Set<string> | null): boolean {
191
+ if (closure) {
192
+ if (concept.groups?.some(g => closure.has(g))) return true;
193
+ if (closure.has(sectionId)) {
194
+ return concept.id.startsWith(sectionId + '.') || concept.id.startsWith(sectionId + '-');
195
+ }
196
+ return false;
197
+ }
198
+ if (concept.groups?.length && concept.groups.includes(sectionId)) return true;
199
+ return concept.id.startsWith(sectionId + '.') || concept.id.startsWith(sectionId + '-');
184
200
  }
185
201
 
186
202
  function getSections(): SectionNode[] {
@@ -188,18 +204,12 @@ function getSections(): SectionNode[] {
188
204
  return adapter.value.getSectionTree();
189
205
  }
190
206
 
191
- function sectionName(section: SectionNode): string {
192
- return section.names[locale.value] || section.names.eng || section.id;
193
- }
194
-
195
207
  const sectionDisplayName = computed(() => {
196
208
  if (!sectionQuery.value) return '';
197
209
  const prefix = sectionQuery.value.replace(/^section-/, '');
198
- const sections = getSections();
199
- const found = sections.find(s => s.id === prefix);
210
+ const found = findSectionNode(getSections(), prefix);
200
211
  if (!found) return prefix;
201
- const name = sectionName(found);
202
- return name !== found.id ? `${found.id} — ${name}` : name;
212
+ return formatSectionLabel(found, locale.value);
203
213
  });
204
214
 
205
215
  // Alphabetical grouping