nowebcli 0.2.10 → 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
 
@@ -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.10",
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",
@@ -45,6 +46,7 @@
45
46
  "dependencies": {
46
47
  "@aws-sdk/client-s3": "^3.1055.0",
47
48
  "@heroicons/react": "^2.2.0",
49
+ "@lucasmarkes/hairline": "^0.3.0",
48
50
  "@monaco-editor/react": "^4.7.0",
49
51
  "@polar-sh/better-auth": "^1.8.4",
50
52
  "@polar-sh/nextjs": "^0.9.6",
package/scripts/noweb.mjs CHANGED
@@ -33,6 +33,7 @@ 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
@@ -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
  }
@@ -406,41 +470,45 @@ async function publish(value) {
406
470
  const sql = postgres(databaseUrl, { connect_timeout: 10, max: 1 });
407
471
 
408
472
  try {
409
- for (const template of templates) {
410
- await uploadTemplateAssets(template, storageApi.createObjectStorageClient(storage), storage.bucketName);
411
- const [published] = await sql`
412
- INSERT INTO templates (
413
- slug, type, component, compiled_js, compiled_css, customizations, author_id, is_published
414
- ) VALUES (
415
- ${template.slug}, ${template.type}, ${template.source}, ${template.js},
416
- ${template.css}, ${sql.json(template.customizations)}, 'github', true
417
- )
418
- ON CONFLICT (slug) DO UPDATE SET
419
- type = EXCLUDED.type,
420
- component = EXCLUDED.component,
421
- compiled_js = EXCLUDED.compiled_js,
422
- compiled_css = EXCLUDED.compiled_css,
423
- customizations = EXCLUDED.customizations,
424
- is_published = true
425
- WHERE templates.type IS DISTINCT FROM EXCLUDED.type
426
- OR templates.component IS DISTINCT FROM EXCLUDED.component
427
- OR templates.compiled_js IS DISTINCT FROM EXCLUDED.compiled_js
428
- OR templates.compiled_css IS DISTINCT FROM EXCLUDED.compiled_css
429
- OR templates.customizations::jsonb IS DISTINCT FROM EXCLUDED.customizations::jsonb
430
- OR templates.is_published IS DISTINCT FROM true
431
- RETURNING id
432
- `;
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
+ });
433
506
  if (!published) {
434
- console.log(`Unchanged ${template.slug}`);
507
+ console.log(`Unchanged ${original.slug}`);
435
508
  continue;
436
509
  }
437
- const screenshot = spawnSync(
438
- process.execPath,
439
- ["--import", "tsx", "scripts/generate-template-screenshots.ts", String(published.id)],
440
- { cwd: root, env: process.env, stdio: "inherit" },
441
- );
442
- if (screenshot.status !== 0) throw new Error(`Could not generate preview for ${template.slug}.`);
443
- 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}`);
444
512
  }
445
513
  } finally {
446
514
  await sql.end({ timeout: 5 });
@@ -527,18 +595,21 @@ function resolveAssetValue(value, assetBase) {
527
595
  return value;
528
596
  }
529
597
 
530
- async function uploadTemplateAssets(template, client, bucketName) {
531
- if (!template.assetFiles.length) return;
598
+ async function uploadTemplateAssets(template, client, bucketName, templateId) {
532
599
  const { PutObjectCommand } = await import("@aws-sdk/client-s3");
533
600
  for (const file of template.assetFiles) {
534
601
  await client.send(new PutObjectCommand({
535
602
  Bucket: bucketName,
536
- Key: `template-assets/${template.slug}/${file.path}`,
603
+ Key: `templates/${templateId}/${file.path}`,
537
604
  Body: file.body,
538
605
  CacheControl: "public, max-age=0, must-revalidate",
539
606
  ContentType: mimeTypes[extname(file.path).toLowerCase()] || "application/octet-stream",
540
607
  }));
541
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
+ }));
542
613
  }
543
614
 
544
615
  function previewHtml(template) {
@@ -657,6 +728,7 @@ try {
657
728
  else if (command === "setup") await setupWorkspace(shouldInstall);
658
729
  else if (command === "check") await (slug && engineRoot === root ? checkRepositoryTemplates(slug) : checkProject());
659
730
  else if (command === "dev") await dev();
731
+ else if (command === "cover") await createCover();
660
732
  else if (command === "login") await login();
661
733
  else if (command === "list") await listTemplates();
662
734
  else if (command === "download") await downloadTemplate(slug);