nowebcli 0.2.9 → 0.2.11
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/lib/customizations.ts +2 -14
- package/lib/template-cover.ts +25 -0
- package/package.json +3 -4
- package/scripts/noweb.mjs +128 -40
- package/templates/templateRuntime.tsx +10 -3
package/lib/customizations.ts
CHANGED
|
@@ -2,6 +2,7 @@ export type ScalarType =
|
|
|
2
2
|
| "text"
|
|
3
3
|
| "markdown"
|
|
4
4
|
| "image"
|
|
5
|
+
| "file"
|
|
5
6
|
| "icon"
|
|
6
7
|
| "color"
|
|
7
8
|
| "number"
|
|
@@ -37,7 +38,7 @@ export type TemplateCustomization = FieldOptions & {
|
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
const SCALAR_TYPES: ScalarType[] = [
|
|
40
|
-
"text", "markdown", "image", "icon", "color",
|
|
41
|
+
"text", "markdown", "image", "file", "icon", "color",
|
|
41
42
|
"number", "range", "boolean", "select",
|
|
42
43
|
];
|
|
43
44
|
|
|
@@ -67,19 +68,6 @@ export function normalizeCustomizationValues(value: unknown): CustomizationValue
|
|
|
67
68
|
: null;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
export function restoreCustomizationValues(
|
|
71
|
-
current: CustomizationValues,
|
|
72
|
-
previous: CustomizationValues,
|
|
73
|
-
fieldIds: string[],
|
|
74
|
-
) {
|
|
75
|
-
const restored = { ...current };
|
|
76
|
-
for (const fieldId of fieldIds) {
|
|
77
|
-
if (Object.hasOwn(previous, fieldId)) restored[fieldId] = previous[fieldId];
|
|
78
|
-
else delete restored[fieldId];
|
|
79
|
-
}
|
|
80
|
-
return restored;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
71
|
export function normalizeCustomizationValue(
|
|
84
72
|
field: TemplateCustomization,
|
|
85
73
|
value: unknown,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function validateTemplateCover(bytes: Uint8Array) {
|
|
2
|
+
if (bytes.length > 5 * 1024 * 1024) throw new Error("public/cover.jpg must be at most 5 MB.");
|
|
3
|
+
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error("public/cover.jpg must be a valid JPEG.");
|
|
4
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
5
|
+
let offset = 2;
|
|
6
|
+
while (offset + 4 <= bytes.length) {
|
|
7
|
+
if (bytes[offset++] !== 0xff) break;
|
|
8
|
+
while (bytes[offset] === 0xff) offset++;
|
|
9
|
+
const marker = bytes[offset++];
|
|
10
|
+
if (marker === 0xda || marker === 0xd9 || offset + 2 > bytes.length) break;
|
|
11
|
+
const length = view.getUint16(offset);
|
|
12
|
+
if (length < 2 || offset + length > bytes.length) break;
|
|
13
|
+
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
|
|
14
|
+
if (length < 8) break;
|
|
15
|
+
const height = view.getUint16(offset + 3);
|
|
16
|
+
const width = view.getUint16(offset + 5);
|
|
17
|
+
if (!width || !height || width * 9 !== height * 16) {
|
|
18
|
+
throw new Error(`public/cover.jpg must have a 16:9 aspect ratio (received ${width}×${height}).`);
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
offset += length;
|
|
23
|
+
}
|
|
24
|
+
throw new Error("public/cover.jpg must be a valid JPEG with readable dimensions.");
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nowebcli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Create, preview and publish community templates for Noweb",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"lib/customizations.ts",
|
|
12
|
+
"lib/template-cover.ts",
|
|
12
13
|
"public/fonts/templates",
|
|
13
14
|
"scripts/noweb.mjs",
|
|
14
15
|
"templates/compileTailwind.ts",
|
|
@@ -43,10 +44,9 @@
|
|
|
43
44
|
"ci": "npm audit --omit=dev --audit-level=high && npm run typecheck && npm run lint && npm run test && npm run build && npm run test:e2e"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@ai-sdk/openai": "^4.0.24",
|
|
47
|
-
"@ai-sdk/react": "^4.0.45",
|
|
48
47
|
"@aws-sdk/client-s3": "^3.1055.0",
|
|
49
48
|
"@heroicons/react": "^2.2.0",
|
|
49
|
+
"@lucasmarkes/hairline": "^0.3.0",
|
|
50
50
|
"@monaco-editor/react": "^4.7.0",
|
|
51
51
|
"@polar-sh/better-auth": "^1.8.4",
|
|
52
52
|
"@polar-sh/nextjs": "^0.9.6",
|
|
@@ -57,7 +57,6 @@
|
|
|
57
57
|
"@tailwindcss/postcss": "^4",
|
|
58
58
|
"@types/pg": "^8.20.0",
|
|
59
59
|
"@uiw/react-textarea-code-editor": "^3.1.1",
|
|
60
|
-
"ai": "^7.0.42",
|
|
61
60
|
"better-auth": "1.7.2",
|
|
62
61
|
"clsx": "^2.1.1",
|
|
63
62
|
"esbuild": "^0.28.1",
|
package/scripts/noweb.mjs
CHANGED
|
@@ -33,12 +33,13 @@ function usage() {
|
|
|
33
33
|
setup install local React and TypeScript dependencies
|
|
34
34
|
dev preview at http://127.0.0.1:4173
|
|
35
35
|
check validate and compile this template
|
|
36
|
+
cover create public/cover.jpg locally (1920×1080; --force to replace)
|
|
36
37
|
login connect the CLI to your Noweb account
|
|
37
38
|
list list your uploaded templates
|
|
38
39
|
download <slug> restore one into the current folder
|
|
39
|
-
upload
|
|
40
|
+
upload save this template without publishing
|
|
40
41
|
delete [slug] delete one of your templates
|
|
41
|
-
publish
|
|
42
|
+
publish make the uploaded template public`);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
async function createTemplate(value) {
|
|
@@ -57,7 +58,7 @@ async function createTemplate(value) {
|
|
|
57
58
|
writeFile(join(directory, "public/.gitkeep"), ""),
|
|
58
59
|
]);
|
|
59
60
|
await setupWorkspace(shouldInstall);
|
|
60
|
-
console.log(`Created ${validSlug} in ${directory}
|
|
61
|
+
console.log(`Created ${validSlug} in ${directory}. Add a 16:9 public/cover.jpg (JPEG, up to 5 MB).`);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
async function setupWorkspace(install = true) {
|
|
@@ -94,7 +95,7 @@ async function checkProject(quiet = false) {
|
|
|
94
95
|
return result;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
async function buildTemplate(directory, slugOverride, assetBase = "/assets") {
|
|
98
|
+
async function buildTemplate(directory, slugOverride, assetBase = "/assets", requireCover = true) {
|
|
98
99
|
const [metadataSource, source, customizationsSource] = await Promise.all([
|
|
99
100
|
readFile(join(directory, "template.json"), "utf8"),
|
|
100
101
|
readFile(join(directory, "Template.tsx"), "utf8"),
|
|
@@ -120,12 +121,74 @@ async function buildTemplate(directory, slugOverride, assetBase = "/assets") {
|
|
|
120
121
|
for (const match of source.matchAll(/\bcustom\(\s*(\d+)\s*\)/g)) {
|
|
121
122
|
if (!ids.includes(Number(match[1]))) throw new Error(`${currentSlug}: custom(${match[1]}) has no customization.`);
|
|
122
123
|
}
|
|
124
|
+
const coverPath = join(directory, "public", "cover.jpg");
|
|
125
|
+
let cover = null;
|
|
126
|
+
if (requireCover) {
|
|
127
|
+
if (!existsSync(coverPath)) throw new Error("Add a required 16:9 public/cover.jpg before uploading (run `noweb cover`).");
|
|
128
|
+
cover = await readFile(coverPath);
|
|
129
|
+
const { validateTemplateCover } = await load("lib/template-cover.ts");
|
|
130
|
+
validateTemplateCover(cover);
|
|
131
|
+
}
|
|
123
132
|
const assetFiles = await findAssetFiles(join(directory, "public"));
|
|
124
133
|
const assetHash = createHash("sha256");
|
|
125
134
|
for (const file of assetFiles) assetHash.update(file.path).update(file.body);
|
|
126
135
|
const { js, css } = await compileTemplateAssets(source, customizations, assetBase);
|
|
127
136
|
const versionedJs = assetFiles.length ? `${js}\n/* template-assets:${assetHash.digest("hex")} */` : js;
|
|
128
|
-
return { ...metadata, assetFiles, css, customizations, fields: customizations.length, js: versionedJs, slug: currentSlug, source };
|
|
137
|
+
return { ...metadata, assetFiles, cover, css, customizations, fields: customizations.length, js: versionedJs, slug: currentSlug, source };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function createCover() {
|
|
141
|
+
const coverPath = join(root, "public", "cover.jpg");
|
|
142
|
+
const force = cliArgs.includes("--force");
|
|
143
|
+
if (!force && existsSync(coverPath)) throw new Error("public/cover.jpg already exists. Use `noweb cover --force` to replace it.");
|
|
144
|
+
const template = await buildTemplate(root, undefined, "/assets", false);
|
|
145
|
+
const server = createServer(async (request, response) => {
|
|
146
|
+
try {
|
|
147
|
+
const pathname = new URL(request.url || "/", "http://127.0.0.1").pathname;
|
|
148
|
+
if (pathname === "/") {
|
|
149
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
150
|
+
response.end(previewHtml(template));
|
|
151
|
+
} else {
|
|
152
|
+
await servePublic(pathname, response);
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
response.writeHead(500).end(error instanceof Error ? error.message : String(error));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const { chromium } = await import("playwright");
|
|
159
|
+
let browser;
|
|
160
|
+
try {
|
|
161
|
+
await new Promise((resolve, reject) => {
|
|
162
|
+
server.once("error", reject);
|
|
163
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
164
|
+
});
|
|
165
|
+
browser = await chromium.launch({ headless: true, args: ["--enable-unsafe-swiftshader", "--use-angle=swiftshader"] });
|
|
166
|
+
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
|
|
167
|
+
const errors = [];
|
|
168
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
169
|
+
page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); });
|
|
170
|
+
await page.goto(`http://127.0.0.1:${server.address().port}`, { waitUntil: "load" });
|
|
171
|
+
await page.waitForFunction(() => document.getElementById("root")?.childElementCount, undefined, { timeout: 15_000 }).catch(() => {
|
|
172
|
+
throw new Error(`Template did not render: ${errors.slice(0, 3).join("; ") || "empty preview"}`);
|
|
173
|
+
});
|
|
174
|
+
await page.evaluate(async () => {
|
|
175
|
+
await Promise.race([
|
|
176
|
+
Promise.all([document.fonts.ready, ...Array.from(document.images, (image) => image.decode().catch(() => undefined))]),
|
|
177
|
+
new Promise((resolve) => setTimeout(resolve, 10_000)),
|
|
178
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
await page.waitForTimeout(5000);
|
|
181
|
+
const bytes = await page.screenshot({ type: "jpeg", quality: 90, fullPage: false });
|
|
182
|
+
const { validateTemplateCover } = await load("lib/template-cover.ts");
|
|
183
|
+
validateTemplateCover(bytes);
|
|
184
|
+
await mkdir(join(root, "public"), { recursive: true });
|
|
185
|
+
await writeFile(coverPath, bytes, { flag: force ? "w" : "wx" });
|
|
186
|
+
console.log("Created public/cover.jpg (1920×1080, 16:9). Check the image before uploading.");
|
|
187
|
+
} finally {
|
|
188
|
+
await browser?.close();
|
|
189
|
+
server.closeAllConnections();
|
|
190
|
+
await new Promise((resolve) => server.close(resolve));
|
|
191
|
+
}
|
|
129
192
|
}
|
|
130
193
|
|
|
131
194
|
async function dev() {
|
|
@@ -172,6 +235,7 @@ async function upload() {
|
|
|
172
235
|
form.set("source", template.source);
|
|
173
236
|
form.set("customizations", JSON.stringify(template.customizations));
|
|
174
237
|
form.set("assetPaths", JSON.stringify(template.assetFiles.map((file) => file.path)));
|
|
238
|
+
form.set("cover", new Blob([template.cover], { type: "image/jpeg" }), "cover.jpg");
|
|
175
239
|
for (const file of template.assetFiles) {
|
|
176
240
|
form.append("assets", new Blob([file.body]), basename(file.path));
|
|
177
241
|
}
|
|
@@ -184,6 +248,20 @@ async function upload() {
|
|
|
184
248
|
if (!response.ok) throw new Error(result.error || `Upload failed (${response.status}).`);
|
|
185
249
|
if (!Number.isInteger(result.id) || result.id < 1) throw new Error("Upload returned an invalid template ID.");
|
|
186
250
|
await writeFile(join(root, "template.json"), `${JSON.stringify({ id: result.id, slug: template.slug, type: template.type }, null, 2)}\n`);
|
|
251
|
+
console.log(`Uploaded ${template.slug} (unpublished): ${new URL(result.url, apiBase)}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function publishUploadedTemplate() {
|
|
255
|
+
const template = JSON.parse(await readFile(join(root, "template.json"), "utf8"));
|
|
256
|
+
if (!Number.isInteger(template.id) || template.id < 1) throw new Error("Upload this template first: npx nowebcli upload");
|
|
257
|
+
const credentials = await requireCredentials();
|
|
258
|
+
const response = await fetch(`${apiBase}/api/templates/visibility`, {
|
|
259
|
+
method: "PATCH",
|
|
260
|
+
headers: { Authorization: `Bearer ${credentials.token}`, "Content-Type": "application/json" },
|
|
261
|
+
body: JSON.stringify({ id: template.id, published: true }),
|
|
262
|
+
});
|
|
263
|
+
const result = await response.json().catch(() => ({}));
|
|
264
|
+
if (!response.ok) throw new Error(result.error || `Publish failed (${response.status}).`);
|
|
187
265
|
console.log(`Published ${template.slug}: ${new URL(result.url, apiBase)}`);
|
|
188
266
|
}
|
|
189
267
|
|
|
@@ -226,7 +304,7 @@ async function listTemplates() {
|
|
|
226
304
|
}
|
|
227
305
|
if (templates.length) {
|
|
228
306
|
console.log("Templates");
|
|
229
|
-
for (const template of templates) console.log(template.slug || template.id);
|
|
307
|
+
for (const template of templates) console.log(`${template.slug || template.id}\t${template.is_published ? "public" : "unpublished"}`);
|
|
230
308
|
}
|
|
231
309
|
if (projects.length) {
|
|
232
310
|
if (templates.length) console.log();
|
|
@@ -392,39 +470,45 @@ async function publish(value) {
|
|
|
392
470
|
const sql = postgres(databaseUrl, { connect_timeout: 10, max: 1 });
|
|
393
471
|
|
|
394
472
|
try {
|
|
395
|
-
for (const
|
|
396
|
-
await
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
473
|
+
for (const original of templates) {
|
|
474
|
+
const published = await sql.begin(async (transaction) => {
|
|
475
|
+
await transaction`SELECT pg_advisory_xact_lock(hashtextextended(${original.slug}, 0))`;
|
|
476
|
+
let [reserved] = await transaction`SELECT id FROM templates WHERE slug = ${original.slug} FOR UPDATE`;
|
|
477
|
+
if (!reserved) {
|
|
478
|
+
[reserved] = await transaction`
|
|
479
|
+
INSERT INTO templates (slug, type, component, compiled_js, compiled_css, customizations, author_id, is_published)
|
|
480
|
+
VALUES (${original.slug}, ${original.type}, ${original.source}, '', '', ${transaction.json(original.customizations)}, 'github', false) RETURNING id
|
|
481
|
+
`;
|
|
482
|
+
}
|
|
483
|
+
const { rewriteProjectAssetReferences } = await load("lib/project-template-snapshot.ts");
|
|
484
|
+
const template = { ...original, ...rewriteProjectAssetReferences(
|
|
485
|
+
{ js: original.js, css: original.css, source: original.source, customizations: original.customizations },
|
|
486
|
+
new Map([[`${publicUrl}/template-assets/${original.slug}`, `${publicUrl}/templates/${reserved.id}`]]),
|
|
487
|
+
) };
|
|
488
|
+
await uploadTemplateAssets(template, storageApi.createObjectStorageClient(storage), storage.bucketName, reserved.id);
|
|
489
|
+
const [updated] = await transaction`
|
|
490
|
+
UPDATE templates SET
|
|
491
|
+
type = ${template.type}, component = ${template.source},
|
|
492
|
+
compiled_js = ${template.js}, compiled_css = ${template.css},
|
|
493
|
+
customizations = ${transaction.json(template.customizations)}, is_published = true
|
|
494
|
+
WHERE id = ${reserved.id} AND (
|
|
495
|
+
type IS DISTINCT FROM ${template.type}
|
|
496
|
+
OR component IS DISTINCT FROM ${template.source}
|
|
497
|
+
OR compiled_js IS DISTINCT FROM ${template.js}
|
|
498
|
+
OR compiled_css IS DISTINCT FROM ${template.css}
|
|
499
|
+
OR customizations::jsonb IS DISTINCT FROM ${transaction.json(template.customizations)}::jsonb
|
|
500
|
+
OR is_published IS DISTINCT FROM true
|
|
501
|
+
)
|
|
502
|
+
RETURNING id
|
|
503
|
+
`;
|
|
504
|
+
return updated;
|
|
505
|
+
});
|
|
417
506
|
if (!published) {
|
|
418
|
-
console.log(`Unchanged ${
|
|
507
|
+
console.log(`Unchanged ${original.slug}`);
|
|
419
508
|
continue;
|
|
420
509
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
["--import", "tsx", "scripts/generate-template-screenshots.ts", String(published.id)],
|
|
424
|
-
{ cwd: root, env: process.env, stdio: "inherit" },
|
|
425
|
-
);
|
|
426
|
-
if (screenshot.status !== 0) throw new Error(`Could not generate preview for ${template.slug}.`);
|
|
427
|
-
console.log(`Published ${template.slug} as template ${published.id}`);
|
|
510
|
+
await (await load("lib/template-screenshot.ts")).generateTemplatePreview(Number(published.id));
|
|
511
|
+
console.log(`Published ${original.slug} as template ${published.id}`);
|
|
428
512
|
}
|
|
429
513
|
} finally {
|
|
430
514
|
await sql.end({ timeout: 5 });
|
|
@@ -511,18 +595,21 @@ function resolveAssetValue(value, assetBase) {
|
|
|
511
595
|
return value;
|
|
512
596
|
}
|
|
513
597
|
|
|
514
|
-
async function uploadTemplateAssets(template, client, bucketName) {
|
|
515
|
-
if (!template.assetFiles.length) return;
|
|
598
|
+
async function uploadTemplateAssets(template, client, bucketName, templateId) {
|
|
516
599
|
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
|
517
600
|
for (const file of template.assetFiles) {
|
|
518
601
|
await client.send(new PutObjectCommand({
|
|
519
602
|
Bucket: bucketName,
|
|
520
|
-
Key: `
|
|
603
|
+
Key: `templates/${templateId}/${file.path}`,
|
|
521
604
|
Body: file.body,
|
|
522
605
|
CacheControl: "public, max-age=0, must-revalidate",
|
|
523
606
|
ContentType: mimeTypes[extname(file.path).toLowerCase()] || "application/octet-stream",
|
|
524
607
|
}));
|
|
525
608
|
}
|
|
609
|
+
await client.send(new PutObjectCommand({
|
|
610
|
+
Bucket: bucketName, Key: `template-previews/${templateId}.jpg`, Body: template.cover,
|
|
611
|
+
CacheControl: "public, max-age=0, must-revalidate", ContentType: "image/jpeg",
|
|
612
|
+
}));
|
|
526
613
|
}
|
|
527
614
|
|
|
528
615
|
function previewHtml(template) {
|
|
@@ -641,12 +728,13 @@ try {
|
|
|
641
728
|
else if (command === "setup") await setupWorkspace(shouldInstall);
|
|
642
729
|
else if (command === "check") await (slug && engineRoot === root ? checkRepositoryTemplates(slug) : checkProject());
|
|
643
730
|
else if (command === "dev") await dev();
|
|
731
|
+
else if (command === "cover") await createCover();
|
|
644
732
|
else if (command === "login") await login();
|
|
645
733
|
else if (command === "list") await listTemplates();
|
|
646
734
|
else if (command === "download") await downloadTemplate(slug);
|
|
647
735
|
else if (command === "upload") await upload();
|
|
648
736
|
else if (command === "delete") await deleteTemplate(slug);
|
|
649
|
-
else if (command === "publish") await publish(slug);
|
|
737
|
+
else if (command === "publish") await (engineRoot === root ? publish(slug) : publishUploadedTemplate());
|
|
650
738
|
else if (command === "help" || command === "--help" || command === "-h") usage();
|
|
651
739
|
else throw new Error(`Unknown command: ${command}`);
|
|
652
740
|
} catch (error) {
|
|
@@ -51,14 +51,21 @@ function loadFont(family: string, fallback: string) {
|
|
|
51
51
|
const stylesheet = document.createElement("link");
|
|
52
52
|
stylesheet.id = "noweb-template-fonts";
|
|
53
53
|
stylesheet.rel = "stylesheet";
|
|
54
|
-
stylesheet.href =
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
stylesheet.href = templateFontStylesheetHref(
|
|
55
|
+
currentTemplateRuntime.dataApiBase,
|
|
56
|
+
window.location.hostname,
|
|
57
|
+
);
|
|
57
58
|
document.head.append(stylesheet);
|
|
58
59
|
}
|
|
59
60
|
return `"${family}", ${fallback}`;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
export function templateFontStylesheetHref(appBase: string | undefined, hostname: string) {
|
|
64
|
+
const base = appBase?.replace(/\/$/, "")
|
|
65
|
+
|| (hostname === "storage.noweb.app" ? "https://noweb.app" : "");
|
|
66
|
+
return `${base}/fonts/templates/fonts.css`;
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
export const font = fontStyles;
|
|
63
70
|
|
|
64
71
|
export function TemplateRuntimeProvider({
|