@3sln/deck 0.0.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Ray Stubbs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @3sln/deck
2
+
3
+ A Vite plugin for building scalable, zero-config, Markdown-based component playgrounds and documentation sites.
4
+
5
+ > [!WARNING]
6
+ > This is a work-in-progress project.
7
+
8
+ ## Features
9
+
10
+ - **Scalable Backend:** Uses **IndexedDB** to index and store all documentation content on the client-side. This allows `deck` to handle hundreds or thousands of documents without a slow initial load time.
11
+ - **Vite Plugin:** A simple Vite plugin provides a zero-config development server with hot-reloading.
12
+ - **Static Site Generation:** A `deck-build` command generates a fully static, production-ready site from your project files.
13
+ - **`<deck-demo>`:** A powerful custom element for embedding live, stateful, and hot-reloading component demos directly in your documentation.
14
+ - **Reactive UI:** A modern, responsive UI with a powerful search feature and a split-screen layout for easy viewing.
15
+ - **Configurable:** The project title and import maps for dynamic demos can be configured in your project's `package.json`.
16
+
17
+ ## Quick Start
18
+
19
+ 1. **Install:**
20
+ ```bash
21
+ npm install @3sln/deck
22
+ ```
23
+
24
+ 2. **Configure:** In your `package.json`, add a build script and your project's configuration:
25
+ ```json
26
+ {
27
+ "scripts": {
28
+ "dev": "vite",
29
+ "build": "deck-build"
30
+ },
31
+ "@3sln/deck": {
32
+ "title": "My Awesome Docs"
33
+ }
34
+ }
35
+ ```
36
+
37
+ 3. **Create a `vite.config.js`:**
38
+ ```javascript
39
+ import { defineConfig } from 'vite';
40
+ import deckPlugin from '@3sln/deck/vite-plugin';
41
+
42
+ export default defineConfig({
43
+ plugins: [deckPlugin()],
44
+ });
45
+ ```
46
+
47
+ 4. **Run:**
48
+ ```bash
49
+ npm run dev
50
+ ```
package/bin/build.js ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {build as viteBuild} from 'vite';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ import {createRequire} from 'module';
7
+ import { sha256, loadDeckConfig, getProjectFiles, getHtmlTemplate } from '../src/config.js';
8
+
9
+ const require = createRequire(import.meta.url);
10
+
11
+ // --- Path Resolution ---
12
+ const userRoot = process.cwd();
13
+ const outDir = path.resolve(userRoot, 'out');
14
+ const assetsDir = path.resolve(outDir, 'assets');
15
+ const deckPluginPath = require.resolve('@3sln/deck/vite-plugin');
16
+ const deckRoot = path.dirname(deckPluginPath);
17
+
18
+ // --- Main Build Function ---
19
+ async function build() {
20
+ try {
21
+ console.log('Starting Deck build...');
22
+
23
+ const config = await loadDeckConfig(userRoot);
24
+ const buildConfig = config.build;
25
+
26
+ // Clean output directory
27
+ await fs.emptyDir(outDir);
28
+ console.log(`Cleaned ${outDir}`);
29
+
30
+ // Bundle the deck application using Vite's JS API
31
+ console.log('Bundling application assets...');
32
+ const viteManifest = await viteBuild({
33
+ configFile: false,
34
+ root: deckRoot,
35
+ build: {
36
+ outDir: assetsDir,
37
+ manifest: true,
38
+ lib: {
39
+ entry: path.resolve(deckRoot, 'src/main.js'),
40
+ name: 'DeckApp',
41
+ fileName: 'deck-app',
42
+ formats: ['es'],
43
+ },
44
+ },
45
+ });
46
+ console.log('Application assets bundled.');
47
+
48
+ // Find and copy all project files
49
+ console.log('Copying project files...');
50
+ const filesToCopy = getProjectFiles(userRoot, buildConfig);
51
+ for (const file of filesToCopy) {
52
+ const source = path.resolve(userRoot, file);
53
+ const dest = path.resolve(outDir, file);
54
+ await fs.ensureDir(path.dirname(dest));
55
+ await fs.copy(source, dest);
56
+ }
57
+ console.log(`Copied ${filesToCopy.length} files.`);
58
+
59
+ // Copy picked static assets
60
+ console.log('Copying picked assets...');
61
+ if (buildConfig.pick) {
62
+ for (const [source, dest] of Object.entries(buildConfig.pick)) {
63
+ const sourcePath = path.resolve(userRoot, source);
64
+ const destPath = path.resolve(outDir, dest);
65
+ if (fs.existsSync(sourcePath)) {
66
+ console.log(`Picking '${source}' to '${dest}'...`);
67
+ await fs.copy(sourcePath, destPath, { dereference: true });
68
+ } else {
69
+ console.warn(`Source path for 'pick' not found: ${sourcePath}`);
70
+ }
71
+ }
72
+ }
73
+
74
+ // Find card paths and hash content for the index
75
+ const cardFiles = filesToCopy.filter(file => file.endsWith('.md') || file.endsWith('.html'));
76
+ const initialCardsData = await Promise.all(cardFiles.map(async (file) => {
77
+ const content = await fs.readFile(path.resolve(outDir, file), 'utf-8');
78
+ const hash = await sha256(content);
79
+ return { path: `/${file}`, hash };
80
+ }));
81
+ console.log(`Found and processed ${initialCardsData.length} cards.`);
82
+
83
+ // Generate asset manifest for service worker
84
+ console.log('Generating asset manifest...');
85
+ const manifest = await fs.readJson(path.resolve(assetsDir, '.vite/manifest.json'));
86
+ const bundledAssets = Object.values(manifest).flatMap(chunk => [chunk.file, ...(chunk.css || [])]).map(file => `/assets/${file}`);
87
+ const assetManifest = {
88
+ files: [...filesToCopy.map(f => `/${f}`), ...bundledAssets]
89
+ };
90
+ await fs.writeJson(path.resolve(outDir, 'asset-manifest.json'), assetManifest);
91
+ console.log('Asset manifest generated.');
92
+
93
+ // Copy service worker
94
+ await fs.copy(path.resolve(deckRoot, 'src/sw.js'), path.resolve(outDir, 'sw.js'));
95
+
96
+ // Generate the final index.html
97
+ console.log('Generating production index.html...');
98
+ const entryFile = manifest['src/main.js']?.file;
99
+ const cssFiles = manifest['src/main.js']?.css || [];
100
+
101
+ if (!entryFile) {
102
+ throw new Error('Could not find entry file in Vite manifest.');
103
+ }
104
+
105
+ const html = getHtmlTemplate({
106
+ title: buildConfig.title,
107
+ importMap: buildConfig.importMap,
108
+ initialCardsData,
109
+ pinnedCardPaths: buildConfig.pinned,
110
+ entryFile: `/assets/${entryFile}`,
111
+ cssFiles,
112
+ });
113
+
114
+ await fs.writeFile(path.resolve(outDir, 'index.html'), html);
115
+ console.log('Production index.html generated.');
116
+ console.log('Build complete!');
117
+ } catch (e) {
118
+ console.error('Deck build failed:', e);
119
+ process.exit(1);
120
+ }
121
+ }
122
+
123
+ build();
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@3sln/deck",
3
+ "version": "0.0.5",
4
+ "description": "A Vite plugin for building scalable, zero-config, Markdown-based component playgrounds and documentation sites.",
5
+ "type": "module",
6
+ "author": "Ray Stubbs",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/3sln/deck.git"
10
+ },
11
+ "scripts": {
12
+ "format": "prettier -w \"**/*.js\""
13
+ },
14
+ "license": "MIT",
15
+ "keywords": [
16
+ "vite",
17
+ "plugin",
18
+ "documentation",
19
+ "markdown",
20
+ "component",
21
+ "playground"
22
+ ],
23
+ "exports": {
24
+ ".": "./src/main.js",
25
+ "./vite-plugin": "./vite-plugin.js"
26
+ },
27
+ "bin": {
28
+ "deck-build": "./bin/build.js"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "src/",
33
+ "vite-plugin.js"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@3sln/bones": "^0.0.4",
40
+ "@3sln/dodo": "^0.0.4",
41
+ "@3sln/ngin": "^0.0.1",
42
+ "fs-extra": "^11.3.2",
43
+ "glob": "^10.3.10",
44
+ "highlight.js": "^11.9.0",
45
+ "marked": "^16.3.0",
46
+ "vite": "^5.1.0"
47
+ },
48
+ "devDependencies": {
49
+ "prettier": "^3.6.2"
50
+ }
51
+ }
package/src/config.js ADDED
@@ -0,0 +1,96 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { globSync } from 'glob';
4
+ import { subtle } from 'crypto';
5
+
6
+ export async function sha256(str) {
7
+ const textAsBuffer = new TextEncoder().encode(str);
8
+ const hashBuffer = await subtle.digest('SHA-256', textAsBuffer);
9
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
10
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
11
+ }
12
+
13
+ export async function loadDeckConfig(root) {
14
+ const userPkgJsonPath = path.resolve(root, 'package.json');
15
+ const userPkgJson = fs.existsSync(userPkgJsonPath) ? await fs.readJson(userPkgJsonPath) : {};
16
+ const options = userPkgJson['@3sln/deck'] || {};
17
+
18
+ const defaultConfig = {
19
+ title: 'Deck',
20
+ pinned: [],
21
+ pick: {},
22
+ include: ['**/*'],
23
+ exclude: [
24
+ '**/node_modules/**',
25
+ 'out/**',
26
+ `**/${path.basename(path.resolve(root, 'out'))}/**`,
27
+ '**/package.json',
28
+ '**/package-lock.json',
29
+ '**/vite.config.js',
30
+ '**/.git/**',
31
+ ],
32
+ };
33
+
34
+ const baseConfig = { ...defaultConfig, ...options };
35
+
36
+ return {
37
+ ...baseConfig,
38
+ dev: { ...baseConfig, ...(options.dev || {}) },
39
+ build: { ...baseConfig, ...(options.build || {}) },
40
+ };
41
+ }
42
+
43
+ export function getProjectFiles(root, config) {
44
+ return globSync(config.include, {
45
+ cwd: root,
46
+ ignore: config.exclude,
47
+ nodir: true,
48
+ dot: true,
49
+ });
50
+ }
51
+
52
+ export function getHtmlTemplate({ title, importMap, initialCardsData, pinnedCardPaths, entryFile, cssFiles = [] }) {
53
+ return `
54
+ <!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <title>${title}</title>
60
+ ${importMap ? `<script type="importmap">${JSON.stringify(importMap)}</script>` : ''}
61
+ <script>
62
+ window.__INITIAL_CARDS_DATA__ = ${JSON.stringify(initialCardsData)};
63
+ window.__PINNED_CARD_PATHS__ = ${JSON.stringify(pinnedCardPaths)};
64
+ </script>
65
+ <style>
66
+ :root { --bg-color: #fff; --text-color: #222; --border-color: #eee; --card-bg: #fff;
67
+ --card-hover-bg: #f9f9f9; --input-bg: #fff; --input-border: #ddd; --link-color: #007aff;
68
+ }
69
+ @media (prefers-color-scheme: dark) {
70
+ :root {
71
+ --bg-color: #121212; --text-color: #eee; --border-color: #333; --card-bg: #1e1e1e;
72
+ --card-hover-bg: #2a2a2a; --input-bg: #2a2a2a; --input-border: #444; --link-color: #09f;
73
+ }
74
+ }
75
+ body {
76
+ background-color: var(--bg-color); color: var(--text-color);
77
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
78
+ margin: 0; padding: 0;
79
+ }
80
+ </style>
81
+ ${cssFiles.map(file => `<link rel="stylesheet" href="/assets/${file}">`).join('\n')}
82
+ </head>
83
+ <body>
84
+ <div id="root"></div>
85
+ <script type="module">
86
+ import { renderDeck } from '${entryFile}';
87
+ renderDeck({
88
+ target: document.getElementById('root'),
89
+ initialCardsData: window.__INITIAL_CARDS_DATA__,
90
+ pinnedCardPaths: window.__PINNED_CARD_PATHS__,
91
+ });
92
+ </script>
93
+ </body>
94
+ </html>
95
+ `;
96
+ }
package/src/db.js ADDED
@@ -0,0 +1,302 @@
1
+ const DB_NAME = 'deck-db';
2
+ const DB_VERSION = 3; // Bump version for schema change
3
+ const CARDS_STORE = 'cards';
4
+ const INDEX_STORE = 'searchIndex';
5
+
6
+ let dbPromise = null;
7
+
8
+ function promisifyRequest(request) {
9
+ return new Promise((resolve, reject) => {
10
+ request.onsuccess = () => resolve(request.result);
11
+ request.onerror = () => reject(request.error);
12
+ });
13
+ }
14
+
15
+ function initDB() {
16
+ if (dbPromise) return dbPromise;
17
+
18
+ dbPromise = new Promise((resolve, reject) => {
19
+ const openRequest = indexedDB.open(DB_NAME, DB_VERSION);
20
+
21
+ openRequest.onupgradeneeded = event => {
22
+ const db = event.target.result;
23
+ let cardsStore;
24
+ if (!db.objectStoreNames.contains(CARDS_STORE)) {
25
+ cardsStore = db.createObjectStore(CARDS_STORE, {keyPath: 'path'});
26
+ } else {
27
+ cardsStore = event.target.transaction.objectStore(CARDS_STORE);
28
+ }
29
+
30
+ if (!cardsStore.indexNames.contains('by-updatedAt')) {
31
+ cardsStore.createIndex('by-updatedAt', 'updatedAt');
32
+ }
33
+ if (!cardsStore.indexNames.contains('by-usedAt')) {
34
+ cardsStore.createIndex('by-usedAt', 'usedAt');
35
+ }
36
+
37
+ if (!db.objectStoreNames.contains(INDEX_STORE)) {
38
+ const store = db.createObjectStore(INDEX_STORE, {keyPath: ['word', 'path']});
39
+ store.createIndex('by-word', 'word');
40
+ store.createIndex('by-path', 'path');
41
+ }
42
+ };
43
+
44
+ openRequest.onsuccess = event => resolve(event.target.result);
45
+ openRequest.onerror = event => reject(event.target.error);
46
+ });
47
+
48
+ return dbPromise;
49
+ }
50
+
51
+ function tokenize(text) {
52
+ if (!text) return [];
53
+ return text.toLowerCase().match(/\w+/g) || [];
54
+ }
55
+
56
+ function getTextContent(html) {
57
+ if (!html) return '';
58
+ return new DOMParser().parseFromString(html, 'text/html').body.textContent || '';
59
+ }
60
+
61
+ async function upsertCard(card) {
62
+ const db = await initDB();
63
+ const tx = db.transaction([CARDS_STORE, INDEX_STORE], 'readwrite');
64
+ const cardsStore = tx.objectStore(CARDS_STORE);
65
+ const indexStore = tx.objectStore(INDEX_STORE);
66
+
67
+ const existing = await promisifyRequest(cardsStore.get(card.path));
68
+
69
+ if (existing) {
70
+ const pathIndex = indexStore.index('by-path');
71
+ const oldIndexKeys = await promisifyRequest(pathIndex.getAllKeys(IDBKeyRange.only(card.path)));
72
+ await Promise.all(oldIndexKeys.map(key => promisifyRequest(indexStore.delete(key))));
73
+ }
74
+
75
+ const now = Date.now();
76
+ const newCard = {...card, updatedAt: now, usedAt: existing?.usedAt || now};
77
+ const titleTokens = tokenize(newCard.title);
78
+ const summaryTokens = tokenize(newCard.summary);
79
+ const bodyText = getTextContent(newCard.body);
80
+ const bodyTokens = tokenize(bodyText);
81
+
82
+ const wordScores = new Map();
83
+ const uniqueBodyWords = new Set(bodyTokens);
84
+ const uniqueTitleWords = new Set(titleTokens);
85
+ const uniqueSummaryWords = new Set(summaryTokens);
86
+
87
+ uniqueBodyWords.forEach(word => {
88
+ const tf = bodyTokens.filter(t => t === word).length;
89
+ let score = tf;
90
+ if (uniqueTitleWords.has(word)) score += 1;
91
+ if (uniqueSummaryWords.has(word)) score += 1;
92
+ wordScores.set(word, score);
93
+ });
94
+
95
+ await Promise.all(
96
+ Array.from(wordScores.entries()).map(([word, score]) => {
97
+ return promisifyRequest(indexStore.put({word, path: newCard.path, score}));
98
+ }),
99
+ );
100
+
101
+ await promisifyRequest(cardsStore.put(newCard));
102
+ return newCard;
103
+ }
104
+
105
+ async function removeCard(path) {
106
+ const db = await initDB();
107
+ const tx = db.transaction([CARDS_STORE, INDEX_STORE], 'readwrite');
108
+ const cardsStore = tx.objectStore(CARDS_STORE);
109
+ const indexStore = tx.objectStore(INDEX_STORE);
110
+
111
+ const pathIndex = indexStore.index('by-path');
112
+ const indexKeysToDelete = await promisifyRequest(pathIndex.getAllKeys(IDBKeyRange.only(path)));
113
+ await Promise.all(indexKeysToDelete.map(key => promisifyRequest(indexStore.delete(key))));
114
+
115
+ await promisifyRequest(cardsStore.delete(path));
116
+ }
117
+
118
+ function levenshtein(a, b) {
119
+ if (a.length === 0) return b.length;
120
+ if (b.length === 0) return a.length;
121
+ const matrix = [];
122
+
123
+ for (let i = 0; i <= b.length; i++) {
124
+ matrix[i] = [i];
125
+ }
126
+ for (let j = 0; j <= a.length; j++) {
127
+ matrix[0][j] = j;
128
+ }
129
+
130
+ for (let i = 1; i <= b.length; i++) {
131
+ for (let j = 1; j <= a.length; j++) {
132
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
133
+ matrix[i][j] = matrix[i - 1][j - 1];
134
+ } else {
135
+ matrix[i][j] = Math.min(
136
+ matrix[i - 1][j - 1] + 1, // substitution
137
+ matrix[i][j - 1] + 1, // insertion
138
+ matrix[i - 1][j] + 1, // deletion
139
+ );
140
+ }
141
+ }
142
+ }
143
+ return matrix[b.length][a.length];
144
+ }
145
+
146
+ async function findCardsByQuery(query, limit = 100) {
147
+ const db = await initDB();
148
+ const searchTokens = tokenize(query);
149
+ if (searchTokens.length === 0) return [];
150
+
151
+ const tx = db.transaction([CARDS_STORE, INDEX_STORE], 'readonly');
152
+ const indexStore = tx.objectStore(INDEX_STORE);
153
+ const cardsStore = tx.objectStore(CARDS_STORE);
154
+ const wordIndex = indexStore.index('by-word');
155
+
156
+ const pathScores = new Map();
157
+
158
+ await Promise.all(
159
+ searchTokens.map(async word => {
160
+ const request = wordIndex.getAll(IDBKeyRange.only(word));
161
+ const results = await promisifyRequest(request);
162
+ results.forEach(({path, score}) => {
163
+ pathScores.set(path, (pathScores.get(path) || 0) + score);
164
+ });
165
+ }),
166
+ );
167
+
168
+ if (pathScores.size > 0) {
169
+ const sortedPaths = Array.from(pathScores.entries())
170
+ .sort((a, b) => b[1] - a[1])
171
+ .map(entry => entry[0])
172
+ .slice(0, limit);
173
+
174
+ const cards = await Promise.all(
175
+ sortedPaths.map(path => promisifyRequest(cardsStore.get(path))),
176
+ );
177
+ return cards.filter(Boolean).sort((a, b) => b.usedAt - a.usedAt);
178
+ }
179
+
180
+ // Fallback to aggregated word-by-word distance search
181
+ console.log('No index match, falling back to word-distance search...');
182
+ const allCards = await promisifyRequest(cardsStore.getAll());
183
+ const cardScores = allCards.map(card => {
184
+ const titleTokens = tokenize(card.title);
185
+ const summaryTokens = tokenize(card.summary);
186
+ const searchableTokens = [...new Set([...titleTokens, ...summaryTokens])];
187
+ let totalScore = 0;
188
+
189
+ searchTokens.forEach(queryWord => {
190
+ let bestWordScore = 0;
191
+ searchableTokens.forEach(searchableWord => {
192
+ let currentScore = 0;
193
+ if (searchableWord.startsWith(queryWord)) {
194
+ currentScore = 10 + queryWord.length; // High score for prefix
195
+ } else {
196
+ const longWord = queryWord.length > searchableWord.length ? queryWord : searchableWord;
197
+ const shortWord = queryWord.length > searchableWord.length ? searchableWord : queryWord;
198
+
199
+ if (longWord.length > 7 && shortWord.length < longWord.length / 2) {
200
+ currentScore = 0; // Skip this match
201
+ } else {
202
+ const distance = levenshtein(queryWord, searchableWord);
203
+ if (distance <= 2) {
204
+ currentScore = 1 / (distance + 1); // Score between 0 and 1
205
+ }
206
+ }
207
+ }
208
+ if (currentScore > bestWordScore) {
209
+ bestWordScore = currentScore;
210
+ }
211
+ });
212
+ totalScore += bestWordScore;
213
+ });
214
+
215
+ return {card, score: totalScore};
216
+ });
217
+
218
+ return cardScores
219
+ .filter(item => item.score > 0)
220
+ .sort((a, b) => {
221
+ if (b.score !== a.score) {
222
+ return b.score - a.score;
223
+ }
224
+ return b.card.usedAt - a.card.usedAt;
225
+ })
226
+ .slice(0, 20)
227
+ .map(item => item.card);
228
+ }
229
+
230
+ async function getRecentCards(limit = 100) {
231
+ const db = await initDB();
232
+ return new Promise((resolve, reject) => {
233
+ const tx = db.transaction(CARDS_STORE, 'readonly');
234
+ const index = tx.objectStore(CARDS_STORE).index('by-usedAt');
235
+ const request = index.openCursor(null, 'prev');
236
+
237
+ const results = [];
238
+
239
+ request.onsuccess = () => {
240
+ const cursor = request.result;
241
+ if (cursor && results.length < limit) {
242
+ results.push(cursor.value);
243
+ cursor.continue();
244
+ } else {
245
+ resolve(results);
246
+ }
247
+ };
248
+
249
+ request.onerror = () => {
250
+ reject(request.error);
251
+ };
252
+ });
253
+ }
254
+
255
+ async function getCard(path) {
256
+ const db = await initDB();
257
+ return await promisifyRequest(db.transaction(CARDS_STORE).objectStore(CARDS_STORE).get(path));
258
+ }
259
+
260
+ async function touchCard(path) {
261
+ const db = await initDB();
262
+ const tx = db.transaction(CARDS_STORE, 'readwrite');
263
+ const store = tx.objectStore(CARDS_STORE);
264
+ const card = await promisifyRequest(store.get(path));
265
+ if (card) {
266
+ card.usedAt = Date.now();
267
+ await promisifyRequest(store.put(card));
268
+ }
269
+ }
270
+
271
+ async function pruneCards(livePaths) {
272
+ const db = await initDB();
273
+ const tx = db.transaction([CARDS_STORE, INDEX_STORE], 'readwrite');
274
+ const cardsStore = tx.objectStore(CARDS_STORE);
275
+ const indexStore = tx.objectStore(INDEX_STORE);
276
+ const pathIndex = indexStore.index('by-path');
277
+
278
+ const dbPaths = await promisifyRequest(cardsStore.getAllKeys());
279
+ const livePathsSet = new Set(livePaths);
280
+
281
+ const stalePaths = dbPaths.filter(path => !livePathsSet.has(path));
282
+ if (stalePaths.length === 0) return;
283
+
284
+ console.log(`Pruning ${stalePaths.length} stale cards...`);
285
+
286
+ for (const path of stalePaths) {
287
+ const indexKeysToDelete = await promisifyRequest(pathIndex.getAllKeys(IDBKeyRange.only(path)));
288
+ await Promise.all(indexKeysToDelete.map(key => promisifyRequest(indexStore.delete(key))));
289
+ await promisifyRequest(cardsStore.delete(path));
290
+ }
291
+ }
292
+
293
+ export {
294
+ initDB,
295
+ upsertCard,
296
+ removeCard,
297
+ findCardsByQuery,
298
+ getRecentCards,
299
+ getCard,
300
+ pruneCards,
301
+ touchCard,
302
+ };