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.
@@ -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.9",
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 publish this template directly to Noweb
40
+ upload save this template without publishing
40
41
  delete [slug] delete one of your templates
41
- publish [slug] publish validated templates (deployment only)`);
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 template of templates) {
396
- await uploadTemplateAssets(template, storageApi.createObjectStorageClient(storage), storage.bucketName);
397
- const [published] = await sql`
398
- INSERT INTO templates (
399
- slug, type, component, compiled_js, compiled_css, customizations, author_id
400
- ) VALUES (
401
- ${template.slug}, ${template.type}, ${template.source}, ${template.js},
402
- ${template.css}, ${sql.json(template.customizations)}, 'github'
403
- )
404
- ON CONFLICT (slug) DO UPDATE SET
405
- type = EXCLUDED.type,
406
- component = EXCLUDED.component,
407
- compiled_js = EXCLUDED.compiled_js,
408
- compiled_css = EXCLUDED.compiled_css,
409
- customizations = EXCLUDED.customizations
410
- WHERE templates.type IS DISTINCT FROM EXCLUDED.type
411
- OR templates.component IS DISTINCT FROM EXCLUDED.component
412
- OR templates.compiled_js IS DISTINCT FROM EXCLUDED.compiled_js
413
- OR templates.compiled_css IS DISTINCT FROM EXCLUDED.compiled_css
414
- OR templates.customizations::jsonb IS DISTINCT FROM EXCLUDED.customizations::jsonb
415
- RETURNING id
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 ${template.slug}`);
507
+ console.log(`Unchanged ${original.slug}`);
419
508
  continue;
420
509
  }
421
- const screenshot = spawnSync(
422
- process.execPath,
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: `template-assets/${template.slug}/${file.path}`,
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 = window.location.hostname === "storage.noweb.app"
55
- ? "https://noweb.app/fonts/templates/fonts.css"
56
- : "/fonts/templates/fonts.css";
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({