@terra-symposium/docs 0.1.73 → 0.1.75

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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/bin/terra-docs.mjs +151 -115
  3. package/lib-dist/components/StaticWorkspace.d.ts.map +1 -1
  4. package/lib-dist/components/StaticWorkspace.js +4 -4
  5. package/lib-dist/components/StaticWorkspace.js.map +1 -1
  6. package/lib-dist/components/terra/Workspace.d.ts.map +1 -1
  7. package/lib-dist/components/terra/Workspace.js +1 -1
  8. package/lib-dist/components/terra/Workspace.js.map +1 -1
  9. package/lib-dist/components/terra/pages/docs/_editable.d.ts +2 -1
  10. package/lib-dist/components/terra/pages/docs/_editable.d.ts.map +1 -1
  11. package/lib-dist/components/terra/pages/docs/_editable.js +2 -2
  12. package/lib-dist/components/terra/pages/docs/_editable.js.map +1 -1
  13. package/lib-dist/components/terra/pages/docs/_main.d.ts +2 -1
  14. package/lib-dist/components/terra/pages/docs/_main.d.ts.map +1 -1
  15. package/lib-dist/components/terra/pages/docs/_main.js +5 -5
  16. package/lib-dist/components/terra/pages/docs/_main.js.map +1 -1
  17. package/lib-dist/components/terra/pages/docs/_sidebar.d.ts.map +1 -1
  18. package/lib-dist/components/terra/pages/docs/_sidebar.js +5 -2
  19. package/lib-dist/components/terra/pages/docs/_sidebar.js.map +1 -1
  20. package/lib-dist/components/terra/pages/docs/_toolbar.d.ts.map +1 -1
  21. package/lib-dist/components/terra/pages/docs/_toolbar.js +1 -4
  22. package/lib-dist/components/terra/pages/docs/_toolbar.js.map +1 -1
  23. package/lib-dist/components/terra/ui.d.ts +3 -1
  24. package/lib-dist/components/terra/ui.d.ts.map +1 -1
  25. package/lib-dist/components/terra/ui.js +11 -3
  26. package/lib-dist/components/terra/ui.js.map +1 -1
  27. package/lib-dist/lib/host.d.ts +2 -0
  28. package/lib-dist/lib/host.d.ts.map +1 -1
  29. package/lib-dist/lib/workspace-types.d.ts.map +1 -1
  30. package/lib-dist/style.css +1 -1
  31. package/package.json +2 -1
  32. package/site/astro.config.mjs +45 -43
  33. package/site/base-path-remark-plugin.mjs +30 -23
  34. package/site/docs-content-plugin.mjs +68 -57
  35. package/site/src/components/DocsSiteSearch.tsx +106 -91
  36. package/site/src/lib/catalog.ts +110 -96
  37. package/site/src/styles/search.css +114 -43
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terra-symposium/docs",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "Terra Docs workspace and Markdown site generator.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -73,6 +73,7 @@
73
73
  "@types/sanitize-html": "^2.16.2",
74
74
  "@vitejs/plugin-react": "6.1.2",
75
75
  "eslint": "10.13.0",
76
+ "globals": "^16.5.0",
76
77
  "husky": "^9.1.7",
77
78
  "postcss": "8.5.31",
78
79
  "postcss-import": "15.1.0",
@@ -13,59 +13,61 @@ const packageRoot = fileURLToPath(new URL('../', import.meta.url));
13
13
  const requireFromPackage = createRequire(import.meta.url);
14
14
  const projectRoot = process.env.TERRA_DOCS_ROOT ?? process.cwd();
15
15
  const publicDir = resolve(projectRoot, '.terra-docs/public');
16
- const outDir = resolve(projectRoot, process.env.TERRA_DOCS_OUT ?? 'docs-dist');
16
+ const outDir = resolve(projectRoot, process.env.TERRA_DOCS_OUT ?? 'dist/docs');
17
17
  const site = process.env.TERRA_DOCS_SITE;
18
18
  const base = process.env.TERRA_DOCS_BASE ?? '/';
19
19
  const projectManifest = (() => {
20
- try {
21
- return JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8'));
22
- } catch {
23
- return {};
24
- }
20
+ try {
21
+ return JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8'));
22
+ } catch {
23
+ return {};
24
+ }
25
25
  })();
