@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 +21 -0
- package/README.md +50 -0
- package/bin/build.js +123 -0
- package/package.json +51 -0
- package/src/config.js +96 -0
- package/src/db.js +302 -0
- package/src/deck-demo.js +859 -0
- package/src/fetcher.js +35 -0
- package/src/highlight.js +45 -0
- package/src/main.js +326 -0
- package/src/state.js +227 -0
- package/src/sw.js +55 -0
- package/vite-plugin.js +147 -0
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
|
+
};
|