-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtemplates.js
More file actions
320 lines (292 loc) · 13.8 KB
/
Copy pathtemplates.js
File metadata and controls
320 lines (292 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
// ──────────────────────────────────────────────
// templates.js — template library + slot fill
// Hierarchy (lookup order: universe → global → official):
// templates/official/<site>/ — hand-written, high quality
// templates/global/<site>/ — user-created, cross-universe
// universes/<universe>/<site>/ — scoped to one universe
//
// Each site folder:
// *.html — page templates (with @websim-template meta + <!-- SLOT: x -->)
// theme.css — site-specific styles (layered on global theme.css)
// components/ — site-specific reusable HTML snippets
// ──────────────────────────────────────────────
import { readdir, readFile, writeFile, mkdir, stat } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = __dirname;
const OFFICIAL_DIR = join(ROOT, "templates", "official");
const GLOBAL_DIR = join(ROOT, "templates", "global");
const GENERIC_DIR = join(ROOT, "templates", "generic");
const UNIVERSE_DIR = join(ROOT, "universes");
/** @typedef {{slug:string, site:string, page:string, name:string, domains:string[], tags:string[], description:string, html:string, scope:"official"|"global"|string}} Tmpl */
let cache = [];
function parseMeta(html) {
const m = html.match(/@websim-template([\s\S]*?)-->/);
const meta = { name: "", page: "", domains: [], tags: [], description: "" };
if (!m) return meta;
for (const line of m[1].split("\n")) {
const kv = line.match(/^\s*(name|page|domains|tags|description):\s*(.+)$/);
if (!kv) continue;
const [, k, v] = kv;
if (k === "domains" || k === "tags") meta[k] = v.split(",").map((s) => s.trim()).filter(Boolean);
else meta[k] = v.trim();
}
return meta;
}
async function loadDir(dir, scope) {
const out = [];
let sites;
try { sites = await readdir(dir); } catch { return out; }
for (const site of sites) {
const sd = join(dir, site);
let st; try { st = await stat(sd); } catch { continue; }
if (!st.isDirectory()) continue;
let pages; try { pages = await readdir(sd); } catch { continue; }
for (const f of pages) {
if (!f.endsWith(".html")) continue;
const html = await readFile(join(sd, f), "utf8");
const meta = parseMeta(html);
const page = meta.page || f.replace(/\.html$/, "");
out.push({ slug: `${site}/${page}`, site, page, name: meta.name || page, domains: meta.domains, tags: meta.tags, description: meta.description, html, scope });
}
}
return out;
}
export async function loadTemplates() {
cache = [];
cache.push(...(await loadDir(OFFICIAL_DIR, "official")));
cache.push(...(await loadDir(GLOBAL_DIR, "global")));
cache.push(...(await loadDir(GENERIC_DIR, "generic")));
try {
const unis = await readdir(UNIVERSE_DIR);
for (const u of unis) cache.push(...(await loadDir(join(UNIVERSE_DIR, u), u)));
} catch { /* none yet */ }
return cache.length;
}
/** Find template by exact slug (site/page) — used by preview endpoint. */
export function getGenericTemplates() {
return cache.filter(t => t.scope === 'generic');
}
export function findBySlug(slug, universe) {
return cache.find(t => t.slug === slug && (t.scope === "official" || t.scope === "global" || t.scope === universe)) || null;
}
/** List templates visible to a universe (official + global + that universe's own). */
export function manifest(universe) {
return cache.filter((t) => t.scope === "official" || t.scope === "global" || t.scope === "generic" || t.scope === universe)
.map(({ slug, site, page, name, domains, tags, description, scope }) => ({ slug, site, page, name, domains, tags, description, scope }));
}
/** Load a site's theme.css (official > global > universe). Returns "" if none. */
export async function getSiteTheme(site, universe) {
const paths = [join(OFFICIAL_DIR, site, "theme.css"), join(GLOBAL_DIR, site, "theme.css"), ...(universe ? [join(UNIVERSE_DIR, universe, site, "theme.css")] : [])];
for (const p of paths) { try { return await readFile(p, "utf8"); } catch {} }
return "";
}
/** Load a site's components folder as a map of name → html snippet. */
export async function getSiteComponents(site, universe) {
const map = {};
const paths = [join(OFFICIAL_DIR, site, "components"), join(GLOBAL_DIR, site, "components"), ...(universe ? [join(UNIVERSE_DIR, universe, site, "components")] : [])];
for (const dir of paths) {
try {
const files = await readdir(dir);
for (const f of files) {
if (!f.endsWith(".html")) continue;
const name = f.replace(/\.html$/, "");
if (!map[name]) map[name] = await readFile(join(dir, f), "utf8");
}
} catch {}
}
return map;
}
function domainMatch(domains, url) {
let host = url;
let parsed = false;
try { host = new URL(url.startsWith("http") ? url : `http://${url}`).hostname; parsed = true; } catch {}
host = host.replace(/^www\./, "");
return domains.some((d) => {
const dd = d.trim().replace(/^www\./, "");
if (dd.includes("*")) return new RegExp("^" + dd.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$").test(host);
if (host === dd || host.endsWith("." + dd)) return true;
// Only use url.includes for path-qualified domain specs (e.g. 'google.com/maps') or unparseable URLs
if (!parsed || dd.includes("/")) return url.includes(dd);
return false;
});
}
const PATH_HINTS = [
["/search", "search-results"], ["/explore", "search-results"], ["/watch", "watch"], ["/results", "search-results"],
// /comments/ must come before /r/ — /r/subreddit is a feed, /r/sub/comments/id is a thread
["/comments/", "thread"], ["/thread/", "thread"],
["/r/", "feed"],
// GitHub issues and issue detail pages
["/issues", "issues"], ["/issue/", "issue"],
// Tweet permalink: /username/status/id
["/status/", "tweet"],
// Twitter notifications
["/notifications", "notifications"],
// "/posts" (plural listing) must come before "/post" (single) so e621/posts doesn't match as post
["/posts", "posts"],
["/post/", "post"], ["/post", "post"], ["/p/", "post"], ["/profile", "profile"], ["/user/", "profile"],
// Reddit user profiles
["/u/", "profile"],
// Amazon cart and orders
["/cart", "cart"], ["/gp/", "orders"],
["/dp/", "product"], ["/board/", "board"],
["/title/", "title"], ["/title", "title"],
// IMDB person pages
["/name/", "person"],
["/maps", "map"],
// YouTube and other video platforms
["/channel", "channel"], ["/c/", "channel"],
["/video/", "video"],
// Generic paths
["/videos", "videos"],
["/album/", "album"], ["/track/", "track"],
["/sets/", "album"],
["/deviation/", "deviation"],
["/pin/", "pin"],
["/in/", "profile"],
["/jobs/", "jobs"],
["/app/", "app"],
["/id/", "profile"],
["/artist/", "artist"],
["/wiki/", "article"],
["/works/", "work"],
];
// Query param hints for sites that use ?page=xxx&s=xxx style URLs
const QUERY_HINTS = [
[/page=post&s=list/, "posts"], [/page=post&s=view/, "post"],
[/tbm=isch/, "images"],
[/tbm=nws/, "news"],
[/tbm=vid/, "videos"],
];
/** Find best template for a URL with match quality info. Returns { template, matchType }.
* matchType is one of: "exact", "fallback", "domain-miss", "static" */
export function findTemplateEx(url, universe) {
const cand = cache.filter((t) => {
if (t.scope === "generic") return false; // handled separately as fallback
if (t.scope === "official" || t.scope === "global") return domainMatch(t.domains, url);
return t.scope === universe && domainMatch(t.domains, url);
});
// No matching domain at all — try generic fallback
if (!cand.length) {
const generic = cache.filter((t) => t.scope === "generic");
if (!generic.length) return { template: null, matchType: "domain-miss" };
// Pick best generic template by path hints
try {
const u = new URL(url.startsWith("http") ? url : `http://${url}`);
const p = u.pathname.toLowerCase();
const q = u.search;
for (const [rx, hint] of QUERY_HINTS) if (rx.test(q)) { const f = generic.find((t) => t.page === hint); if (f) return { template: f, matchType: "fallback" }; }
for (const [seg, hint] of PATH_HINTS) if (p.includes(seg)) { const f = generic.find((t) => t.page === hint); if (f) return { template: f, matchType: "fallback" }; }
} catch {}
const fallback = generic.find((t) => t.page === "article") || generic[0];
return { template: fallback, matchType: "domain-miss" };
}
// Prefer scoped over global over official
cand.sort((a, b) => {
const o = { official: 0, global: 1 };
const va = o[a.scope] ?? 2, vb = o[b.scope] ?? 2;
return vb - va;
});
if (cand.length === 1) {
const tmpl = cand[0];
// Check if this is a static template (no slots)
if (!extractSlots(tmpl.html).length) {
return { template: tmpl, matchType: "static" };
}
// Single candidate, no path hint match => fallback
return { template: tmpl, matchType: "fallback" };
}
try {
const u = new URL(url.startsWith("http") ? url : `http://${url}`);
const p = u.pathname.toLowerCase();
const q = u.search;
const h = u.hostname.replace(/^www\./, "");
// Prefer templates whose domain exactly matches the hostname over wildcard/partial matches
const exact = cand.filter((t) => t.domains.some((d) => d.trim().replace(/^www\./, "") === h));
const pool = exact.length ? exact : cand;
// Check query hints first
for (const [rx, hint] of QUERY_HINTS) if (rx.test(q)) { const f = pool.find((t) => t.page === hint); if (f) return { template: f, matchType: "exact" }; }
// Then check path hints
for (const [seg, hint] of PATH_HINTS) if (p.includes(seg)) { const f = pool.find((t) => t.page === hint); if (f) return { template: f, matchType: "exact" }; }
// Single-segment path with no slash after first char => likely a profile/username URL
if (/^\/[^\/]+$/.test(p) && pool.find((t) => t.page === "profile")) {
return { template: pool.find((t) => t.page === "profile"), matchType: "exact" };
}
// No path hint matched => fallback to home/feed/etc
const fallback = pool.find((t) => ["index", "home", "feed", "map", "browse", "profile"].includes(t.page)) || pool[0];
return { template: fallback, matchType: "fallback" };
} catch {}
// Fallback to any home/feed template
const fallback = cand.find((t) => ["index", "home", "feed", "browse"].includes(t.page)) || cand[0];
return { template: fallback, matchType: "fallback" };
}
/** Find best template for a URL. Universe > global > official. */
export function findTemplate(url, universe) {
return findTemplateEx(url, universe).template;
}
export function extractSlots(html) {
const out = new Set();
for (const m of html.matchAll(/<!--\s*SLOT:\s*([\w-]+)\s*-->/g)) out.add(m[1]);
return [...out];
}
export function fillSlots(html, values) {
return html.replace(/<!--\s*SLOT:\s*([\w-]+)\s*-->/g, (_, n) => {
if (!values || values[n] == null) return "";
const v = values[n];
// Arrays: auto-generate component tags using key→attr + value→text mapping
if (Array.isArray(v)) {
// Try to infer the component name from the slot name
const slotToComponent = { results: "search_result", threads: "x-thread", posts: "post_card", comments: "comment", videos: "video_card" };
const compName = slotToComponent[n] || n.replace(/s$/, "");
// For related-searches, format as search links
if (n === "related-searches") {
return v.map(item => {
const q = typeof item === "string" ? item : (item.query || item.text || String(item));
return `<a href="/search?q=${encodeURIComponent(q)}">${q}</a>`;
}).join(" · ");
}
return v.map(item => {
if (typeof item === "object" && item !== null) {
let attrs = "";
let children = "";
for (const [k, val] of Object.entries(item)) {
if (k === "children" || k === "body" || k === "text" || k === "content" || k === "description" || k === "snippet") {
children = String(val ?? "");
} else {
attrs += ` ${k}="${String(val ?? "").replace(/"/g, """)}"`;
}
}
if (!children && item.snippet) children = item.snippet;
return children ? `<${compName}${attrs}>${children}</${compName}>` : `<${compName}${attrs}/>`;
}
return String(item);
}).join("\n");
}
return String(v);
});
}
/** Save a new template. If universe is "global" or not set, goes to global/.
* Otherwise goes to universes/<universe>/<site>/.
* Also saves theme.css and components if provided. */
export async function saveUniverseTemplate(universe, site, page, name, domains, tags, description, html, extra) {
if (!html || !html.trim()) throw new Error("empty html");
const scope = (universe && universe !== "global") ? universe : "global";
const base = scope === "global" ? join(GLOBAL_DIR, site) : join(UNIVERSE_DIR, scope, site);
await mkdir(base, { recursive: true });
const meta = `<!--\n@websim-template\nname: ${name}\npage: ${page}\ndomains: ${domains.join(", ")}\ntags: ${tags.join(", ")}\ndescription: ${description}\n-->\n`;
await writeFile(join(base, `${page}.html`), meta + html, "utf8");
// Save optional theme.css and components
if (extra?.themeCss?.trim()) {
await writeFile(join(base, "theme.css"), extra.themeCss, "utf8");
}
if (extra?.components && typeof extra.components === "object") {
const compDir = join(base, "components");
await mkdir(compDir, { recursive: true });
for (const [name, snippet] of Object.entries(extra.components)) {
if (snippet && snippet.trim()) await writeFile(join(compDir, `${name}.html`), snippet, "utf8");
}
}
await loadTemplates();
return `${site}/${page}`;
}