26
26
 
27
27
  function packageExportAliases() {
28
- if (!projectManifest.name || !projectManifest.exports) return [];
29
- const escapeRegex = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
30
- return Object.entries(projectManifest.exports).flatMap(([key, value]) => {
31
- const target = typeof value === 'string' ? value : value?.import ?? value?.default;
32
- if (!target?.startsWith('./')) return [];
33
- const prefix = projectManifest.name + (key === '.' ? '' : key.slice(1));
34
- if (key.includes('*')) {
35
- const matcher = new RegExp(`^${escapeRegex(prefix).replace('*', '(.+)')}$`);
36
- return [{ find: matcher, replacement: resolve(projectRoot, target.replace('*', '$1')) }];
37
- }
38
- return [{ find: new RegExp(`^${escapeRegex(prefix)}$`), replacement: resolve(projectRoot, target) }];
39
- });
28
+ if (!projectManifest.name || !projectManifest.exports) return [];
29
+ const escapeRegex = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
30
+ return Object.entries(projectManifest.exports).flatMap(([key, value]) => {
31
+ const target = typeof value === 'string' ? value : (value?.import ?? value?.default);
32
+ if (!target?.startsWith('./')) return [];
33
+ const prefix = projectManifest.name + (key === '.' ? '' : key.slice(1));
34
+ if (key.includes('*')) {
35
+ const matcher = new RegExp(`^${escapeRegex(prefix).replace('*', '(.+)')}$`);
36
+ return [{ find: matcher, replacement: resolve(projectRoot, target.replace('*', '$1')) }];
37
+ }
38
+ return [
39
+ { find: new RegExp(`^${escapeRegex(prefix)}$`), replacement: resolve(projectRoot, target) },
40
+ ];
41
+ });
40
42
  }
41
43
 
42
44
  const packageDependencyResolver = {
43
- name: 'terra-docs-package-dependencies',
44
- enforce: 'pre',
45
- resolveId(source) {
46
- if (!/^(?:astro|@astrojs\/(?:markdown-remark|mdx|react))(?:\/|$)/.test(source)) return null;
47
- try {
48
- return requireFromPackage.resolve(source);
49
- } catch {
50
- return null;
51
- }
52
- },
45
+ name: 'terra-docs-package-dependencies',
46
+ enforce: 'pre',
47
+ resolveId(source) {
48
+ if (!/^(?:astro|@astrojs\/(?:markdown-remark|mdx|react))(?:\/|$)/.test(source)) return null;
49
+ try {
50
+ return requireFromPackage.resolve(source);
51
+ } catch {
52
+ return null;
53
+ }
54
+ },
53
55
  };
54
56
 
55
57
  export default defineConfig({
56
- root: projectRoot,
57
- srcDir: resolve(packageRoot, 'site/src'),
58
- publicDir,
59
- outDir,
60
- ...(site ? { site } : {}),
61
- base,
62
- devToolbar: { enabled: false },
63
- build: { format: 'directory' },
64
- integrations: [react(), mdx()],
65
- markdown: { processor: unified({ remarkPlugins: [[basePathRemarkPlugin, { base }]] }) },
66
- vite: {
67
- plugins: [packageDependencyResolver, docsContentPlugin({ root: projectRoot, base })],
68
- resolve: { alias: packageExportAliases() },
69
- server: { fs: { allow: [projectRoot, packageRoot] } },
70
- },
58
+ root: projectRoot,
59
+ srcDir: resolve(packageRoot, 'site/src'),
60
+ publicDir,
61
+ outDir,
62
+ ...(site ? { site } : {}),
63
+ base,
64
+ devToolbar: { enabled: false },
65
+ build: { format: 'directory' },
66
+ integrations: [react({ experimentalDisableStreaming: true }), mdx()],
67
+ markdown: { processor: unified({ remarkPlugins: [[basePathRemarkPlugin, { base }]] }) },
68
+ vite: {
69
+ plugins: [packageDependencyResolver, docsContentPlugin({ root: projectRoot, base })],
70
+ resolve: { alias: packageExportAliases() },
71
+ server: { fs: { allow: [projectRoot, packageRoot] } },
72
+ },
71
73
  });
