spark-ssr 0.1.1 → 0.3.0
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/README.md +219 -18
- package/bin/cli.js +86 -12
- package/package.json +4 -3
- package/src/config.js +5 -0
- package/src/db.js +4 -1
- package/src/hydrate.js +10 -3
- package/src/index.js +11 -4
- package/src/parse.js +0 -0
- package/src/render.js +27 -3
- package/src/schema.js +226 -0
- package/src/server.js +784 -86
- package/src/sources.js +131 -0
package/src/sources.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sources beyond SQL — the <spark-ssr> block stops meaning "SQL" and starts
|
|
3
|
+
* meaning "where data comes from" (§8). All bundled, no deps: Bun has fetch,
|
|
4
|
+
* the filesystem, and import() natively.
|
|
5
|
+
*
|
|
6
|
+
* repo = https://api.github.com/repos/x/y URL — server-side fetch, JSON
|
|
7
|
+
* posts = ./content/posts/*.md glob — files become rows
|
|
8
|
+
* weather = ./lib/weather.js module — default export (req, db)
|
|
9
|
+
*
|
|
10
|
+
* Plus the per-source TTL cache behind cache="…" (Tier 3): entries record
|
|
11
|
+
* which tables their SQL read, and any write to a `live` table sweeps them.
|
|
12
|
+
*/
|
|
13
|
+
import { join, resolve, basename, extname } from 'node:path';
|
|
14
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
|
|
17
|
+
// ── URL source ─────────────────────────────────────────────────────────
|
|
18
|
+
// `:param` interpolates from the request (path params, then query), URL-encoded.
|
|
19
|
+
export async function urlSource(url, req) {
|
|
20
|
+
const resolved = String(url).replace(/:([a-zA-Z_$][\w$]*)/g, (m, name) => {
|
|
21
|
+
const v = req?.params?.[name] ?? req?.query?.[name];
|
|
22
|
+
return v === undefined ? m : encodeURIComponent(String(v));
|
|
23
|
+
});
|
|
24
|
+
const res = await fetch(resolved, { headers: { accept: 'application/json' } });
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── glob source ────────────────────────────────────────────────────────
|
|
30
|
+
// Files become rows: front-matter → columns, body → .body, filename → .slug.
|
|
31
|
+
// A blog/docs/portfolio site with no database at all. Markdown stays text —
|
|
32
|
+
// rendering belongs to a companion package, the core is HTML-only.
|
|
33
|
+
export function parseFrontMatter(text) {
|
|
34
|
+
const s = String(text);
|
|
35
|
+
const m = s.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
36
|
+
if (!m) return { data: {}, body: s };
|
|
37
|
+
const data = {};
|
|
38
|
+
for (const line of m[1].split('\n')) {
|
|
39
|
+
const kv = line.match(/^([\w-]+)\s*:\s*(.*)$/);
|
|
40
|
+
if (!kv) continue;
|
|
41
|
+
let v = kv[2].trim().replace(/^["'](.*)["']$/, '$1');
|
|
42
|
+
if (v === 'true') v = true;
|
|
43
|
+
else if (v === 'false') v = false;
|
|
44
|
+
else if (v !== '' && !Number.isNaN(Number(v)) && /^-?[\d.]+$/.test(v)) v = Number(v);
|
|
45
|
+
data[kv[1]] = v;
|
|
46
|
+
}
|
|
47
|
+
return { data, body: s.slice(m[0].length) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Minimal glob: `*` within a segment, `**` for any depth. Enough for
|
|
51
|
+
// ./content/posts/*.md — not a general matcher.
|
|
52
|
+
function globRegex(pattern) {
|
|
53
|
+
const esc = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
54
|
+
.replace(/\*\*/g, ' GLOBSTAR ')
|
|
55
|
+
.replace(/\*/g, '[^/]*')
|
|
56
|
+
.replace(/ GLOBSTAR /g, '.*')
|
|
57
|
+
.replace(/\?/g, '[^/]');
|
|
58
|
+
return new RegExp('^' + esc + '$');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function globSource(pattern, root) {
|
|
62
|
+
const rel = String(pattern).replace(/^\.\//, '');
|
|
63
|
+
const rx = globRegex(rel);
|
|
64
|
+
// Walk from the deepest literal directory prefix.
|
|
65
|
+
let start = root;
|
|
66
|
+
for (const seg of rel.split('/')) {
|
|
67
|
+
if (/[*?]/.test(seg)) break;
|
|
68
|
+
const next = join(start, seg);
|
|
69
|
+
if (existsSync(next) && statSync(next).isDirectory()) start = next;
|
|
70
|
+
else break;
|
|
71
|
+
}
|
|
72
|
+
const rows = [];
|
|
73
|
+
(function walk(dir) {
|
|
74
|
+
let names;
|
|
75
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
76
|
+
for (const f of names) {
|
|
77
|
+
if (f.startsWith('.')) continue;
|
|
78
|
+
const full = join(dir, f);
|
|
79
|
+
const st = statSync(full);
|
|
80
|
+
if (st.isDirectory()) { walk(full); continue; }
|
|
81
|
+
const relPath = full.slice(root.length + 1).split('\\').join('/');
|
|
82
|
+
if (!rx.test(relPath)) continue;
|
|
83
|
+
const raw = readFileSync(full, 'utf8');
|
|
84
|
+
const { data, body } = parseFrontMatter(raw);
|
|
85
|
+
rows.push({
|
|
86
|
+
slug: basename(f, extname(f)),
|
|
87
|
+
path: '/' + relPath,
|
|
88
|
+
mtime: st.mtimeMs,
|
|
89
|
+
...data,
|
|
90
|
+
body,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
})(start);
|
|
94
|
+
// Date-ish front matter first when present, else filename order.
|
|
95
|
+
rows.sort((a, b) => (a.date && b.date) ? String(b.date).localeCompare(String(a.date)) : a.slug.localeCompare(b.slug));
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── module source ──────────────────────────────────────────────────────
|
|
100
|
+
// default export (req, db) => value. In dev the import is mtime-busted so
|
|
101
|
+
// edits take effect without a restart.
|
|
102
|
+
export async function moduleSource(spec, root, req, db, { watch = true } = {}) {
|
|
103
|
+
const file = resolve(root, String(spec).replace(/^\.\//, ''));
|
|
104
|
+
if (!file.startsWith(root) || !existsSync(file)) return null;
|
|
105
|
+
const bust = watch ? '?v=' + statSync(file).mtimeMs : '';
|
|
106
|
+
const mod = await import(pathToFileURL(file).href + bust);
|
|
107
|
+
const fn = mod.default;
|
|
108
|
+
return typeof fn === 'function' ? await fn(req, db) : fn ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── the per-source TTL cache (cache="…") ───────────────────────────────
|
|
112
|
+
export function makeSourceCache() {
|
|
113
|
+
const store = new Map(); // key → { value, expires, tables:Set }
|
|
114
|
+
return {
|
|
115
|
+
get(key) {
|
|
116
|
+
const hit = store.get(key);
|
|
117
|
+
if (!hit) return undefined;
|
|
118
|
+
if (hit.expires < Date.now()) { store.delete(key); return undefined; }
|
|
119
|
+
return hit;
|
|
120
|
+
},
|
|
121
|
+
set(key, value, ttlSeconds, tables = new Set()) {
|
|
122
|
+
store.set(key, { value, expires: Date.now() + ttlSeconds * 1000, tables });
|
|
123
|
+
},
|
|
124
|
+
// A write went through table t — every cached source that read it is stale.
|
|
125
|
+
invalidate(table) {
|
|
126
|
+
for (const [k, v] of store) if (v.tables.has(table)) store.delete(k);
|
|
127
|
+
},
|
|
128
|
+
clear() { store.clear(); },
|
|
129
|
+
size() { return store.size; },
|
|
130
|
+
};
|
|
131
|
+
}
|