@techninja/clearstack 0.3.42 → 0.3.44
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/bin/cli.js +11 -1
- package/lib/build-modulepreload.js +105 -0
- package/lib/build-og-images.js +11 -4
- package/lib/build-sitemap.js +101 -0
- package/lib/og-image-template.js +15 -1
- package/package.json +1 -1
- package/templates/shared/src/index.html +17 -0
package/bin/cli.js
CHANGED
|
@@ -68,7 +68,13 @@ async function run(action) {
|
|
|
68
68
|
report(process.cwd(), { json: !!flags.json });
|
|
69
69
|
} else if (action === 'build') {
|
|
70
70
|
const sub = args.find((a) => a !== 'build' && !a.startsWith('-'));
|
|
71
|
-
if (sub === '
|
|
71
|
+
if (sub === 'sitemap') {
|
|
72
|
+
const { buildSitemap } = await import('../lib/build-sitemap.js');
|
|
73
|
+
buildSitemap({ projectDir: process.cwd(), outDir: flags.out || 'dist', baseUrl: flags.url || '' });
|
|
74
|
+
} else if (sub === 'modulepreload' || sub === 'preload') {
|
|
75
|
+
const { buildModulePreload } = await import('../lib/build-modulepreload.js');
|
|
76
|
+
buildModulePreload({ projectDir: process.cwd(), outDir: flags.out || 'dist' });
|
|
77
|
+
} else if (sub === 'og-images' || sub === 'images') {
|
|
72
78
|
const mod = await import('../lib/build-og-images.js');
|
|
73
79
|
const common = { projectDir: process.cwd(), outDir: flags.out || 'dist', logo: flags.logo || '', siteName: flags.site || '' };
|
|
74
80
|
if (flags.slug) await mod.buildOneOGImage({ ...common, slug: flags.slug });
|
|
@@ -80,7 +86,11 @@ async function run(action) {
|
|
|
80
86
|
outDir: flags.out || 'dist',
|
|
81
87
|
baseUrl: flags.url || '',
|
|
82
88
|
});
|
|
89
|
+
const { buildSitemap } = await import('../lib/build-sitemap.js');
|
|
90
|
+
buildSitemap({ projectDir: process.cwd(), outDir: flags.out || 'dist', baseUrl: flags.url || '' });
|
|
83
91
|
if (sub === 'all') {
|
|
92
|
+
const { buildModulePreload } = await import('../lib/build-modulepreload.js');
|
|
93
|
+
buildModulePreload({ projectDir: process.cwd(), outDir: flags.out || 'dist' });
|
|
84
94
|
const { buildOGImages } = await import('../lib/build-og-images.js');
|
|
85
95
|
await buildOGImages({
|
|
86
96
|
projectDir: process.cwd(),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module preload builder — statically crawls ES module imports and injects
|
|
3
|
+
* <link rel="modulepreload"> tags into index.html so the browser fetches all
|
|
4
|
+
* modules in parallel instead of chaining waterfall requests.
|
|
5
|
+
* @module lib/build-modulepreload
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { resolve, dirname, relative, extname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} srcDir - The src/ directory to crawl
|
|
13
|
+
* @param {string} entryFile - Relative path to entry (e.g. 'router/index.js')
|
|
14
|
+
* @param {string[]} ignoreDirs - Dirs to skip (e.g. ['vendor', 'deps'])
|
|
15
|
+
* @returns {string[]} Ordered list of module paths (entry last)
|
|
16
|
+
*/
|
|
17
|
+
export function crawlModules(srcDir, entryFile, ignoreDirs = ['vendor', 'deps']) {
|
|
18
|
+
const visited = new Set();
|
|
19
|
+
const order = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
*
|
|
23
|
+
*/
|
|
24
|
+
function crawl(relPath) {
|
|
25
|
+
if (visited.has(relPath)) return;
|
|
26
|
+
visited.add(relPath);
|
|
27
|
+
if (ignoreDirs.some((d) => relPath.includes(`/${d}/`) || relPath.startsWith(`${d}/`))) return;
|
|
28
|
+
|
|
29
|
+
const absPath = resolve(srcDir, relPath);
|
|
30
|
+
if (!existsSync(absPath) || extname(absPath) !== '.js') return;
|
|
31
|
+
|
|
32
|
+
const src = readFileSync(absPath, 'utf-8');
|
|
33
|
+
const importRe = /(?:^|\n)\s*import\s[^'"]*['"]([^'"]+)['"]/g;
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = importRe.exec(src)) !== null) {
|
|
36
|
+
const spec = m[1];
|
|
37
|
+
if (spec.startsWith('#')) {
|
|
38
|
+
// Import map alias — resolve via known prefixes
|
|
39
|
+
const mapped = resolveAlias(spec, relPath);
|
|
40
|
+
if (mapped) crawl(mapped);
|
|
41
|
+
} else if (spec.startsWith('./') || spec.startsWith('../')) {
|
|
42
|
+
const abs = resolve(dirname(resolve(srcDir, relPath)), spec);
|
|
43
|
+
const rel = relative(srcDir, abs.endsWith('.js') ? abs : abs + '.js');
|
|
44
|
+
crawl(rel);
|
|
45
|
+
}
|
|
46
|
+
// Skip bare specifiers (vendor/hybrids handled separately)
|
|
47
|
+
}
|
|
48
|
+
order.push(relPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
crawl(entryFile);
|
|
52
|
+
return order;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve #alias/ import map specifiers to relative paths. */
|
|
56
|
+
function resolveAlias(spec, fromFile) {
|
|
57
|
+
const aliases = {
|
|
58
|
+
'#store/': 'store/',
|
|
59
|
+
'#utils/': 'utils/',
|
|
60
|
+
'#atoms/': 'components/atoms/',
|
|
61
|
+
'#molecules/': 'components/molecules/',
|
|
62
|
+
'#organisms/': 'components/organisms/',
|
|
63
|
+
'#templates/': 'components/templates/',
|
|
64
|
+
'#pages/': 'pages/',
|
|
65
|
+
};
|
|
66
|
+
for (const [prefix, dir] of Object.entries(aliases)) {
|
|
67
|
+
if (spec.startsWith(prefix)) return dir + spec.slice(prefix.length);
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inject modulepreload tags into index.html.
|
|
74
|
+
* @param {{ projectDir: string, srcDir?: string, outDir?: string, entry?: string, ignore?: string[] }} opts
|
|
75
|
+
* @returns {{ modules: number }}
|
|
76
|
+
*/
|
|
77
|
+
export function buildModulePreload(opts) {
|
|
78
|
+
const {
|
|
79
|
+
projectDir,
|
|
80
|
+
srcDir = resolve(projectDir, 'src'),
|
|
81
|
+
outDir = resolve(projectDir, 'dist'),
|
|
82
|
+
entry = 'router/index.js',
|
|
83
|
+
ignore = ['vendor', 'deps'],
|
|
84
|
+
} = opts;
|
|
85
|
+
|
|
86
|
+
const indexPath = resolve(outDir, 'index.html');
|
|
87
|
+
if (!existsSync(indexPath)) {
|
|
88
|
+
console.log('⚠ dist/index.html not found — run build first');
|
|
89
|
+
return { modules: 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const modules = crawlModules(srcDir, entry, ignore);
|
|
93
|
+
const tags = modules.map((m) => ` <link rel="modulepreload" href="/${m}">`).join('\n');
|
|
94
|
+
|
|
95
|
+
let html = readFileSync(indexPath, 'utf-8');
|
|
96
|
+
if (html.includes('rel="modulepreload"')) {
|
|
97
|
+
// Remove existing preload tags before re-injecting
|
|
98
|
+
html = html.replace(/\s*<link rel="modulepreload"[^>]*>/g, '');
|
|
99
|
+
}
|
|
100
|
+
html = html.replace('</head>', `${tags}\n</head>`);
|
|
101
|
+
writeFileSync(indexPath, html);
|
|
102
|
+
|
|
103
|
+
console.log(`✅ Modulepreload: ${modules.length} modules → dist/index.html`);
|
|
104
|
+
return { modules: modules.length };
|
|
105
|
+
}
|
package/lib/build-og-images.js
CHANGED
|
@@ -17,6 +17,8 @@ import { resolveTemplate, renderTemplate, buildContext, loadTokens, WIDTH, HEIGH
|
|
|
17
17
|
* @property {string} [logo] - Path or URL to logo
|
|
18
18
|
* @property {string} [siteName] - Site name for badge
|
|
19
19
|
* @property {Record<string, unknown>} [context] - Extra interpolation data
|
|
20
|
+
* @property {(slug: string) => boolean} [filter] - Only render matching slugs
|
|
21
|
+
* @property {(slug: string, n: number, total: number) => void} [onProgress]
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -25,7 +27,7 @@ import { resolveTemplate, renderTemplate, buildContext, loadTokens, WIDTH, HEIGH
|
|
|
25
27
|
* @returns {Promise<{ images: number, routes: number }>}
|
|
26
28
|
*/
|
|
27
29
|
export async function buildOGImages(opts) {
|
|
28
|
-
const { projectDir, outDir = 'dist', logo, siteName, context = {} } = opts;
|
|
30
|
+
const { projectDir, outDir = 'dist', logo, siteName, context = {}, filter, onProgress } = opts;
|
|
29
31
|
const routes = loadRoutes(projectDir);
|
|
30
32
|
if (!routes) {
|
|
31
33
|
console.log('⚠ No routes config found. Create clearstack.routes.json');
|
|
@@ -37,7 +39,8 @@ export async function buildOGImages(opts) {
|
|
|
37
39
|
const start = performance.now();
|
|
38
40
|
|
|
39
41
|
const { chromium } = await import('playwright');
|
|
40
|
-
const browser = await chromium.launch();
|
|
42
|
+
const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH });
|
|
43
|
+
console.log(' Browser ready, rendering...');
|
|
41
44
|
const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
|
|
42
45
|
const out = resolve(projectDir, outDir);
|
|
43
46
|
let images = 0;
|
|
@@ -47,6 +50,7 @@ export async function buildOGImages(opts) {
|
|
|
47
50
|
const isDynamic = pattern.includes(':');
|
|
48
51
|
|
|
49
52
|
if (!isDynamic) {
|
|
53
|
+
if (filter) continue;
|
|
50
54
|
const data = { app: { name: siteName }, ...context };
|
|
51
55
|
const ctx = buildContext(config, data, site);
|
|
52
56
|
await screenshot(page, renderTemplate(template, ctx, projectDir), out, pattern);
|
|
@@ -57,11 +61,13 @@ export async function buildOGImages(opts) {
|
|
|
57
61
|
for (const item of items) {
|
|
58
62
|
const slug = item.slug || item.id || item.sku || item[paramName];
|
|
59
63
|
if (!slug) continue;
|
|
64
|
+
if (filter && !filter(String(slug))) continue;
|
|
60
65
|
const path = pattern.replace(`:${paramName}`, String(slug));
|
|
61
66
|
const data = { [paramName]: item, item, app: { name: siteName }, ...context };
|
|
62
67
|
const ctx = buildContext(config, data, site);
|
|
63
68
|
await screenshot(page, renderTemplate(template, ctx, projectDir), out, path);
|
|
64
69
|
images++;
|
|
70
|
+
if (onProgress) onProgress(String(slug), images, -1);
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
}
|
|
@@ -85,7 +91,7 @@ export async function buildOneOGImage(opts) {
|
|
|
85
91
|
const tokens = loadTokens(projectDir);
|
|
86
92
|
const site = { tokens, logo, siteName };
|
|
87
93
|
const { chromium } = await import('playwright');
|
|
88
|
-
const browser = await chromium.launch();
|
|
94
|
+
const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH });
|
|
89
95
|
const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
|
|
90
96
|
const out = resolve(projectDir, outDir);
|
|
91
97
|
let result = '';
|
|
@@ -113,7 +119,8 @@ export async function buildOneOGImage(opts) {
|
|
|
113
119
|
|
|
114
120
|
/** Render HTML content and save screenshot to disk. */
|
|
115
121
|
async function screenshot(page, html, outDir, routePath) {
|
|
116
|
-
await page.setContent(html, { waitUntil: '
|
|
122
|
+
await page.setContent(html, { waitUntil: 'domcontentloaded' });
|
|
123
|
+
await page.waitForTimeout(html.includes('fonts.googleapis') ? 2500 : 800);
|
|
117
124
|
const imgPath = routePath === '/'
|
|
118
125
|
? resolve(outDir, 'og-image.png')
|
|
119
126
|
: resolve(outDir, routePath.slice(1) + '.png');
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap generator — builds sitemap.xml from clearstack.routes.json.
|
|
3
|
+
* Static routes get one URL each; dynamic routes iterate their data source.
|
|
4
|
+
* @module lib/build-sitemap
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { loadRoutes, resolveDataSource } from './build-og.js';
|
|
10
|
+
import { interpolate } from './og-template.js';
|
|
11
|
+
|
|
12
|
+
/** @param {string} s */
|
|
13
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/'/g, ''');
|
|
14
|
+
|
|
15
|
+
/** Default priority by route type. */
|
|
16
|
+
function defaultPriority(pattern) {
|
|
17
|
+
if (pattern === '/') return '1.0';
|
|
18
|
+
if (pattern.includes(':')) return '0.5';
|
|
19
|
+
return '0.7';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default changefreq by route type. */
|
|
23
|
+
function defaultChangefreq(pattern) {
|
|
24
|
+
if (pattern === '/') return 'daily';
|
|
25
|
+
if (pattern.includes(':')) return 'weekly';
|
|
26
|
+
return 'monthly';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} date - ISO date string or empty
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function fmtDate(date) {
|
|
34
|
+
if (!date) return '';
|
|
35
|
+
try { return new Date(date).toISOString().slice(0, 10); } catch { return ''; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build sitemap.xml from route config.
|
|
40
|
+
* @param {{ projectDir: string, outDir?: string, baseUrl: string, context?: Record<string, unknown> }} opts
|
|
41
|
+
* @returns {{ urls: number }}
|
|
42
|
+
*/
|
|
43
|
+
export function buildSitemap(opts) {
|
|
44
|
+
const { projectDir, outDir = 'dist', baseUrl, context = {} } = opts;
|
|
45
|
+
const routes = loadRoutes(projectDir);
|
|
46
|
+
if (!routes) {
|
|
47
|
+
console.log('⚠ No routes config found. Create clearstack.routes.json');
|
|
48
|
+
return { urls: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const urls = [];
|
|
52
|
+
|
|
53
|
+
for (const [pattern, config] of Object.entries(routes)) {
|
|
54
|
+
const sm = config.sitemap || {};
|
|
55
|
+
const priority = sm.priority ?? defaultPriority(pattern);
|
|
56
|
+
const changefreq = sm.changefreq ?? defaultChangefreq(pattern);
|
|
57
|
+
const isDynamic = pattern.includes(':');
|
|
58
|
+
|
|
59
|
+
if (!isDynamic) {
|
|
60
|
+
const lastmod = fmtDate(sm.lastmod || '');
|
|
61
|
+
urls.push(entry(`${baseUrl}${pattern}`, changefreq, String(priority), lastmod));
|
|
62
|
+
} else if (config.data) {
|
|
63
|
+
const items = resolveDataSource(config.data, projectDir);
|
|
64
|
+
const paramName = pattern.match(/:(\w+)/)?.[1] || 'id';
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const slug = item.slug || item.id || item.sku || item[paramName];
|
|
67
|
+
if (!slug) continue;
|
|
68
|
+
const path = pattern.replace(`:${paramName}`, String(slug));
|
|
69
|
+
const data = { [paramName]: item, item, ...context };
|
|
70
|
+
const resolvedPath = interpolate(path, data);
|
|
71
|
+
const lastmod = fmtDate(item.date || item.modified || item.created || '');
|
|
72
|
+
urls.push(entry(`${baseUrl}${resolvedPath}`, changefreq, String(priority), lastmod));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const xml = [
|
|
78
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
79
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
80
|
+
...urls,
|
|
81
|
+
'</urlset>',
|
|
82
|
+
].join('\n');
|
|
83
|
+
|
|
84
|
+
const out = resolve(projectDir, outDir);
|
|
85
|
+
mkdirSync(out, { recursive: true });
|
|
86
|
+
writeFileSync(resolve(out, 'sitemap.xml'), xml);
|
|
87
|
+
console.log(`✅ Sitemap: ${urls.length} URLs → ${outDir}/sitemap.xml`);
|
|
88
|
+
return { urls: urls.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Build a single <url> entry. */
|
|
92
|
+
function entry(loc, changefreq, priority, lastmod) {
|
|
93
|
+
return [
|
|
94
|
+
' <url>',
|
|
95
|
+
` <loc>${esc(loc)}</loc>`,
|
|
96
|
+
lastmod ? ` <lastmod>${lastmod}</lastmod>` : '',
|
|
97
|
+
` <changefreq>${changefreq}</changefreq>`,
|
|
98
|
+
` <priority>${priority}</priority>`,
|
|
99
|
+
' </url>',
|
|
100
|
+
].filter(Boolean).join('\n');
|
|
101
|
+
}
|
package/lib/og-image-template.js
CHANGED
|
@@ -75,6 +75,14 @@ export function buildContext(config, itemData, site) {
|
|
|
75
75
|
const resolvedImage = image && !image.startsWith('http') ? `${baseUrl}${image}` : image;
|
|
76
76
|
const item = itemData.item || {};
|
|
77
77
|
const emoji = item.emoji || itemData.emoji || '';
|
|
78
|
+
const tags = item.tags || itemData.tags || [];
|
|
79
|
+
const tagsHtml = tags.map((t) => `<div class="tag">#${t}</div>`).join('');
|
|
80
|
+
const postTitle = item.title || item.caption || item.name || '';
|
|
81
|
+
const logoSrc = item.logo?.wordmark || item.logo?.mark || '';
|
|
82
|
+
const itemLogoHtml = logoSrc ? `<img src="${logoSrc}" alt="${postTitle}">` : '';
|
|
83
|
+
const dateFormatted = item.date ? new Date(item.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '';
|
|
84
|
+
const mediaUrl = resolvedImage || (item.files?.[0] ? `https://data.tn42.com/assets-media/${item.files[0]}` : '');
|
|
85
|
+
const isVideo = item.type === 'video' || (item.files?.[0] || '').endsWith('.mp4');
|
|
78
86
|
|
|
79
87
|
return {
|
|
80
88
|
...itemData, ...item,
|
|
@@ -87,12 +95,18 @@ export function buildContext(config, itemData, site) {
|
|
|
87
95
|
text: t['color-text'] || '#e2e8f0',
|
|
88
96
|
textMuted: t['color-text-muted'] || '#a8b8cc',
|
|
89
97
|
emoji, image: resolvedImage,
|
|
98
|
+
tagsHtml, postTitle, dateFormatted, mediaUrl,
|
|
99
|
+
logoHtml: itemLogoHtml,
|
|
100
|
+
noLogoMark: itemLogoHtml ? '' : '<div class="mark">42</div>',
|
|
101
|
+
mediaHtml: isVideo
|
|
102
|
+
? `<video src="${mediaUrl}" autoplay muted playsinline style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"></video>`
|
|
103
|
+
: mediaUrl ? `<img src="${mediaUrl}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">` : '',
|
|
90
104
|
variantsFormatted: formatNum(item.expected_variants),
|
|
91
105
|
uniqueFormatted: formatNum(item.estimated_unique_variants),
|
|
92
106
|
priceFormatted: item.price ? `$${(item.price / 100).toFixed(2)}` : '',
|
|
93
107
|
imageHtml: resolvedImage ? `<img class="hero" src="${resolvedImage}">` : '',
|
|
94
108
|
cardClass: resolvedImage ? 'card-with-image' : '',
|
|
95
|
-
|
|
109
|
+
siteLogo: site.logo ? `<img class="logo" src="${site.logo}">` : '',
|
|
96
110
|
badgeHtml: site.siteName ? `<div class="badge">${site.siteName}</div>` : '',
|
|
97
111
|
emojiHtml: emoji ? `<div class="emoji">${emoji}</div>` : '',
|
|
98
112
|
};
|
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>{{name}}</title>
|
|
7
|
+
<!-- FOUC prevention — matches --color-bg in tokens.css -->
|
|
8
|
+
<style>html,body{background:#0f172a;color:#e2e8f0;margin:0}</style>
|
|
7
9
|
<link rel="stylesheet" href="/styles/reset.css" />
|
|
8
10
|
<link rel="stylesheet" href="/styles/tokens.css" />
|
|
9
11
|
<link rel="stylesheet" href="/styles/shared.css" />
|
|
@@ -27,7 +29,22 @@
|
|
|
27
29
|
</script>
|
|
28
30
|
</head>
|
|
29
31
|
<body>
|
|
32
|
+
<noscript>
|
|
33
|
+
<div style="font-family:system-ui;padding:2rem;max-width:40rem;margin:4rem auto;text-align:center">
|
|
34
|
+
<h1>JavaScript required</h1>
|
|
35
|
+
<p>{{name}} requires JavaScript to run. Please enable it in your browser settings.</p>
|
|
36
|
+
</div>
|
|
37
|
+
</noscript>
|
|
30
38
|
<app-router></app-router>
|
|
31
39
|
<script type="module" src="/router/index.js"></script>
|
|
40
|
+
<script>
|
|
41
|
+
// Global error boundary — catches unhandled promise rejections and JS errors
|
|
42
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
43
|
+
console.error('[app] Unhandled rejection:', e.reason);
|
|
44
|
+
});
|
|
45
|
+
window.addEventListener('error', (e) => {
|
|
46
|
+
console.error('[app] Uncaught error:', e.message, e.filename, e.lineno);
|
|
47
|
+
});
|
|
48
|
+
</script>
|
|
32
49
|
</body>
|
|
33
50
|
</html>
|