@@ -1,31 +1,38 @@
1
1
  function addBasePath(value, base) {
2
- if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || base === '/') return value;
3
- const basePath = `/${base.replace(/^\/+|\/+$/g, '')}`;
4
- if (value === basePath) return `${basePath}/`;
5
- if (value.startsWith(`${basePath}/`)) return value;
6
- return `${basePath}${value}`;
2
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || base === '/')
3
+ return value;
4
+ const basePath = `/${base.replace(/^\/+|\/+$/g, '')}`;
5
+ if (value === basePath) return `${basePath}/`;
6
+ if (value.startsWith(`${basePath}/`)) return value;
7
+ return `${basePath}${value}`;
7
8
  }
8
9
 
9
10
  function addBaseToHtml(source, base) {
10
- return source.replace(/\b(href|src)=(['"])(\/[^'"#?]*)([^'"]*)\2/gi, (_match, attribute, quote, pathname, suffix) =>
11
- `${attribute}=${quote}${addBasePath(`${pathname}${suffix}`, base)}${quote}`,
12
- );
11
+ return source.replace(
12
+ /\b(href|src)=(['"])(\/[^'"#?]*)([^'"]*)\2/gi,
13
+ (_match, attribute, quote, pathname, suffix) =>
14
+ `${attribute}=${quote}${addBasePath(`${pathname}${suffix}`, base)}${quote}`,
15
+ );
13
16
  }
14
17
 
15
18
  export function basePathRemarkPlugin({ base }) {
16
- return (tree) => {
17
- const visit = (node) => {
18
- if (['link', 'image', 'definition'].includes(node.type) && typeof node.url === 'string') {
19
- node.url = addBasePath(node.url, base);
20
- }
21
- if (node.type === 'html' && typeof node.value === 'string') {
22
- node.value = addBaseToHtml(node.value, base);
23
- }
24
- if (node.type === 'mdxJsxAttribute' && ['href', 'src'].includes(node.name) && typeof node.value === 'string') {
25
- node.value = addBasePath(node.value, base);
26
- }
27
- for (const child of node.children ?? []) visit(child);
28
- };
29
- visit(tree);
30
- };
19
+ return (tree) => {
20
+ const visit = (node) => {
21
+ if (['link', 'image', 'definition'].includes(node.type) && typeof node.url === 'string') {
22
+ node.url = addBasePath(node.url, base);
23
+ }
24
+ if (node.type === 'html' && typeof node.value === 'string') {
25
+ node.value = addBaseToHtml(node.value, base);
26
+ }
27
+ if (
28
+ node.type === 'mdxJsxAttribute' &&
29
+ ['href', 'src'].includes(node.name) &&
30
+ typeof node.value === 'string'
31
+ ) {
32
+ node.value = addBasePath(node.value, base);
33
+ }
34
+ for (const child of node.children ?? []) visit(child);
35
+ };
36
+ visit(tree);
37
+ };
31
38
  }
@@ -4,70 +4,81 @@ import { extname, relative, resolve, sep } from 'node:path';
4
4
  const markdownExtensions = new Set(['.md', '.mdx']);
5
5
 
6
6
  function publicRoute(file, docsRoot, base) {
7
- const path = relative(docsRoot, file).split(sep).join('/').replace(/\.(?:md|mdx)$/i, '');
8
- const route = path.replace(/(?:^|\/)index$/i, '').replace(/^\/+|\/+$/g, '');
9
- return `${base}${route ? `${route}/` : ''}`;
7
+ const path = relative(docsRoot, file)
8
+ .split(sep)
9
+ .join('/')
10
+ .replace(/\.(?:md|mdx)$/i, '');
11
+ const route = path.replace(/(?:^|\/)index$/i, '').replace(/^\/+|\/+$/g, '');
12
+ return `${base}${route ? `${route}/` : ''}`;
10
13
  }
11
14
 
