nowebcli 0.2.1
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/README.md +188 -0
- package/lib/customizations.ts +252 -0
- package/package.json +103 -0
- package/scripts/noweb.mjs +625 -0
- package/templates/compileTailwind.ts +67 -0
- package/templates/shadersRuntime.tsx +10 -0
- package/templates/templateCompiler.ts +200 -0
- package/templates/templateRuntime.tsx +280 -0
- package/templates/validateTemplateCode.ts +121 -0
- package/tsconfig.json +38 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { access, chmod, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
import { register } from "tsx/esm/api";
|
|
12
|
+
|
|
13
|
+
const root = process.cwd();
|
|
14
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const engineRoot = existsSync(join(root, "templates/templateCompiler.ts")) ? root : packageRoot;
|
|
16
|
+
const repositoryTemplatesRoot = join(root, "templates");
|
|
17
|
+
const types = new Set(["portfolio", "app", "business", "other"]);
|
|
18
|
+
const maxAssetBytes = 20 * 1024 * 1024;
|
|
19
|
+
const maxAssetsBytes = 50 * 1024 * 1024;
|
|
20
|
+
const cliArgs = process.argv.slice(2);
|
|
21
|
+
const [command = "help", slug] = cliArgs.filter((argument) => !argument.startsWith("--"));
|
|
22
|
+
const shouldInstall = !cliArgs.includes("--no-install");
|
|
23
|
+
const dryRun = cliArgs.includes("--dry-run");
|
|
24
|
+
const apiBase = (process.env.NOWEB_URL || "https://noweb.app").replace(/\/$/, "");
|
|
25
|
+
const clientId = "noweb-cli";
|
|
26
|
+
register({ tsconfig: join(engineRoot, "tsconfig.json") });
|
|
27
|
+
|
|
28
|
+
function usage() {
|
|
29
|
+
console.log(`noweb
|
|
30
|
+
|
|
31
|
+
create [slug] scaffold the current folder
|
|
32
|
+
setup install local React and TypeScript dependencies
|
|
33
|
+
dev preview at http://127.0.0.1:4173
|
|
34
|
+
check validate and compile this template
|
|
35
|
+
login connect the CLI to your Noweb account
|
|
36
|
+
list list your uploaded templates
|
|
37
|
+
download <slug> restore one into the current folder
|
|
38
|
+
upload publish this template directly to Noweb
|
|
39
|
+
publish [slug] publish validated templates (deployment only)`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createTemplate(value) {
|
|
43
|
+
const validSlug = requireSlug(value || basename(root));
|
|
44
|
+
const directory = root;
|
|
45
|
+
const name = validSlug.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
46
|
+
|
|
47
|
+
for (const file of ["Template.tsx", "customizations.json", "template.json"]) {
|
|
48
|
+
if (existsSync(join(directory, file))) throw new Error(`Template file already exists: ${file}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await mkdir(join(directory, "public"), { recursive: true });
|
|
52
|
+
await Promise.all([
|
|
53
|
+
writeFile(join(directory, "template.json"), `${JSON.stringify({ slug: validSlug, name, type: "other" }, null, 2)}\n`),
|
|
54
|
+
writeFile(join(directory, "Template.tsx"), starterTemplate),
|
|
55
|
+
writeFile(join(directory, "customizations.json"), starterCustomizations),
|
|
56
|
+
writeFile(join(directory, "public/.gitkeep"), ""),
|
|
57
|
+
]);
|
|
58
|
+
await setupWorkspace(shouldInstall);
|
|
59
|
+
console.log(`Created ${validSlug} in ${directory}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function setupWorkspace(install = true) {
|
|
63
|
+
const packagePath = join(root, "package.json");
|
|
64
|
+
if (!existsSync(packagePath)) {
|
|
65
|
+
const { version } = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
|
|
66
|
+
await Promise.all([
|
|
67
|
+
writeFile(packagePath, `${JSON.stringify(workspacePackage(version), null, 2)}\n`),
|
|
68
|
+
writeFile(join(root, "tsconfig.json"), `${JSON.stringify(workspaceTsconfig, null, 2)}\n`),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
if (!install) return;
|
|
72
|
+
const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], { cwd: root, stdio: "inherit" });
|
|
73
|
+
if (result.status !== 0) throw new Error("Could not install the template workspace dependencies.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function checkRepositoryTemplates(value, quiet = false, assetRoot = "/assets") {
|
|
77
|
+
const slugs = value ? [requireSlug(value)] : await findTemplates();
|
|
78
|
+
if (!slugs.length) throw new Error("No templates with template.json found.");
|
|
79
|
+
|
|
80
|
+
const results = [];
|
|
81
|
+
for (const currentSlug of slugs) {
|
|
82
|
+
const assetBase = assetRoot === "/assets" ? assetRoot : `${assetRoot}/${currentSlug}`;
|
|
83
|
+
const result = await buildTemplate(join(repositoryTemplatesRoot, currentSlug), currentSlug, assetBase);
|
|
84
|
+
results.push(result);
|
|
85
|
+
if (!quiet) console.log(`✓ ${currentSlug} (${result.fields} fields, ${formatBytes(result.js.length + result.css.length)})`);
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function checkProject(quiet = false) {
|
|
91
|
+
const result = await buildTemplate(root);
|
|
92
|
+
if (!quiet) console.log(`✓ ${result.slug} (${result.fields} fields, ${formatBytes(result.js.length + result.css.length)})`);
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function buildTemplate(directory, slugOverride, assetBase = "/assets") {
|
|
97
|
+
const [metadataSource, source, customizationsSource] = await Promise.all([
|
|
98
|
+
readFile(join(directory, "template.json"), "utf8"),
|
|
99
|
+
readFile(join(directory, "Template.tsx"), "utf8"),
|
|
100
|
+
readFile(join(directory, "customizations.json"), "utf8"),
|
|
101
|
+
]);
|
|
102
|
+
const metadata = JSON.parse(metadataSource);
|
|
103
|
+
const currentSlug = requireSlug(slugOverride || metadata?.slug);
|
|
104
|
+
if (!metadata || typeof metadata.name !== "string" || !metadata.name.trim() || !types.has(metadata.type)) {
|
|
105
|
+
throw new Error(`${currentSlug}: template.json needs a name and type (${[...types].join(", ")}).`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const [{ compileTemplateAssets }, { validateTemplateCode }, { validateTemplateCustomizations }] = await Promise.all([
|
|
109
|
+
load("templates/templateCompiler.ts"),
|
|
110
|
+
load("templates/validateTemplateCode.ts"),
|
|
111
|
+
load("lib/customizations.ts"),
|
|
112
|
+
]);
|
|
113
|
+
validateTemplateCode(source);
|
|
114
|
+
const customizations = validateTemplateCustomizations(JSON.parse(customizationsSource)).map((field) => ({
|
|
115
|
+
...field,
|
|
116
|
+
defaultValue: resolveAssetValue(field.defaultValue, assetBase),
|
|
117
|
+
}));
|
|
118
|
+
const ids = customizations.map((field) => field.fieldId);
|
|
119
|
+
if (new Set(ids).size !== ids.length) throw new Error(`${currentSlug}: customization field IDs must be unique.`);
|
|
120
|
+
for (const match of source.matchAll(/\bcustom\(\s*(\d+)\s*\)/g)) {
|
|
121
|
+
if (!ids.includes(Number(match[1]))) throw new Error(`${currentSlug}: custom(${match[1]}) has no customization.`);
|
|
122
|
+
}
|
|
123
|
+
const assetFiles = await findAssetFiles(join(directory, "public"));
|
|
124
|
+
const assetHash = createHash("sha256");
|
|
125
|
+
for (const file of assetFiles) assetHash.update(file.path).update(file.body);
|
|
126
|
+
const { js, css } = await compileTemplateAssets(source, customizations, assetBase);
|
|
127
|
+
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 };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function dev() {
|
|
132
|
+
const port = Number(process.env.PORT || 4173);
|
|
133
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be between 1 and 65535.");
|
|
134
|
+
|
|
135
|
+
const server = createServer(async (request, response) => {
|
|
136
|
+
try {
|
|
137
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
|
138
|
+
if (url.pathname === "/") {
|
|
139
|
+
const template = await checkProject(true);
|
|
140
|
+
response.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "text/html; charset=utf-8" });
|
|
141
|
+
response.end(previewHtml(template));
|
|
142
|
+
console.log(`✓ rebuilt ${template.slug}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await servePublic(url.pathname, response);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
response.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
148
|
+
response.end(`<pre>${escapeHtml(error instanceof Error ? error.stack || error.message : String(error))}</pre>`);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
server.on("error", (error) => {
|
|
152
|
+
console.error(error.message);
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
});
|
|
155
|
+
server.listen(port, "127.0.0.1", () => {
|
|
156
|
+
console.log(`Preview: http://127.0.0.1:${port}\nRefresh the page to rebuild.`);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function upload() {
|
|
161
|
+
const template = await checkProject();
|
|
162
|
+
if (dryRun) {
|
|
163
|
+
console.log(`Ready to upload ${template.slug}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const credentials = await requireCredentials();
|
|
168
|
+
const form = new FormData();
|
|
169
|
+
form.set("slug", template.slug);
|
|
170
|
+
form.set("name", template.name);
|
|
171
|
+
form.set("type", template.type);
|
|
172
|
+
form.set("source", template.source);
|
|
173
|
+
form.set("customizations", JSON.stringify(template.customizations));
|
|
174
|
+
form.set("assetPaths", JSON.stringify(template.assetFiles.map((file) => file.path)));
|
|
175
|
+
for (const file of template.assetFiles) {
|
|
176
|
+
form.append("assets", new Blob([file.body]), basename(file.path));
|
|
177
|
+
}
|
|
178
|
+
const response = await fetch(`${apiBase}/api/templates/upload`, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: { Authorization: `Bearer ${credentials.token}` },
|
|
181
|
+
body: form,
|
|
182
|
+
});
|
|
183
|
+
const result = await response.json().catch(() => ({}));
|
|
184
|
+
if (!response.ok) throw new Error(result.error || `Upload failed (${response.status}).`);
|
|
185
|
+
console.log(`Published ${template.slug}: ${new URL(result.url, apiBase)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function listTemplates() {
|
|
189
|
+
const credentials = await requireCredentials();
|
|
190
|
+
const response = await fetch(`${apiBase}/api/templates/download`, {
|
|
191
|
+
headers: { Authorization: `Bearer ${credentials.token}` },
|
|
192
|
+
});
|
|
193
|
+
const result = await response.json().catch(() => ({}));
|
|
194
|
+
if (!response.ok) throw new Error(result.error || `Could not list templates (${response.status}).`);
|
|
195
|
+
const templates = Array.isArray(result.templates) ? result.templates : [];
|
|
196
|
+
const projects = Array.isArray(result.projects) ? result.projects : [];
|
|
197
|
+
if (!templates.length && !projects.length) {
|
|
198
|
+
console.log("No templates or projects.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (templates.length) {
|
|
202
|
+
console.log("Templates");
|
|
203
|
+
for (const template of templates) console.log(`${template.slug}\t${template.name}`);
|
|
204
|
+
}
|
|
205
|
+
if (projects.length) {
|
|
206
|
+
if (templates.length) console.log();
|
|
207
|
+
console.log("Projects");
|
|
208
|
+
for (const project of projects) console.log(`${project.id}\t${project.name}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function downloadTemplate(value) {
|
|
213
|
+
const selectedSlug = requireSlug(value);
|
|
214
|
+
for (const file of ["Template.tsx", "customizations.json", "template.json"]) {
|
|
215
|
+
if (existsSync(join(root, file))) throw new Error(`Template file already exists: ${file}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const credentials = await requireCredentials();
|
|
219
|
+
const response = await fetch(`${apiBase}/api/templates/download?slug=${encodeURIComponent(selectedSlug)}`, {
|
|
220
|
+
headers: { Authorization: `Bearer ${credentials.token}` },
|
|
221
|
+
});
|
|
222
|
+
const result = await response.json().catch(() => ({}));
|
|
223
|
+
if (!response.ok) throw new Error(result.error || `Download failed (${response.status}).`);
|
|
224
|
+
const template = result.template;
|
|
225
|
+
if (
|
|
226
|
+
!template || template.metadata?.slug !== selectedSlug || typeof template.metadata?.name !== "string" ||
|
|
227
|
+
!types.has(template.metadata?.type) || typeof template.source !== "string" ||
|
|
228
|
+
!Array.isArray(template.customizations) || !Array.isArray(template.assets) || template.assets.length > 200
|
|
229
|
+
) throw new Error("The server returned an invalid template.");
|
|
230
|
+
|
|
231
|
+
const assets = await Promise.all(template.assets.map(async (asset) => {
|
|
232
|
+
const path = requireAssetPath(asset?.path);
|
|
233
|
+
if (typeof asset?.url !== "string" || !/^https?:\/\//.test(asset.url)) throw new Error("The server returned an invalid asset URL.");
|
|
234
|
+
if (existsSync(join(root, "public", path))) throw new Error(`Asset already exists: public/${path}`);
|
|
235
|
+
const assetResponse = await fetch(asset.url);
|
|
236
|
+
if (!assetResponse.ok) throw new Error(`Could not download public/${path}.`);
|
|
237
|
+
const body = Buffer.from(await assetResponse.arrayBuffer());
|
|
238
|
+
if (body.length > maxAssetBytes) throw new Error(`${path}: template assets must be 20 MB or smaller.`);
|
|
239
|
+
return { body, path };
|
|
240
|
+
}));
|
|
241
|
+
if (assets.reduce((sum, asset) => sum + asset.body.length, 0) > maxAssetsBytes) {
|
|
242
|
+
throw new Error("Template assets must be 50 MB or smaller in total.");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await mkdir(join(root, "public"), { recursive: true });
|
|
246
|
+
await Promise.all(assets.map(async (asset) => {
|
|
247
|
+
const destination = join(root, "public", asset.path);
|
|
248
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
249
|
+
await writeFile(destination, asset.body);
|
|
250
|
+
}));
|
|
251
|
+
await Promise.all([
|
|
252
|
+
writeFile(join(root, "template.json"), `${JSON.stringify(template.metadata, null, 2)}\n`),
|
|
253
|
+
writeFile(join(root, "Template.tsx"), template.source),
|
|
254
|
+
writeFile(join(root, "customizations.json"), `${JSON.stringify(template.customizations, null, 2)}\n`),
|
|
255
|
+
...(!assets.length ? [writeFile(join(root, "public/.gitkeep"), "")] : []),
|
|
256
|
+
]);
|
|
257
|
+
await setupWorkspace(shouldInstall);
|
|
258
|
+
console.log(`Downloaded ${selectedSlug} into ${root}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function login() {
|
|
262
|
+
const response = await fetch(`${apiBase}/api/auth/device/code`, {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: { "Content-Type": "application/json" },
|
|
265
|
+
body: JSON.stringify({ client_id: clientId, scope: "template:write" }),
|
|
266
|
+
});
|
|
267
|
+
const device = await response.json().catch(() => ({}));
|
|
268
|
+
if (!response.ok) throw new Error(device.error_description || device.message || "Could not start CLI login.");
|
|
269
|
+
|
|
270
|
+
console.log(`Open ${device.verification_uri}\nCode: ${device.user_code}`);
|
|
271
|
+
openBrowser(device.verification_uri_complete);
|
|
272
|
+
let interval = Math.max(1, Number(device.interval) || 5);
|
|
273
|
+
const expiresAt = Date.now() + Number(device.expires_in) * 1000;
|
|
274
|
+
while (Date.now() < expiresAt) {
|
|
275
|
+
await wait(interval * 1000);
|
|
276
|
+
const tokenResponse = await fetch(`${apiBase}/api/auth/device/token`, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "Content-Type": "application/json" },
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
281
|
+
device_code: device.device_code,
|
|
282
|
+
client_id: clientId,
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
const token = await tokenResponse.json().catch(() => ({}));
|
|
286
|
+
if (tokenResponse.ok) {
|
|
287
|
+
await writeCredentials({
|
|
288
|
+
baseUrl: apiBase,
|
|
289
|
+
token: token.access_token,
|
|
290
|
+
expiresAt: Date.now() + Number(token.expires_in) * 1000,
|
|
291
|
+
});
|
|
292
|
+
console.log("✓ Logged in to Noweb");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (token.error === "authorization_pending") continue;
|
|
296
|
+
if (token.error === "slow_down") {
|
|
297
|
+
interval += 5;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
throw new Error(token.error_description || token.error || "CLI login failed.");
|
|
301
|
+
}
|
|
302
|
+
throw new Error("CLI login expired. Run the command again.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function credentialPath() {
|
|
306
|
+
const configRoot = process.env.XDG_CONFIG_HOME || process.env.APPDATA || join(homedir(), ".config");
|
|
307
|
+
return join(configRoot, "noweb", "credentials.json");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function readCredentials() {
|
|
311
|
+
try {
|
|
312
|
+
const credentials = JSON.parse(await readFile(credentialPath(), "utf8"));
|
|
313
|
+
return typeof credentials?.token === "string" && typeof credentials?.expiresAt === "number" ? credentials : null;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error?.code === "ENOENT") return null;
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function requireCredentials() {
|
|
321
|
+
const credentials = await readCredentials();
|
|
322
|
+
if (!credentials || credentials.baseUrl !== apiBase || credentials.expiresAt <= Date.now()) {
|
|
323
|
+
throw new Error("Log in first: npx nowebcli login");
|
|
324
|
+
}
|
|
325
|
+
return credentials;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function writeCredentials(credentials) {
|
|
329
|
+
const path = credentialPath();
|
|
330
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
331
|
+
await writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
332
|
+
await chmod(path, 0o600);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function openBrowser(url) {
|
|
336
|
+
const [commandName, args] = process.platform === "darwin"
|
|
337
|
+
? ["open", [url]]
|
|
338
|
+
: process.platform === "win32"
|
|
339
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
340
|
+
: ["xdg-open", [url]];
|
|
341
|
+
try {
|
|
342
|
+
const child = spawn(commandName, args, { detached: true, stdio: "ignore" });
|
|
343
|
+
child.unref();
|
|
344
|
+
} catch {
|
|
345
|
+
// The printed URL is the fallback when no browser opener is available.
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function wait(milliseconds) {
|
|
350
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function publish(value) {
|
|
354
|
+
if (engineRoot !== root) throw new Error("Publishing is only available from the Noweb repository.");
|
|
355
|
+
const databaseUrl = process.env.DATABASE_POOL_URL ?? process.env.DATABASE_URL;
|
|
356
|
+
if (!databaseUrl) throw new Error("DATABASE_POOL_URL or DATABASE_URL is required to publish templates.");
|
|
357
|
+
|
|
358
|
+
const storageApi = await load("lib/object-storage.ts");
|
|
359
|
+
const storage = storageApi.getObjectStorageClientConfig();
|
|
360
|
+
const publicUrl = storageApi.getObjectStoragePublicUrl()?.replace(/\/$/, "");
|
|
361
|
+
if (!storage || !publicUrl) throw new Error("Object storage configuration is required to publish templates.");
|
|
362
|
+
const templates = await checkRepositoryTemplates(value, false, `${publicUrl}/template-assets`);
|
|
363
|
+
const { default: postgres } = await import("postgres");
|
|
364
|
+
const sql = postgres(databaseUrl, { connect_timeout: 10, max: 1 });
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
for (const template of templates) {
|
|
368
|
+
await uploadTemplateAssets(template, storageApi.createObjectStorageClient(storage), storage.bucketName);
|
|
369
|
+
const [published] = await sql`
|
|
370
|
+
INSERT INTO templates (
|
|
371
|
+
name, type, component, compiled_js, compiled_css, customizations,
|
|
372
|
+
author_id, approved_by, source_slug
|
|
373
|
+
) VALUES (
|
|
374
|
+
${template.name}, ${template.type}, ${template.source}, ${template.js},
|
|
375
|
+
${template.css}, ${sql.json(template.customizations)}, 'github', 'github',
|
|
376
|
+
${template.slug}
|
|
377
|
+
)
|
|
378
|
+
ON CONFLICT (source_slug) DO UPDATE SET
|
|
379
|
+
name = EXCLUDED.name,
|
|
380
|
+
type = EXCLUDED.type,
|
|
381
|
+
component = EXCLUDED.component,
|
|
382
|
+
compiled_js = EXCLUDED.compiled_js,
|
|
383
|
+
compiled_css = EXCLUDED.compiled_css,
|
|
384
|
+
customizations = EXCLUDED.customizations,
|
|
385
|
+
approved_by = 'github'
|
|
386
|
+
WHERE templates.name IS DISTINCT FROM EXCLUDED.name
|
|
387
|
+
OR templates.type IS DISTINCT FROM EXCLUDED.type
|
|
388
|
+
OR templates.component IS DISTINCT FROM EXCLUDED.component
|
|
389
|
+
OR templates.compiled_js IS DISTINCT FROM EXCLUDED.compiled_js
|
|
390
|
+
OR templates.compiled_css IS DISTINCT FROM EXCLUDED.compiled_css
|
|
391
|
+
OR templates.customizations::jsonb IS DISTINCT FROM EXCLUDED.customizations::jsonb
|
|
392
|
+
OR templates.approved_by IS DISTINCT FROM 'github'
|
|
393
|
+
RETURNING id
|
|
394
|
+
`;
|
|
395
|
+
if (!published) {
|
|
396
|
+
console.log(`Unchanged ${template.slug}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const screenshot = spawnSync(
|
|
400
|
+
process.execPath,
|
|
401
|
+
["--import", "tsx", "scripts/generate-template-screenshots.ts", String(published.id)],
|
|
402
|
+
{ cwd: root, env: process.env, stdio: "inherit" },
|
|
403
|
+
);
|
|
404
|
+
if (screenshot.status !== 0) throw new Error(`Could not generate preview for ${template.slug}.`);
|
|
405
|
+
console.log(`Published ${template.slug} as template ${published.id}`);
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
await sql.end({ timeout: 5 });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function findTemplates() {
|
|
413
|
+
const entries = await readdir(repositoryTemplatesRoot, { withFileTypes: true });
|
|
414
|
+
return entries
|
|
415
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
416
|
+
.map((entry) => entry.name)
|
|
417
|
+
.sort();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function servePublic(pathname, response) {
|
|
421
|
+
if (pathname.startsWith("/assets/")) {
|
|
422
|
+
return serveFile(
|
|
423
|
+
resolve(root, "public"),
|
|
424
|
+
decodeURIComponent(pathname.slice("/assets/".length)),
|
|
425
|
+
response,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return serveFile(resolve(root, "public"), decodeURIComponent(pathname).replace(/^\/+/, ""), response);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function serveFile(publicRoot, pathname, response) {
|
|
432
|
+
const target = resolve(publicRoot, pathname);
|
|
433
|
+
if (!target.startsWith(`${publicRoot}${sep}`)) {
|
|
434
|
+
response.writeHead(403).end();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
const body = await readFile(target);
|
|
439
|
+
response.writeHead(200, { "Content-Type": mimeTypes[extname(target).toLowerCase()] || "application/octet-stream" });
|
|
440
|
+
response.end(body);
|
|
441
|
+
} catch {
|
|
442
|
+
response.writeHead(404).end();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function findAssetFiles(directory, prefix = "") {
|
|
447
|
+
let entries;
|
|
448
|
+
try {
|
|
449
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (error?.code === "ENOENT") return [];
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const files = [];
|
|
456
|
+
for (const entry of entries) {
|
|
457
|
+
if (entry.name === ".gitkeep") continue;
|
|
458
|
+
if (entry.name.startsWith(".")) throw new Error(`Hidden template assets are not allowed: ${entry.name}`);
|
|
459
|
+
const absolutePath = join(directory, entry.name);
|
|
460
|
+
if (entry.isDirectory()) {
|
|
461
|
+
files.push(...await findAssetFiles(absolutePath, join(prefix, entry.name)));
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (!entry.isFile()) throw new Error(`Template assets may only contain regular files: ${absolutePath}`);
|
|
465
|
+
const file = await stat(absolutePath);
|
|
466
|
+
if (file.size > maxAssetBytes) throw new Error(`${entry.name}: template assets must be 20 MB or smaller.`);
|
|
467
|
+
files.push({ body: await readFile(absolutePath), path: join(prefix, entry.name).split(sep).join("/"), size: file.size });
|
|
468
|
+
}
|
|
469
|
+
const total = files.reduce((sum, file) => sum + file.size, 0);
|
|
470
|
+
if (total > maxAssetsBytes) throw new Error("Template assets must be 50 MB or smaller in total.");
|
|
471
|
+
return files;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function resolveAssetValue(value, assetBase) {
|
|
475
|
+
if (typeof value === "string" && value.startsWith("/assets/")) {
|
|
476
|
+
return `${assetBase.replace(/\/$/, "")}/${value.slice("/assets/".length)}`;
|
|
477
|
+
}
|
|
478
|
+
if (Array.isArray(value)) return value.map((item) => resolveAssetValue(item, assetBase));
|
|
479
|
+
if (value && typeof value === "object") {
|
|
480
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveAssetValue(item, assetBase)]));
|
|
481
|
+
}
|
|
482
|
+
return value;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function uploadTemplateAssets(template, client, bucketName) {
|
|
486
|
+
if (!template.assetFiles.length) return;
|
|
487
|
+
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
|
488
|
+
for (const file of template.assetFiles) {
|
|
489
|
+
await client.send(new PutObjectCommand({
|
|
490
|
+
Bucket: bucketName,
|
|
491
|
+
Key: `template-assets/${template.slug}/${file.path}`,
|
|
492
|
+
Body: file.body,
|
|
493
|
+
CacheControl: "public, max-age=0, must-revalidate",
|
|
494
|
+
ContentType: mimeTypes[extname(file.path).toLowerCase()] || "application/octet-stream",
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function previewHtml(template) {
|
|
500
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(template.name)}</title><style>${escapeClosingTag(template.css, "style")}</style></head><body><div id="root"></div><script>${escapeClosingTag(template.js, "script")}</script><script>window.__nowebRenderTemplate(document.getElementById("root"), {});</script></body></html>`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function requireSlug(value) {
|
|
504
|
+
if (!value || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) throw new Error("Use a lowercase template slug, for example: minimal-portfolio");
|
|
505
|
+
return value;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function requireAssetPath(value) {
|
|
509
|
+
if (typeof value !== "string" || value.length > 300 || value.includes("\\")) throw new Error("Invalid asset path.");
|
|
510
|
+
const parts = value.split("/");
|
|
511
|
+
if (parts.some((part) => !part || part === "." || part === ".." || part.startsWith("."))) throw new Error("Invalid asset path.");
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function load(relativePath) {
|
|
516
|
+
return import(pathToFileURL(join(engineRoot, relativePath)).href);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function escapeHtml(value) {
|
|
520
|
+
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function escapeClosingTag(value, tag) {
|
|
524
|
+
return value.replace(new RegExp(`</${tag}`, "gi"), `<\\/${tag}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatBytes(bytes) {
|
|
528
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const mimeTypes = {
|
|
532
|
+
".css": "text/css; charset=utf-8",
|
|
533
|
+
".gif": "image/gif",
|
|
534
|
+
".jpeg": "image/jpeg",
|
|
535
|
+
".jpg": "image/jpeg",
|
|
536
|
+
".js": "text/javascript; charset=utf-8",
|
|
537
|
+
".png": "image/png",
|
|
538
|
+
".avif": "image/avif",
|
|
539
|
+
".json": "application/json",
|
|
540
|
+
".mp4": "video/mp4",
|
|
541
|
+
".svg": "image/svg+xml",
|
|
542
|
+
".webp": "image/webp",
|
|
543
|
+
".woff": "font/woff",
|
|
544
|
+
".woff2": "font/woff2",
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const starterTemplate = `import React from "react";
|
|
548
|
+
import { custom } from "noweb";
|
|
549
|
+
|
|
550
|
+
export default function Template() {
|
|
551
|
+
return (
|
|
552
|
+
<main className="grid min-h-screen place-items-center bg-neutral-950 px-6 text-white">
|
|
553
|
+
<div className="max-w-2xl text-center">
|
|
554
|
+
<h1 className="text-5xl font-semibold tracking-tight">{custom(1)}</h1>
|
|
555
|
+
<p className="mt-5 text-lg text-white/60">{custom(2)}</p>
|
|
556
|
+
</div>
|
|
557
|
+
</main>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
`;
|
|
561
|
+
|
|
562
|
+
const starterCustomizations = `${JSON.stringify([
|
|
563
|
+
{ fieldId: 1, name: "Title", type: "text", defaultValue: "Your new template" },
|
|
564
|
+
{ fieldId: 2, name: "Description", type: "text", defaultValue: "Make it yours." },
|
|
565
|
+
], null, 2)}\n`;
|
|
566
|
+
|
|
567
|
+
function workspacePackage(version) {
|
|
568
|
+
return {
|
|
569
|
+
name: "noweb-project",
|
|
570
|
+
private: true,
|
|
571
|
+
scripts: {
|
|
572
|
+
check: "noweb check",
|
|
573
|
+
dev: "noweb dev",
|
|
574
|
+
},
|
|
575
|
+
dependencies: {
|
|
576
|
+
noweb: `npm:nowebcli@^${version}`,
|
|
577
|
+
react: "19.2.4",
|
|
578
|
+
"react-dom": "19.2.4",
|
|
579
|
+
},
|
|
580
|
+
devDependencies: {
|
|
581
|
+
"@types/react": "^19",
|
|
582
|
+
"@types/react-dom": "^19",
|
|
583
|
+
typescript: "^5",
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const workspaceTsconfig = {
|
|
589
|
+
compilerOptions: {
|
|
590
|
+
allowImportingTsExtensions: true,
|
|
591
|
+
jsx: "react-jsx",
|
|
592
|
+
lib: ["DOM", "ES2022"],
|
|
593
|
+
module: "ESNext",
|
|
594
|
+
moduleResolution: "Bundler",
|
|
595
|
+
noEmit: true,
|
|
596
|
+
paths: {
|
|
597
|
+
"@noweb": ["./node_modules/noweb/templates/templateRuntime.tsx"],
|
|
598
|
+
"@noweb/shaders": ["./node_modules/noweb/templates/shadersRuntime.tsx"],
|
|
599
|
+
noweb: ["./node_modules/noweb/templates/templateRuntime.tsx"],
|
|
600
|
+
},
|
|
601
|
+
skipLibCheck: true,
|
|
602
|
+
strict: true,
|
|
603
|
+
target: "ES2022",
|
|
604
|
+
},
|
|
605
|
+
include: ["Template.tsx"],
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
await access(join(engineRoot, "templates/templateCompiler.ts"));
|
|
610
|
+
|
|
611
|
+
if (command === "create") await createTemplate(slug);
|
|
612
|
+
else if (command === "setup") await setupWorkspace(shouldInstall);
|
|
613
|
+
else if (command === "check") await (slug && engineRoot === root ? checkRepositoryTemplates(slug) : checkProject());
|
|
614
|
+
else if (command === "dev") await dev();
|
|
615
|
+
else if (command === "login") await login();
|
|
616
|
+
else if (command === "list") await listTemplates();
|
|
617
|
+
else if (command === "download") await downloadTemplate(slug);
|
|
618
|
+
else if (command === "upload") await upload();
|
|
619
|
+
else if (command === "publish") await publish(slug);
|
|
620
|
+
else if (command === "help" || command === "--help" || command === "-h") usage();
|
|
621
|
+
else throw new Error(`Unknown command: ${command}`);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
console.error(`\n${error instanceof Error ? error.message : error}`);
|
|
624
|
+
process.exitCode = 1;
|
|
625
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const TAILWIND_COMPILE_TIMEOUT_MS = 10_000;
|
|
12
|
+
const TAILWIND_COMPILE_MAX_BUFFER = 8 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
export async function compileTailwindForTemplate(
|
|
15
|
+
code: string,
|
|
16
|
+
additionalSources: string[] = [],
|
|
17
|
+
) {
|
|
18
|
+
const dir = await mkdtemp(path.join(tmpdir(), "template-css-"));
|
|
19
|
+
const input = path.join(dir, "input.css");
|
|
20
|
+
const output = path.join(dir, "output.css");
|
|
21
|
+
const runner = path.join(dir, "runner.mjs");
|
|
22
|
+
const template = path.join(dir, "template.tsx");
|
|
23
|
+
const tailwindCss = path.join(path.dirname(require.resolve(/* turbopackIgnore: true */ "tailwindcss/package.json")), "index.css");
|
|
24
|
+
const packageJsonUrl = pathToFileURL(require.resolve(/* turbopackIgnore: true */ "postcss/package.json")).href;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(template, code);
|
|
28
|
+
await Promise.all(
|
|
29
|
+
additionalSources.map((source, index) =>
|
|
30
|
+
writeFile(path.join(dir, `additional-${index}.tsx`), source),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
const sources = [
|
|
34
|
+
'@source "./template.tsx";',
|
|
35
|
+
...additionalSources.map((_, index) => `@source "./additional-${index}.tsx";`),
|
|
36
|
+
].join("\n");
|
|
37
|
+
await writeFile(input, `@import ${JSON.stringify(tailwindCss)};\n${sources}\n`);
|
|
38
|
+
await writeFile(
|
|
39
|
+
runner,
|
|
40
|
+
`
|
|
41
|
+
import { createRequire } from "node:module";
|
|
42
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
43
|
+
|
|
44
|
+
const require = createRequire(${JSON.stringify(packageJsonUrl)});
|
|
45
|
+
const postcss = require("postcss");
|
|
46
|
+
const tailwindcss = require("@tailwindcss/postcss");
|
|
47
|
+
const input = ${JSON.stringify(input)};
|
|
48
|
+
const output = ${JSON.stringify(output)};
|
|
49
|
+
const css = await readFile(input, "utf8");
|
|
50
|
+
const result = await postcss([
|
|
51
|
+
tailwindcss({ base: ${JSON.stringify(dir)}, optimize: { minify: true } }),
|
|
52
|
+
]).process(css, { from: input });
|
|
53
|
+
|
|
54
|
+
await writeFile(output, result.css);
|
|
55
|
+
`,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await run(process.execPath, [runner], {
|
|
59
|
+
maxBuffer: TAILWIND_COMPILE_MAX_BUFFER,
|
|
60
|
+
timeout: TAILWIND_COMPILE_TIMEOUT_MS,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return readFile(output, "utf8");
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(dir, { force: true, recursive: true });
|
|
66
|
+
}
|
|
67
|
+
}
|