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.
- package/lib/customizations.ts +2 -1
- package/lib/template-cover.ts +25 -0
- package/package.json +3 -1
- package/scripts/noweb.mjs +110 -38
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
|
|
|
@@ -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",
|
|
@@ -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
|
|
410
|
-
await
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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 ${
|
|
507
|
+
console.log(`Unchanged ${original.slug}`);
|
|
435
508
|
continue;
|
|
436
509
|
}
|
|
437
|
-
|
|
438
|
-
|
|
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: `
|
|
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);
|