12
15
  function rewriteTarget(rawTarget, sourceFile, docsRoot, base) {
13
- const target = rawTarget.trim();
14
- if (!target || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(target)) return rawTarget;
15
- const match = /^(<[^>]+>|[^\s]+)([\s\S]*)$/.exec(target);
16
- if (!match) return rawTarget;
17
- const bracketed = match[1].startsWith('<') && match[1].endsWith('>');
18
- const pathname = bracketed ? match[1].slice(1, -1) : match[1];
19
- const suffix = match[2];
20
- const [filePath, ...hashParts] = pathname.split('#');
21
- const [cleanPath, ...queryParts] = filePath.split('?');
22
- const fragment = hashParts.length ? `#${hashParts.join('#')}` : '';
23
- const query = queryParts.length ? `?${queryParts.join('?')}` : '';
24
- if (cleanPath.startsWith('/')) {
25
- const basePath = base === '/' ? '' : `/${base.replace(/^\/+|\/+$/g, '')}`;
26
- const rootBase = `${basePath}/`;
27
- const routedPath = base === '/' || cleanPath.startsWith(rootBase)
28
- ? cleanPath
29
- : cleanPath === basePath
30
- ? rootBase
31
- : `${basePath}${cleanPath}`;
32
- const rewritten = `${routedPath}${query}${fragment}`;
33
- return `${bracketed ? `<${rewritten}>` : rewritten}${suffix}`;
34
- }
35
- let resolvedPath;
36
- try {
37
- resolvedPath = resolve(sourceFile, '..', decodeURIComponent(cleanPath));
38
- } catch {
39
- return rawTarget;
40
- }
41
- const relativePath = relative(docsRoot, resolvedPath);
42
- if (relativePath.startsWith(`..${sep}`) || relativePath === '..' || !existsSync(resolvedPath)) return rawTarget;
43
- const extension = extname(resolvedPath).toLowerCase();
44
- const rewritten = markdownExtensions.has(extension)
45
- ? publicRoute(resolvedPath, docsRoot, base)
46
- : `${base}_terra-docs/assets/${relativePath.split(sep).join('/')}`;
47
- return `${bracketed ? `<${rewritten}${query}${fragment}>` : `${rewritten}${query}${fragment}`}${suffix}`;
16
+ const target = rawTarget.trim();
17
+ if (!target || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(target)) return rawTarget;
18
+ const match = /^(<[^>]+>|[^\s]+)([\s\S]*)$/.exec(target);
19
+ if (!match) return rawTarget;
20
+ const bracketed = match[1].startsWith('<') && match[1].endsWith('>');
21
+ const pathname = bracketed ? match[1].slice(1, -1) : match[1];
22
+ const suffix = match[2];
23
+ const [filePath, ...hashParts] = pathname.split('#');
24
+ const [cleanPath, ...queryParts] = filePath.split('?');
25
+ const fragment = hashParts.length ? `#${hashParts.join('#')}` : '';
26
+ const query = queryParts.length ? `?${queryParts.join('?')}` : '';
27
+ if (cleanPath.startsWith('/')) {
28
+ const basePath = base === '/' ? '' : `/${base.replace(/^\/+|\/+$/g, '')}`;
29
+ const rootBase = `${basePath}/`;
30
+ const routedPath =
31
+ base === '/' || cleanPath.startsWith(rootBase)
32
+ ? cleanPath
33
+ : cleanPath === basePath
34
+ ? rootBase
35
+ : `${basePath}${cleanPath}`;
36
+ const rewritten = `${routedPath}${query}${fragment}`;
37
+ return `${bracketed ? `<${rewritten}>` : rewritten}${suffix}`;
38
+ }
39
+ let resolvedPath;
40
+ try {
41
+ resolvedPath = resolve(sourceFile, '..', decodeURIComponent(cleanPath));
42
+ } catch {
43
+ return rawTarget;
44
+ }
45
+ const relativePath = relative(docsRoot, resolvedPath);
46
+ if (relativePath.startsWith(`..${sep}`) || relativePath === '..' || !existsSync(resolvedPath))
47
+ return rawTarget;
48
+ const extension = extname(resolvedPath).toLowerCase();
49
+ const rewritten = markdownExtensions.has(extension)
50
+ ? publicRoute(resolvedPath, docsRoot, base)
51
+ : `${base}_terra-docs/assets/${relativePath.split(sep).join('/')}`;
52
+ return `${bracketed ? `<${rewritten}${query}${fragment}>` : `${rewritten}${query}${fragment}`}${suffix}`;
48
53
  }
