diff --git a/sites/svelte.dev/package-lock.json b/sites/svelte.dev/package-lock.json index 3462255899cb..05781d383c83 100644 --- a/sites/svelte.dev/package-lock.json +++ b/sites/svelte.dev/package-lock.json @@ -13,8 +13,10 @@ "cookie": "^0.5.0", "devalue": "^4.3.0", "do-not-zip": "^1.0.0", + "flexsearch": "^0.7.31", "flru": "^1.0.2", - "sourcemap-codec": "^1.4.8" + "sourcemap-codec": "^1.4.8", + "svelte-local-storage-store": "^0.4.0" }, "devDependencies": { "@resvg/resvg-js": "^2.4.0", @@ -2069,6 +2071,11 @@ "node": ">=8" } }, + "node_modules/flexsearch": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.31.tgz", + "integrity": "sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA==" + }, "node_modules/flru": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", @@ -3554,7 +3561,6 @@ "version": "3.55.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.55.1.tgz", "integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ==", - "dev": true, "engines": { "node": ">= 8" } @@ -3598,6 +3604,17 @@ "resolved": "https://registry.npmjs.org/svelte-json-tree/-/svelte-json-tree-1.0.0.tgz", "integrity": "sha512-scs1OdkC8uFpTN4MX0yKkOzZ1/EG3eP1ARC+xcFthXp2IfcwBaXgab0FqA4Am0vQwffNNB+1Gd1LFkJBlynWTA==" }, + "node_modules/svelte-local-storage-store": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.4.0.tgz", + "integrity": "sha512-ctPykTt4S3BE5bF0mfV0jKiUR1qlmqLvnAkQvYHLeb9wRyO1MdIFDVI23X+TZEFleATHkTaOpYZswIvf3b2tWA==", + "engines": { + "node": ">=0.14" + }, + "peerDependencies": { + "svelte": "^3.48.0" + } + }, "node_modules/svelte-preprocess": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.1.tgz", @@ -5496,6 +5513,11 @@ "to-regex-range": "^5.0.1" } }, + "flexsearch": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.31.tgz", + "integrity": "sha512-XGozTsMPYkm+6b5QL3Z9wQcJjNYxp0CYn3U1gO7dwD6PAqU1SVWZxI9CCg3z+ml3YfqdPnrBehaBrnH2AGKbNA==" + }, "flru": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", @@ -6576,8 +6598,7 @@ "svelte": { "version": "3.55.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.55.1.tgz", - "integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ==", - "dev": true + "integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ==" }, "svelte-check": { "version": "3.0.3", @@ -6607,6 +6628,12 @@ "resolved": "https://registry.npmjs.org/svelte-json-tree/-/svelte-json-tree-1.0.0.tgz", "integrity": "sha512-scs1OdkC8uFpTN4MX0yKkOzZ1/EG3eP1ARC+xcFthXp2IfcwBaXgab0FqA4Am0vQwffNNB+1Gd1LFkJBlynWTA==" }, + "svelte-local-storage-store": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.4.0.tgz", + "integrity": "sha512-ctPykTt4S3BE5bF0mfV0jKiUR1qlmqLvnAkQvYHLeb9wRyO1MdIFDVI23X+TZEFleATHkTaOpYZswIvf3b2tWA==", + "requires": {} + }, "svelte-preprocess": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.1.tgz", diff --git a/sites/svelte.dev/package.json b/sites/svelte.dev/package.json index 7a3bd573eac6..e93bac371ec3 100644 --- a/sites/svelte.dev/package.json +++ b/sites/svelte.dev/package.json @@ -21,8 +21,10 @@ "cookie": "^0.5.0", "devalue": "^4.3.0", "do-not-zip": "^1.0.0", + "flexsearch": "^0.7.31", "flru": "^1.0.2", - "sourcemap-codec": "^1.4.8" + "sourcemap-codec": "^1.4.8", + "svelte-local-storage-store": "^0.4.0" }, "devDependencies": { "@resvg/resvg-js": "^2.4.0", diff --git a/sites/svelte.dev/src/lib/actions/focus.js b/sites/svelte.dev/src/lib/actions/focus.js new file mode 100644 index 000000000000..b688318bf0ce --- /dev/null +++ b/sites/svelte.dev/src/lib/actions/focus.js @@ -0,0 +1,68 @@ +/** @param {HTMLElement} node */ +export function focusable_children(node) { + const nodes = Array.from( + node.querySelectorAll( + 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])' + ) + ); + + const index = nodes.indexOf(document.activeElement); + + const update = (d) => { + let i = index + d; + i += nodes.length; + i %= nodes.length; + + // @ts-expect-error + nodes[i].focus(); + }; + + return { + /** @param {string} [selector] */ + next: (selector) => { + const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)]; + + for (let i = 0; i < reordered.length; i += 1) { + if (!selector || reordered[i].matches(selector)) { + reordered[i].focus(); + return; + } + } + }, + /** @param {string} [selector] */ + prev: (selector) => { + const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)]; + + for (let i = reordered.length - 2; i >= 0; i -= 1) { + if (!selector || reordered[i].matches(selector)) { + reordered[i].focus(); + return; + } + } + }, + update + }; +} + +export function trap(node) { + const handle_keydown = (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + + const group = focusable_children(node); + if (e.shiftKey) { + group.prev(); + } else { + group.next(); + } + } + }; + + node.addEventListener('keydown', handle_keydown); + + return { + destroy: () => { + node.removeEventListener('keydown', handle_keydown); + } + }; +} diff --git a/sites/svelte.dev/src/lib/search/Search.svelte b/sites/svelte.dev/src/lib/search/Search.svelte new file mode 100644 index 000000000000..b501a6f44162 --- /dev/null +++ b/sites/svelte.dev/src/lib/search/Search.svelte @@ -0,0 +1,130 @@ + + +
+ + diff --git a/sites/svelte.dev/src/lib/search/SearchBox.svelte b/sites/svelte.dev/src/lib/search/SearchBox.svelte new file mode 100644 index 000000000000..b456db94649e --- /dev/null +++ b/sites/svelte.dev/src/lib/search/SearchBox.svelte @@ -0,0 +1,420 @@ + + +No results
+ {/if} +No results
+{/if} + + diff --git a/sites/svelte.dev/src/lib/search/content.js b/sites/svelte.dev/src/lib/search/content.js new file mode 100644 index 000000000000..2de86eac7fa3 --- /dev/null +++ b/sites/svelte.dev/src/lib/search/content.js @@ -0,0 +1,133 @@ +import { normalizeSlugify, removeMarkdown } from '$lib/server/docs'; +import { extract_frontmatter, transform } from '$lib/server/markdown'; +import fs from 'node:fs'; +import path from 'node:path'; +import glob from 'tiny-glob/sync.js'; + +const base = '../../site/content/'; + +const categories = [ + { + slug: 'docs', + label: null, + /** @param {string[]} parts */ + href: (parts) => + parts.length > 1 ? `/docs/${parts[0]}#${parts.slice(1).join('-')}` : `/docs/${parts[0]}`, + }, + { + slug: 'faq', + label: 'FAQ', + /** @param {string[]} parts */ + href: (parts) => `/faq#${parts.join('-')}`, + }, +]; + +export function content() { + /** @type {import('./types').Block[]} */ + const blocks = []; + + for (const category of categories) { + const breadcrumbs = category.label ? [category.label] : []; + + for (const file of glob('**/*.md', { cwd: `${base}/${category.slug}` })) { + const basename = path.basename(file); + const match = /\d{2}-(.+)\.md/.exec(basename); + if (!match) continue; + + const slug = match[1]; + + const filepath = `${base}/${category.slug}/${file}`; + // const markdown = replace_placeholders(fs.readFileSync(filepath, 'utf-8')); + const markdown = fs.readFileSync(filepath, 'utf-8'); + + const { body, metadata } = extract_frontmatter(markdown); + + const sections = body.trim().split(/^### /m); + const intro = sections.shift().trim(); + const rank = +metadata.rank || undefined; + + blocks.push({ + breadcrumbs: [...breadcrumbs, removeMarkdown(metadata.title ?? '')], + href: category.href([slug]), + content: plaintext(intro), + rank, + }); + + for (const section of sections) { + const lines = section.split('\n'); + const h3 = lines.shift(); + const content = lines.join('\n'); + + const subsections = content.trim().split('### '); + + const intro = subsections.shift().trim(); + + blocks.push({ + breadcrumbs: [...breadcrumbs, removeMarkdown(metadata.title), removeMarkdown(h3)], + href: category.href([slug, normalizeSlugify(h3)]), + content: plaintext(intro), + rank, + }); + + for (const subsection of subsections) { + const lines = subsection.split('\n'); + const h4 = lines.shift(); + + blocks.push({ + breadcrumbs: [ + ...breadcrumbs, + removeMarkdown(metadata.title), + removeMarkdown(h3), + removeMarkdown(h4), + ], + href: category.href([slug, normalizeSlugify(h3), normalizeSlugify(h4)]), + content: plaintext(lines.join('\n').trim()), + rank, + }); + } + } + } + } + + return blocks; +} + +/** @param {string} markdown */ +function plaintext(markdown) { + /** @param {unknown} text */ + const block = (text) => `${text}\n`; + + /** @param {string} text */ + const inline = (text) => text; + + return transform(markdown, { + code: (source) => source.split('// ---cut---\n').pop(), + blockquote: block, + html: () => '\n', + heading: (text) => `${text}\n`, + hr: () => '', + list: block, + listitem: block, + checkbox: block, + paragraph: (text) => `${text}\n\n`, + table: block, + tablerow: block, + tablecell: (text, opts) => { + return text + ' '; + }, + strong: inline, + em: inline, + codespan: inline, + br: () => '', + del: inline, + link: (href, title, text) => text, + image: (href, title, text) => text, + text: inline, + }) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/(\d+);/g, (match, code) => { + return String.fromCharCode(code); + }) + .trim(); +} diff --git a/sites/svelte.dev/src/lib/search/search.js b/sites/svelte.dev/src/lib/search/search.js new file mode 100644 index 000000000000..590f82a59c34 --- /dev/null +++ b/sites/svelte.dev/src/lib/search/search.js @@ -0,0 +1,107 @@ +import flexsearch from 'flexsearch'; + +// @ts-expect-error +const Index = /** @type {import('flexsearch').Index} */ (flexsearch.Index ?? flexsearch); + +export let inited = false; + +/** @type {import('flexsearch').Index[]} */ +let indexes; + +/** @type {Map