49
54
 
50
55
  function mapOutsideCode(source, callback) {
51
- const sections = source.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g);
52
- return sections.map((section, index) => index % 2 ? section : callback(section)).join('');
56
+ const sections = source.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g);
57
+ return sections.map((section, index) => (index % 2 ? section : callback(section))).join('');
53
58
  }
54
59
 
55
60
  export function docsContentPlugin({ root, base }) {
56
- const docsRoot = resolve(root, 'docs');
57
- return {
58
- name: 'terra-docs-content-assets',
59
- enforce: 'pre',
60
- transform(source, id) {
61
- if (id.includes('?') || !markdownExtensions.has(extname(id).toLowerCase())) return null;
62
- const sourceFile = resolve(id);
63
- const relativePath = relative(docsRoot, sourceFile);
64
- if (relativePath.startsWith(`..${sep}`) || relativePath === '..') return null;
65
- const withMarkdownLinks = source.replace(/(!?\[[^\]]*\]\()([^)]+)(\))/g, (_full, before, target, after) =>
66
- `${before}${rewriteTarget(target, sourceFile, docsRoot, base)}${after}`,
67
- );
68
- return mapOutsideCode(withMarkdownLinks, (section) => section.replace(/\b(src|href)=(['"])(.*?)\2/g, (full, attribute, quote, target) =>
69
- `${attribute}=${quote}${rewriteTarget(target, sourceFile, docsRoot, base)}${quote}`,
70
- ));
71
- },
72
- };
61
+ const docsRoot = resolve(root, 'docs');
62
+ return {
63
+ name: 'terra-docs-content-assets',
64
+ enforce: 'pre',
65
+ transform(source, id) {
66
+ if (id.includes('?') || !markdownExtensions.has(extname(id).toLowerCase())) return null;
67
+ const sourceFile = resolve(id);
68
+ const relativePath = relative(docsRoot, sourceFile);
69
+ if (relativePath.startsWith(`..${sep}`) || relativePath === '..') return null;
70
+ const withMarkdownLinks = source.replace(
71
+ /(!?\[[^\]]*\]\()([^)]+)(\))/g,
72
+ (_full, before, target, after) =>
73
+ `${before}${rewriteTarget(target, sourceFile, docsRoot, base)}${after}`,
74
+ );
75
+ return mapOutsideCode(withMarkdownLinks, (section) =>
76
+ section.replace(
77
+ /\b(src|href)=(['"])(.*?)\2/g,
78
+ (full, attribute, quote, target) =>
79
+ `${attribute}=${quote}${rewriteTarget(target, sourceFile, docsRoot, base)}${quote}`,
80
+ ),
81
+ );
82
+ },
83
+ };
73
84
  }
@@ -1,108 +1,123 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
2
 
3
3
  type SearchDocument = {
4
- title: string;
5
- description: string;
6
- href: string;
7
- text: string;
4
+ title: string;
5
+ description: string;
6
+ href: string;
7
+ text: string;
8
8
  };
9
9
 
10
10
  type Props = { base?: string };
11
11
 
12
12
  function excerpt(text: string, query: string): string {
13
- const normalized = text.replace(/\s+/g, ' ').trim();
14
- const offset = normalized.toLowerCase().indexOf(query.toLowerCase());
15
- const start = Math.max(0, offset - 72);
16
- const end = Math.min(normalized.length, start + 190);
17
- return `${start > 0 ? '…' : ''}${normalized.slice(start, end)}${end < normalized.length ? '…' : ''}`;
13
+ const normalized = text.replace(/\s+/g, ' ').trim();
14
+ const offset = normalized.toLowerCase().indexOf(query.toLowerCase());
15
+ const start = Math.max(0, offset - 72);
16
+ const end = Math.min(normalized.length, start + 190);
17
+ return `${start > 0 ? '…' : ''}${normalized.slice(start, end)}${end < normalized.length ? '…' : ''}`;
18
18
  }
19
19
 
20
20
  export function DocsSiteSearch({ base = '/' }: Props) {
21
- const dialog = useRef<HTMLDialogElement>(null);
22
- const input = useRef<HTMLInputElement>(null);
23
- const [query, setQuery] = useState('');
24
- const [documents, setDocuments] = useState<SearchDocument[]>([]);
25
- const [loaded, setLoaded] = useState(false);
26
- const [failed, setFailed] = useState(false);
27
- const [opened, setOpened] = useState(false);
28
- const normalizedBase = base.endsWith('/') ? base : `${base}/`;
21
+ const dialog = useRef<HTMLDialogElement>(null);
22
+ const input = useRef<HTMLInputElement>(null);
23
+ const [query, setQuery] = useState('');
24
+ const [documents, setDocuments] = useState<SearchDocument[]>([]);
25
+ const [loaded, setLoaded] = useState(false);
26
+ const [failed, setFailed] = useState(false);
27
+ const [opened, setOpened] = useState(false);
28
+ const normalizedBase = base.endsWith('/') ? base : `${base}/`;
29
29
 
30
- const open = () => {
31
- if (!dialog.current?.open) dialog.current?.showModal();
32
- setOpened(true);
33
- window.setTimeout(() => input.current?.focus(), 0);
34
- };
30
+ const open = () => {
31
+ if (!dialog.current?.open) dialog.current?.showModal();
32
+ setOpened(true);
33
+ window.setTimeout(() => input.current?.focus(), 0);
34
+ };
35
35
 
36
- useEffect(() => {
37
- const onKeyDown = (event: KeyboardEvent) => {
38
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
39
- event.preventDefault();
40
- open();
41
- } else if (event.key === '/' && !/input|textarea|select/i.test((event.target as HTMLElement)?.tagName ?? '')) {
42
- event.preventDefault();
43
- open();
44
- }
45
- };
46
- document.addEventListener('keydown', onKeyDown);
47
- return () => document.removeEventListener('keydown', onKeyDown);
48
- }, []);
36
+ useEffect(() => {
37
+ const onOpenSearch = () => open();
38
+ const onKeyDown = (event: KeyboardEvent) => {
39
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
40
+ event.preventDefault();
41
+ open();
42
+ } else if (
43
+ event.key === '/' &&
44
+ !/input|textarea|select/i.test((event.target as HTMLElement)?.tagName ?? '')
45
+ ) {
46
+ event.preventDefault();
47
+ open();
48
+ }
49
+ };
50
+ document.addEventListener('docs:open-search', onOpenSearch);
51
+ document.addEventListener('keydown', onKeyDown);
52
+ return () => {
53
+ document.removeEventListener('docs:open-search', onOpenSearch);
54
+ document.removeEventListener('keydown', onKeyDown);
55
+ };
56
+ }, []);
49
57
 
50
- useEffect(() => {
51
- if (!opened || loaded) return;
52
- fetch(`${normalizedBase}_terra-docs/search-index.json`)
53
- .then((response) => {
54
- if (!response.ok) throw new Error(`Search index returned ${response.status}`);
55
- return response.json() as Promise<SearchDocument[]>;
56
- })
57
- .then((data) => {
58
- setDocuments(data);
59
- setLoaded(true);
60
- })
61
- .catch(() => setFailed(true));
62
- }, [loaded, normalizedBase, opened]);
58
+ useEffect(() => {
59
+ if (!opened || loaded) return;
60
+ fetch(`${normalizedBase}_terra-docs/search-index.json`)
61
+ .then((response) => {
62
+ if (!response.ok) throw new Error(`Search index returned ${response.status}`);
63
+ return response.json() as Promise<SearchDocument[]>;
64
+ })
65
+ .then((data) => {
66
+ setDocuments(data);
67
+ setLoaded(true);
68
+ })
69
+ .catch(() => setFailed(true));
70
+ }, [loaded, normalizedBase, opened]);
63
71
 
64
- const results = useMemo(() => {
65
- const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
66
- if (!terms.length) return [];
67
- return documents
68
- .map((document) => {
69
- const title = document.title.toLowerCase();
70
- const text = `${document.description} ${document.text}`.toLowerCase();
71
- const matches = terms.filter((term) => title.includes(term) || text.includes(term));
72
- const score = matches.length * 10 + terms.filter((term) => title.includes(term)).length * 5;
73
- return { document, score };
74
- })
75
- .filter((result) => result.score > 0)
76
- .sort((left, right) => right.score - left.score || left.document.title.localeCompare(right.document.title))
77
- .slice(0, 10);
78
- }, [documents, query]);
72
+ const results = useMemo(() => {
73
+ const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
74
+ if (!terms.length) return [];
75
+ return documents
76
+ .map((document) => {
77
+ const title = document.title.toLowerCase();
78
+ const text = `${document.description} ${document.text}`.toLowerCase();
79
+ const matches = terms.filter((term) => title.includes(term) || text.includes(term));
80
+ const score = matches.length * 10 + terms.filter((term) => title.includes(term)).length * 5;
81
+ return { document, score };
82
+ })
83
+ .filter((result) => result.score > 0)
84
+ .sort(
85
+ (left, right) =>
86
+ right.score - left.score || left.document.title.localeCompare(right.document.title),
87
+ )
88
+ .slice(0, 10);
89
+ }, [documents, query]);
79
90
 
80
- return (
81
- <div className="terra-docs-search">
82
- <button className="terra-docs-search-trigger" type="button" onClick={open} aria-label="Search documentation">
83
- <span aria-hidden="true">⌕</span><span>Search docs</span><kbd>⌘ K</kbd>
84
- </button>
85
- <dialog className="terra-docs-search-dialog" ref={dialog} onClose={() => setQuery('')}>
86
- <div className="terra-docs-search-panel">
87
- <label className="terra-docs-search-input-row">
88
- <span aria-hidden="true">⌕</span>
89
- <input ref={input} value={query} onChange={(event) => setQuery(event.currentTarget.value)} placeholder="Search documentation…" />
90
- <button type="button" onClick={() => dialog.current?.close()} aria-label="Close search">Esc</button>
91
- </label>
92
- <div className="terra-docs-search-results" aria-live="polite">
93
- {failed ? <p>Search is unavailable right now.</p> : null}
94
- {!loaded && !failed ? <p>Loading search…</p> : null}
95
- {loaded && query.trim() && results.length === 0 ? <p>No matching pages.</p> : null}
96
- {results.map(({ document }) => (
97
- <a key={document.href} href={document.href} onClick={() => dialog.current?.close()}>
98
- <strong>{document.title}</strong>
99
- {document.description ? <span>{document.description}</span> : null}
100
- <small>{excerpt(document.text, query.trim().split(/\s+/)[0] ?? query)}</small>
101
- </a>
102
- ))}
103
- </div>
104
- </div>
105
- </dialog>
106
- </div>
107
- );
91
+ return (
92
+ <div className="terra-docs-search">
93
+ <dialog className="terra-docs-search-dialog" ref={dialog} onClose={() => setQuery('')}>
94
+ <div className="terra-docs-search-panel">
95
+ <label className="terra-docs-search-input-row">
96
+ <span aria-hidden="true">⌕</span>
97
+ <input
98
+ ref={input}
99
+ value={query}
100
+ onChange={(event) => setQuery(event.currentTarget.value)}
101
+ placeholder="Search documentation…"
102
+ />
103
+ <button type="button" onClick={() => dialog.current?.close()} aria-label="Close search">
104
+ Esc
105
+ </button>
106
+ </label>
107
+ <div className="terra-docs-search-results" aria-live="polite">
108
+ {failed ? <p>Search is unavailable right now.</p> : null}
109
+ {!loaded && !failed ? <p>Loading search…</p> : null}
110
+ {loaded && query.trim() && results.length === 0 ? <p>No matching pages.</p> : null}
111
+ {results.map(({ document }) => (
112
+ <a key={document.href} href={document.href} onClick={() => dialog.current?.close()}>
113
+ <strong>{document.title}</strong>
114
+ {document.description ? <span>{document.description}</span> : null}
115
+ <small>{excerpt(document.text, query.trim().split(/\s+/)[0] ?? query)}</small>
116
+ </a>
117
+ ))}
118
+ </div>
119
+ </div>
120
+ </dialog>
121
+ </div>
122
+ );
108
123
  }