@ecomcx/create-nexus 0.0.0-stage → 1.24.0
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 +13 -2
- package/bin/cms.mjs +157 -0
- package/lib/deploy.mjs +97 -0
- package/lib/env/Caddyfile +58 -0
- package/lib/env/init-secrets.sh +31 -0
- package/lib/env.mjs +235 -0
- package/lib/npm.mjs +9 -0
- package/lib/pack.mjs +147 -0
- package/lib/scaffold.mjs +504 -0
- package/lib/upgrade.mjs +56 -0
- package/lib/util.mjs +94 -0
- package/lib/validate.mjs +277 -0
- package/package.json +35 -4
package/lib/upgrade.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { ADMIN_SDK, OLD_NAMES, THEME_SDK, VERSION } from "./npm.mjs";
|
|
5
|
+
import { c, readJson, run } from "./util.mjs";
|
|
6
|
+
|
|
7
|
+
// Tên mới + tên cũ (project tạo trước khi SDK lên npm: giữ khoá cũ làm bí danh để import cũ vẫn chạy)
|
|
8
|
+
const SDK = [THEME_SDK, ADMIN_SDK, "@cms/theme-sdk", "@cms/admin-sdk"];
|
|
9
|
+
/** Phiên bản mới nhất trên npm (dự phòng: phiên bản của CLI đang chạy). */
|
|
10
|
+
function latest(npmName) {
|
|
11
|
+
try { return execFileSync("npm", ["view", npmName, "version"], { encoding: "utf8", shell: process.platform === "win32" }).trim(); }
|
|
12
|
+
catch { return VERSION; }
|
|
13
|
+
}
|
|
14
|
+
const spec = (key) => (OLD_NAMES[key] ? `npm:${OLD_NAMES[key]}@^${latest(OLD_NAMES[key])}` : `^${latest(key)}`);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Đưa project theme/plugin lên SDK mới nhất trên npm (@ecomcx/nexus-theme-sdk, @ecomcx/nexus-admin-sdk). Project cũ khai báo
|
|
18
|
+
* "@cms/…" (cài bằng link tarball trên GitHub) → bí danh "npm:@ecomcx/nexus-…@^bản mới nhất" để import cũ vẫn chạy
|
|
19
|
+
* (validate nhắc đổi import sang tên mới). Bỏ bản đang cài khỏi node_modules + package-lock.json rồi cài lại.
|
|
20
|
+
* Website không phụ thuộc bản này: CMS build theme/plugin bằng SDK của chính nó; bản trong project dùng cho gợi ý code,
|
|
21
|
+
* kiểm tra kiểu và `pack`.
|
|
22
|
+
*/
|
|
23
|
+
export async function upgrade(dir) {
|
|
24
|
+
const pkgFile = path.join(dir, "package.json");
|
|
25
|
+
const pkg = readJson(pkgFile);
|
|
26
|
+
if (!pkg) throw new Error(`Không thấy package.json ở ${dir}`);
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const section of ["dependencies", "devDependencies"])
|
|
29
|
+
for (const name of SDK)
|
|
30
|
+
if (pkg[section]?.[name] !== undefined) {
|
|
31
|
+
const before = pkg[section][name];
|
|
32
|
+
pkg[section][name] = spec(name);
|
|
33
|
+
found.push({ name, before, installed: readJson(path.join(dir, "node_modules", ...name.split("/"), "package.json"))?.version ?? null });
|
|
34
|
+
}
|
|
35
|
+
if (!found.length) { console.log(c.dim(" Project không dùng @ecomcx/nexus-theme-sdk hay @ecomcx/nexus-admin-sdk — không có gì để nâng.")); return { upgraded: [] }; }
|
|
36
|
+
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
|
|
37
|
+
|
|
38
|
+
// Gỡ bản cũ khỏi lockfile + node_modules: npm giữ bản theo lockfile/cache nếu link không đổi
|
|
39
|
+
const lockFile = path.join(dir, "package-lock.json");
|
|
40
|
+
const lock = readJson(lockFile);
|
|
41
|
+
if (lock?.packages) {
|
|
42
|
+
for (const { name } of found) {
|
|
43
|
+
delete lock.packages[`node_modules/${name}`];
|
|
44
|
+
if (lock.packages[""]?.dependencies?.[name]) lock.packages[""].dependencies[name] = pkg.dependencies[name];
|
|
45
|
+
if (lock.packages[""]?.devDependencies?.[name]) lock.packages[""].devDependencies[name] = pkg.devDependencies[name];
|
|
46
|
+
}
|
|
47
|
+
fs.writeFileSync(lockFile, JSON.stringify(lock, null, 2) + "\n");
|
|
48
|
+
}
|
|
49
|
+
for (const { name } of found) fs.rmSync(path.join(dir, "node_modules", ...name.split("/")), { recursive: true, force: true });
|
|
50
|
+
|
|
51
|
+
console.log(c.cyan("• Cài SDK mới nhất…"));
|
|
52
|
+
await run("npm", ["install", "--prefer-online", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, quiet: true, shell: process.platform === "win32" });
|
|
53
|
+
const upgraded = found.map((f) => ({ ...f, now: readJson(path.join(dir, "node_modules", ...f.name.split("/"), "package.json"))?.version ?? null }));
|
|
54
|
+
for (const u of upgraded) console.log(` ${u.name.padEnd(16)} ${u.installed ?? "?"} → ${c.green(u.now ?? "?")}`);
|
|
55
|
+
return { upgraded };
|
|
56
|
+
}
|
package/lib/util.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export const c = {
|
|
6
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
7
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function readJson(file) {
|
|
11
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function write(file, content) {
|
|
15
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
16
|
+
fs.writeFileSync(file, content.replace(/^\n/, ""));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function slugify(s) {
|
|
20
|
+
return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/đ/g, "d").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pascal(s) {
|
|
24
|
+
return slugify(s).split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function run(cmd, args, opts = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn(cmd, args, { stdio: opts.quiet ? "pipe" : "inherit", ...opts });
|
|
30
|
+
let out = "";
|
|
31
|
+
child.stdout?.on("data", (d) => (out += d));
|
|
32
|
+
child.stderr?.on("data", (d) => (out += d));
|
|
33
|
+
child.on("error", reject);
|
|
34
|
+
child.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(`${cmd} thoát với mã ${code}\n${out}`))));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Backend dạng mã nguồn ("backend": "X.cs" / {"sources": [...]}) — CMS ≥ 1.7.0 tự biên dịch khi cài. Không phải → null. */
|
|
39
|
+
export function backendSources(manifest) {
|
|
40
|
+
const b = manifest?.backend;
|
|
41
|
+
if (typeof b === "string") return [b];
|
|
42
|
+
if (b && Array.isArray(b.sources) && b.sources.length) return b.sources;
|
|
43
|
+
if (b && typeof b.sources === "string") return [b.sources];
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Mở rộng mẫu "a.cs", "src/*.cs" hoặc đệ quy (thư mục có "**") thành danh sách file tương đối, không ra ngoài thư mục.
|
|
48
|
+
export function expandSources(dir, patterns) {
|
|
49
|
+
const out = new Set();
|
|
50
|
+
const walk = (d, recursive, re) => {
|
|
51
|
+
if (!fs.existsSync(d)) return;
|
|
52
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
53
|
+
const p = path.join(d, e.name);
|
|
54
|
+
if (e.isDirectory()) { if (recursive && e.name !== "node_modules" && e.name !== "bin" && e.name !== "obj") walk(p, recursive, re); }
|
|
55
|
+
else if (re.test(e.name)) out.add(path.relative(dir, p));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
for (const pat of patterns) {
|
|
59
|
+
const rel = pat.replace(/\\/g, "/");
|
|
60
|
+
if (!rel.includes("*")) { out.add(rel); continue; }
|
|
61
|
+
const recursive = rel.includes("/**/");
|
|
62
|
+
const base = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")).replace("/**", "") : "";
|
|
63
|
+
const re = new RegExp("^" + path.basename(rel).replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
|
|
64
|
+
walk(path.join(dir, base), recursive, re);
|
|
65
|
+
}
|
|
66
|
+
return [...out].filter((f) => !f.startsWith("..") && !path.isAbsolute(f));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseArgs(argv) {
|
|
70
|
+
const args = { _: [] };
|
|
71
|
+
for (let i = 0; i < argv.length; i++) {
|
|
72
|
+
const a = argv[i];
|
|
73
|
+
if (a.startsWith("--")) {
|
|
74
|
+
const [k, v] = a.slice(2).split("=");
|
|
75
|
+
if (v !== undefined) args[k] = v;
|
|
76
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith("--")) args[k] = argv[++i];
|
|
77
|
+
else args[k] = true;
|
|
78
|
+
} else args._.push(a);
|
|
79
|
+
}
|
|
80
|
+
return args;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Tìm thư mục gốc monorepo CMS (có sdk/theme-sdk) để dev local; trả null nếu không có. */
|
|
84
|
+
export function findCmsRepo(start = process.cwd()) {
|
|
85
|
+
if (process.env.CMS_REPO && fs.existsSync(path.join(process.env.CMS_REPO, "sdk"))) return path.resolve(process.env.CMS_REPO);
|
|
86
|
+
let dir = path.resolve(start);
|
|
87
|
+
for (let i = 0; i < 8; i++) {
|
|
88
|
+
if (fs.existsSync(path.join(dir, "sdk", "theme-sdk", "package.json")) && fs.existsSync(path.join(dir, "backend"))) return dir;
|
|
89
|
+
const parent = path.dirname(dir);
|
|
90
|
+
if (parent === dir) break;
|
|
91
|
+
dir = parent;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
package/lib/validate.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { IntlMessageFormat } from "intl-messageformat";
|
|
4
|
+
import { readJson, backendSources, expandSources } from "./util.mjs";
|
|
5
|
+
|
|
6
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
|
7
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+].*)?$/;
|
|
8
|
+
const FIELD_TYPES = ["text", "textarea", "number", "checkbox", "select", "color", "media", "url", "email", "richtext", "menu"];
|
|
9
|
+
|
|
10
|
+
/** Kiểm tra theme hoặc plugin. Trả về { kind, manifest, errors, warnings }. */
|
|
11
|
+
export function validate(dir) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
const warnings = [];
|
|
14
|
+
const themeJson = path.join(dir, "theme.json");
|
|
15
|
+
const pluginJson = path.join(dir, "plugin.json");
|
|
16
|
+
const kind = fs.existsSync(themeJson) ? "theme" : fs.existsSync(pluginJson) ? "plugin" : null;
|
|
17
|
+
if (!kind) return { kind: null, manifest: null, errors: ["Không tìm thấy theme.json hoặc plugin.json"], warnings };
|
|
18
|
+
const manifest = readJson(kind === "theme" ? themeJson : pluginJson);
|
|
19
|
+
if (!manifest) return { kind, manifest: null, errors: [`${kind}.json không phải JSON hợp lệ`], warnings };
|
|
20
|
+
|
|
21
|
+
if (!manifest.name) errors.push("Thiếu name");
|
|
22
|
+
if (!SLUG.test(manifest.slug ?? "")) errors.push("slug chỉ gồm a-z, 0-9, dấu gạch ngang (2–64 ký tự)");
|
|
23
|
+
if (!SEMVER.test(manifest.version ?? "")) errors.push("version phải theo semver, vd 1.0.0");
|
|
24
|
+
const dicts = checkLocales(dir, errors, warnings);
|
|
25
|
+
checkLabelKeys(manifest, dicts, kind, errors, warnings);
|
|
26
|
+
checkOldImports(dir, warnings);
|
|
27
|
+
// Bản dịch kiểu cũ (khoá là chuỗi gốc): { "en": { "Chuỗi gốc": "Translation" } } — vẫn chạy, nên chuyển sang locales/*.json
|
|
28
|
+
if (manifest.translations !== undefined) {
|
|
29
|
+
warnings.push(kind === "plugin"
|
|
30
|
+
? '"translations" (khoá là chuỗi gốc) đã cũ — chuyển sang locales/{vi,en}.json + createT()/Text.T(); nhãn trong plugin.json viết "%khoá%" (xem plugin-guide › Đa ngôn ngữ)'
|
|
31
|
+
: '"translations" (khoá là chuỗi gốc) đã cũ — nhãn trong theme.json viết "%khoá%" và dịch trong locales/{vi,en}.json (CMS ≥ 1.8.0, xem theme-guide › Đa ngôn ngữ)');
|
|
32
|
+
const t = manifest.translations;
|
|
33
|
+
const okShape = t && typeof t === "object" && !Array.isArray(t) && Object.entries(t).every(([code, d]) =>
|
|
34
|
+
/^[a-z]{2}(-[a-z0-9]{2,8})?$/i.test(code) && d && typeof d === "object" && !Array.isArray(d) && Object.values(d).every((v) => typeof v === "string"));
|
|
35
|
+
if (!okShape) errors.push('translations phải có dạng { "en": { "Chuỗi gốc": "Bản dịch" } }');
|
|
36
|
+
}
|
|
37
|
+
if (!manifest.requires?.cms) warnings.push('Nên khai báo requires.cms, vd ">=1.0.0 <2.0.0"');
|
|
38
|
+
checkPermissions(manifest.permissions, errors, warnings);
|
|
39
|
+
|
|
40
|
+
if (kind === "theme") {
|
|
41
|
+
const templates = path.join(dir, "templates");
|
|
42
|
+
const hasIndex = ["index.tsx", "index.ts", ...(manifest.editing === "blocks" ? ["index.json"] : [])].some((f) => fs.existsSync(path.join(templates, f)));
|
|
43
|
+
if (!manifest.parent && !hasIndex)
|
|
44
|
+
errors.push(manifest.editing === "blocks" ? "Theme cần templates/index.json hoặc index.tsx (template dự phòng cuối cùng)" : "Theme cần templates/index.tsx (template dự phòng cuối cùng)");
|
|
45
|
+
if (manifest.editing != null && !["code", "hybrid", "blocks"].includes(manifest.editing)) errors.push(`editing "${manifest.editing}" không hợp lệ (code, hybrid, blocks)`);
|
|
46
|
+
for (const name of Object.keys(manifest.regions ?? {}))
|
|
47
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) errors.push(`regions: tên vùng "${name}" không hợp lệ (chữ thường, số, dấu gạch ngang)`);
|
|
48
|
+
if (manifest.regions && manifest.editing !== "hybrid") warnings.push('regions chỉ được dùng khi theme.json có "editing": "hybrid"');
|
|
49
|
+
// Vùng chuẩn của core: header/footer bọc trong EditableRegion thì plugin thay được với mọi theme
|
|
50
|
+
const layoutFile = ["layout.tsx", "layout.ts"].map((f) => path.join(dir, f)).find((f) => fs.existsSync(f));
|
|
51
|
+
if (layoutFile && manifest.editing !== "blocks") {
|
|
52
|
+
const src = fs.readFileSync(layoutFile, "utf8");
|
|
53
|
+
for (const name of ["header", "footer"])
|
|
54
|
+
if (!new RegExp(`<EditableRegion[^>]*name=["']${name}["']`).test(src))
|
|
55
|
+
warnings.push(`${path.basename(layoutFile)}: nên bọc ${name} trong <EditableRegion name="${name}" data={data}> để plugin thay được ${name}`);
|
|
56
|
+
}
|
|
57
|
+
for (const folder of ["templates", "parts"]) {
|
|
58
|
+
const d = path.join(dir, folder);
|
|
59
|
+
if (!fs.existsSync(d)) continue;
|
|
60
|
+
for (const f of fs.readdirSync(d).filter((f) => f.endsWith(".json"))) {
|
|
61
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}\.json$/.test(f)) errors.push(`${folder}/${f}: tên file chỉ gồm chữ thường, số, gạch ngang`);
|
|
62
|
+
try { const j = JSON.parse(fs.readFileSync(path.join(d, f), "utf8")); if (!Array.isArray(j.blocks) && j.type !== "doc") errors.push(`${folder}/${f}: không phải nội dung khối ({ version, blocks })`); }
|
|
63
|
+
catch (e) { errors.push(`${folder}/${f}: JSON lỗi — ${e.message}`); }
|
|
64
|
+
}
|
|
65
|
+
if (manifest.editing !== "blocks" && folder === "templates" && fs.readdirSync(d).some((f) => f.endsWith(".json")))
|
|
66
|
+
warnings.push('templates/*.json chỉ được dùng khi theme.json có "editing": "blocks"');
|
|
67
|
+
}
|
|
68
|
+
for (const f of manifest.settings ?? []) {
|
|
69
|
+
if (!f.key) errors.push("settings: thiếu key");
|
|
70
|
+
if (!FIELD_TYPES.includes(f.type)) errors.push(`settings.${f.key}: type "${f.type}" không hợp lệ (${FIELD_TYPES.join(", ")})`);
|
|
71
|
+
if (f.type === "select" && !f.options?.length) errors.push(`settings.${f.key}: select cần options`);
|
|
72
|
+
}
|
|
73
|
+
const pageSlugs = new Set();
|
|
74
|
+
for (const p of manifest.pages ?? []) {
|
|
75
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(p.slug ?? "")) errors.push(`pages: slug "${p.slug}" không hợp lệ (chữ thường, số, gạch ngang)`);
|
|
76
|
+
else if (pageSlugs.has(p.slug)) errors.push(`pages: slug "${p.slug}" bị khai báo hai lần`);
|
|
77
|
+
pageSlugs.add(p.slug);
|
|
78
|
+
if (!p.title?.trim()) errors.push(`pages: trang "${p.slug}" thiếu title`);
|
|
79
|
+
if (p.template != null && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(p.template)) errors.push(`pages: template "${p.template}" của trang "${p.slug}" không hợp lệ`);
|
|
80
|
+
else if (p.template && !["canvas", "full-width"].includes(p.template) && !fs.existsSync(path.join(templates, `${p.template}.tsx`)) && !fs.existsSync(path.join(templates, `${p.template}.ts`)))
|
|
81
|
+
warnings.push(`pages: trang "${p.slug}" dùng template "${p.template}" nhưng không thấy templates/${p.template}.tsx`);
|
|
82
|
+
}
|
|
83
|
+
const patternsDir = path.join(dir, "patterns");
|
|
84
|
+
if (fs.existsSync(patternsDir)) for (const f of fs.readdirSync(patternsDir).filter((f) => f.endsWith(".json"))) {
|
|
85
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}\.json$/.test(f)) { errors.push(`patterns/${f}: tên file chỉ gồm chữ thường, số, gạch ngang`); continue; }
|
|
86
|
+
try {
|
|
87
|
+
const j = JSON.parse(fs.readFileSync(path.join(patternsDir, f), "utf8"));
|
|
88
|
+
if (!j.title?.trim?.()) errors.push(`patterns/${f}: thiếu title`);
|
|
89
|
+
if (!j.content || (typeof j.content !== "object")) errors.push(`patterns/${f}: thiếu content (cây khối)`);
|
|
90
|
+
if (j.category && !/^[a-z][a-z0-9-]{0,30}$/.test(j.category)) errors.push(`patterns/${f}: category "${j.category}" không hợp lệ`);
|
|
91
|
+
} catch (e) { errors.push(`patterns/${f}: JSON lỗi — ${e.message}`); }
|
|
92
|
+
}
|
|
93
|
+
if (manifest.screenshot && !fs.existsSync(path.join(dir, manifest.screenshot))) warnings.push(`Không thấy ảnh ${manifest.screenshot}`);
|
|
94
|
+
if (fs.existsSync(path.join(dir, "node_modules"))) warnings.push("node_modules sẽ bị loại khỏi gói");
|
|
95
|
+
} else {
|
|
96
|
+
const declared = ["collections", "settings", "postTypes", "taxonomies", "postStatuses", "postMeta", "capabilities", "menus"].some((k) => manifest[k] && Object.keys(manifest[k]).length);
|
|
97
|
+
for (const key of Object.keys(manifest.postMeta ?? {}))
|
|
98
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,99}$/.test(key)) errors.push(`postMeta: khoá "${key}" không hợp lệ (chữ, số, _ - . :, tối đa 100 ký tự)`);
|
|
99
|
+
for (const name of Object.keys(manifest.pageTemplates ?? {}))
|
|
100
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) errors.push(`pageTemplates: tên template "${name}" không hợp lệ (chữ thường, số, gạch ngang)`);
|
|
101
|
+
if (!manifest.backend && !manifest.admin && !manifest.frontend && !declared) errors.push("Plugin cần ít nhất một phần: backend, admin, frontend hoặc khai báo (collections, settings, postTypes…)");
|
|
102
|
+
for (const [name, c] of Object.entries(manifest.collections ?? {})) {
|
|
103
|
+
if (!/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/.test(name)) errors.push(`collections: tên bảng không hợp lệ: ${name}`);
|
|
104
|
+
const types = ["text", "textarea", "email", "tel", "url", "integer", "number", "boolean", "date", "datetime", "time", "select"];
|
|
105
|
+
for (const f of c.fields ?? []) {
|
|
106
|
+
if (!/^[a-z][a-zA-Z0-9]{0,40}$/.test(f.key ?? "") || ["id", "createdAt", "updatedAt"].includes(f.key)) errors.push(`collections.${name}: tên trường không hợp lệ: ${f.key}`);
|
|
107
|
+
if (!types.includes(f.type ?? "text")) errors.push(`collections.${name}.${f.key}: kiểu "${f.type}" không hợp lệ (${types.join(", ")})`);
|
|
108
|
+
if (f.type === "select" && !f.options?.length) errors.push(`collections.${name}.${f.key}: select cần options`);
|
|
109
|
+
}
|
|
110
|
+
if (!(c.fields ?? []).length) errors.push(`collections.${name}: cần ít nhất một trường`);
|
|
111
|
+
}
|
|
112
|
+
if (declared && !/>=\s*1\.([7-9]|\d\d)/.test(manifest.requires?.cms ?? "")) warnings.push('khai báo trong plugin.json cần CMS ≥ 1.7.0 — nên ghi "requires": { "cms": ">=1.7.0" }');
|
|
113
|
+
const sources = backendSources(manifest);
|
|
114
|
+
if (sources) {
|
|
115
|
+
for (const s of sources) if (!/^(?!\/)(?!.*\.\.)[A-Za-z0-9_\-./*]+\.cs$/.test(s)) errors.push(`backend: đường dẫn mã nguồn không hợp lệ: ${s}`);
|
|
116
|
+
const files = expandSources(dir, sources);
|
|
117
|
+
if (!files.length) errors.push("backend: không thấy file .cs nào");
|
|
118
|
+
for (const f of files) if (!fs.existsSync(path.join(dir, f))) errors.push(`backend: không thấy ${f}`);
|
|
119
|
+
if (!/>=\s*1\.([7-9]|\d\d)/.test(manifest.requires?.cms ?? "")) warnings.push('backend dạng mã nguồn cần CMS ≥ 1.7.0 — nên ghi "requires": { "cms": ">=1.7.0" }');
|
|
120
|
+
} else if (manifest.backend && !/\.dll$/i.test(manifest.backend.assembly ?? "")) errors.push("backend.assembly phải là đường dẫn .dll, vd backend/MyPlugin.dll");
|
|
121
|
+
if (manifest.admin && !/\.js$/i.test(manifest.admin.entry ?? "")) errors.push("admin.entry phải là file .js đã build, vd admin/index.js");
|
|
122
|
+
if (manifest.admin?.loginEntry && !/\.js$/i.test(manifest.admin.loginEntry)) errors.push("admin.loginEntry phải là file .js đã build, vd admin/login.js (nguồn: admin/src/login.tsx)");
|
|
123
|
+
if (manifest.frontend && !/\.(tsx?|jsx?)$/.test(manifest.frontend.entry ?? "")) errors.push("frontend.entry phải là .ts/.tsx/.js/.jsx");
|
|
124
|
+
for (const r of manifest.frontend?.routes ?? [])
|
|
125
|
+
if (typeof r !== "string" || !/^\/[a-z0-9._-]+(\/[a-z0-9._-]+)*$/.test(r) || /^\/(api|_next|admin|uploads|extensions|hubs|feed)(\/|$)|^\/(sitemap\.xml|robots\.txt|favicon\.ico)$/.test(r))
|
|
126
|
+
errors.push(`frontend.routes: đường dẫn "${r}" không hợp lệ hoặc trùng đường dẫn của hệ thống`);
|
|
127
|
+
if (manifest.frontend && !fs.existsSync(path.join(dir, manifest.frontend.entry))) errors.push(`Không thấy ${manifest.frontend.entry}`);
|
|
128
|
+
if (manifest.admin && !fs.existsSync(path.join(dir, "admin", "src")) && !fs.existsSync(path.join(dir, manifest.admin.entry)))
|
|
129
|
+
errors.push("Không thấy admin/src/index.tsx (source) hoặc bundle admin đã build");
|
|
130
|
+
for (const w of checkSources(dir, manifest)) warnings.push(w);
|
|
131
|
+
const legacy = findLegacyCalls(path.join(dir, "admin", "src"));
|
|
132
|
+
if (legacy.length) warnings.push(`__("…") kiểu cũ ở ${legacy.slice(0, 3).join(", ")}${legacy.length > 3 ? ` (+${legacy.length - 3})` : ""} — dùng createT("${manifest.slug}") với locales/*.json`);
|
|
133
|
+
if (manifest.backend && !sources && !(fs.existsSync(path.join(dir, "backend")) && fs.readdirSync(path.join(dir, "backend")).some((f) => f.endsWith(".csproj"))) && !fs.existsSync(path.join(dir, manifest.backend.assembly)))
|
|
134
|
+
errors.push("Không thấy backend/*.csproj hoặc DLL đã build");
|
|
135
|
+
}
|
|
136
|
+
return { kind, manifest, errors, warnings };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const LOCALE_FILE = /^[a-z]{2}(-[a-z0-9]{2,8})?\.json$/i;
|
|
140
|
+
const KEY = /^[A-Za-z0-9_][\w.-]*$/;
|
|
141
|
+
/** Tên biến của thông điệp ICU (kể cả trong plural/select lồng nhau). */
|
|
142
|
+
function argsOf(ast, out = new Set()) {
|
|
143
|
+
for (const el of ast) {
|
|
144
|
+
if (typeof el.value === "string" && el.type !== 0 && el.type !== 7) out.add(el.value);
|
|
145
|
+
if (el.options) for (const o of Object.values(el.options)) argsOf(o.value, out);
|
|
146
|
+
if (el.children) argsOf(el.children, out);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** locales/{ngôn-ngữ}.json: { "khoa": "thông điệp ICU" } — cú pháp ICU, khoá và biến khớp giữa các ngôn ngữ. */
|
|
152
|
+
/** Quyền trong manifest: viết sai → lỗi; quyền toàn phần mà chỉ cần vài domain / thư mục riêng → gợi ý quyền hẹp (CMS ≥ 1.8.0). */
|
|
153
|
+
function checkPermissions(perms, errors, warnings) {
|
|
154
|
+
if (perms === undefined) return;
|
|
155
|
+
if (!Array.isArray(perms) || perms.some((p) => typeof p !== "string")) { errors.push('"permissions" phải là mảng chuỗi'); return; }
|
|
156
|
+
const host = /^network:(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
157
|
+
for (const p of perms) {
|
|
158
|
+
const known = ["network", "filesystem", "database:core", "admin:sensitive", "files"].includes(p) || host.test(p) || /^files:\d{1,6}(mb|gb)$/.test(p);
|
|
159
|
+
if (!known) errors.push(`Quyền không hợp lệ: "${p}" — dùng network:<domain>, network:*.<domain>, files, files:<N>mb, network, filesystem, database:core, admin:sensitive`);
|
|
160
|
+
}
|
|
161
|
+
if (perms.includes("network")) warnings.push('Quyền "network" toàn phần bị bộ quét cảnh báo — nếu chỉ gọi vài domain, khai báo "network:<domain>" và gọi qua ctx.Http (CMS ≥ 1.8.0)');
|
|
162
|
+
if (perms.includes("filesystem")) warnings.push('Quyền "filesystem" toàn phần bị bộ quét cảnh báo — nếu chỉ cần lưu file của plugin, khai báo "files" (hoặc "files:500mb") và dùng ctx.Files (CMS ≥ 1.8.0)');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Nhãn "%khoá%" trong manifest phải có trong locales/*.json của gói (CMS dịch theo ngôn ngữ người xem, CMS ≥ 1.8.0). */
|
|
166
|
+
function checkLabelKeys(manifest, dicts, kind, errors, warnings) {
|
|
167
|
+
const refs = new Set();
|
|
168
|
+
const visit = (v) => {
|
|
169
|
+
if (typeof v === "string") { const m = /^%([A-Za-z][\w.-]*)%$/.exec(v); if (m) refs.add(m[1]); }
|
|
170
|
+
else if (Array.isArray(v)) v.forEach(visit);
|
|
171
|
+
else if (v && typeof v === "object") Object.values(v).forEach(visit);
|
|
172
|
+
};
|
|
173
|
+
visit(manifest);
|
|
174
|
+
if (!refs.size) return;
|
|
175
|
+
const codes = Object.keys(dicts);
|
|
176
|
+
if (!codes.length) { errors.push(`${kind}.json dùng nhãn "%khoá%" nhưng không có locales/*.json`); return; }
|
|
177
|
+
for (const key of refs) if (!codes.some((c) => key in dicts[c])) errors.push(`Nhãn "%${key}%" không có trong locales/*.json`);
|
|
178
|
+
if (!/>=\s*1\.([8-9]|\d\d)/.test(manifest.requires?.cms ?? "")) warnings.push('nhãn "%khoá%" cần CMS ≥ 1.8.0 (bản cũ hiện nguyên chữ) — nên ghi "requires": { "cms": ">=1.8.0" }');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function checkLocales(dir, errors, warnings) {
|
|
182
|
+
const localesDir = path.join(dir, "locales");
|
|
183
|
+
if (!fs.existsSync(localesDir)) return {};
|
|
184
|
+
const dicts = {};
|
|
185
|
+
for (const file of fs.readdirSync(localesDir).filter((f) => f.endsWith(".json"))) {
|
|
186
|
+
if (!LOCALE_FILE.test(file)) { errors.push(`locales/${file}: tên file phải là mã ngôn ngữ, vd vi.json, en.json`); continue; }
|
|
187
|
+
const dict = readJson(path.join(localesDir, file));
|
|
188
|
+
if (!dict || typeof dict !== "object" || Array.isArray(dict) || !Object.values(dict).every((v) => typeof v === "string")) {
|
|
189
|
+
errors.push(`locales/${file}: phải là JSON { "khoa": "thông điệp" }`); continue;
|
|
190
|
+
}
|
|
191
|
+
const code = file.slice(0, -5);
|
|
192
|
+
dicts[code] = {};
|
|
193
|
+
for (const [key, msg] of Object.entries(dict)) {
|
|
194
|
+
if (!KEY.test(key)) errors.push(`locales/${file}: khoá "${key}" không hợp lệ (chữ, số, dấu chấm, gạch)`);
|
|
195
|
+
try { dicts[code][key] = [...argsOf(new IntlMessageFormat(msg, code, undefined, { ignoreTag: true }).getAst())].sort().join(","); }
|
|
196
|
+
catch (e) { errors.push(`locales/${file}: "${key}" sai cú pháp ICU (${e.message})`); }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const codes = Object.keys(dicts);
|
|
200
|
+
const keys = new Set(codes.flatMap((c) => Object.keys(dicts[c])));
|
|
201
|
+
for (const key of keys) {
|
|
202
|
+
const missing = codes.filter((c) => !(key in dicts[c]));
|
|
203
|
+
if (missing.length) warnings.push(`locales: "${key}" thiếu ở ${missing.map((c) => `${c}.json`).join(", ")}`);
|
|
204
|
+
const vars = new Set(codes.filter((c) => key in dicts[c]).map((c) => dicts[c][key]));
|
|
205
|
+
if (vars.size > 1) errors.push(`locales: biến của "${key}" lệch giữa các ngôn ngữ (${codes.filter((c) => key in dicts[c]).map((c) => `${c}: {${dicts[c][key]}}`).join(" · ")})`);
|
|
206
|
+
}
|
|
207
|
+
return dicts;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* API đã đánh dấu bỏ (deprecated) — hợp đồng công khai (docs/public-contract.md của CMS): còn chạy ít nhất tới bản major kế tiếp,
|
|
212
|
+
* `validate` cảnh báo để người viết plugin/theme chuyển trước. Bỏ API nào thì thêm một dòng ở đây (since = bản CMS/SDK bắt đầu cảnh báo).
|
|
213
|
+
*/
|
|
214
|
+
const DEPRECATIONS = [
|
|
215
|
+
{ re: /(?<![\w$.])registerTranslations\(/g, since: "1.4.0", message: "registerTranslations() đã cũ — dùng locales/*.json + createT(slug)" },
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
/** Mã nguồn của gói (bỏ node_modules, thư mục build). */
|
|
219
|
+
function sourceFiles(dir) {
|
|
220
|
+
const out = [];
|
|
221
|
+
(function walk(d) {
|
|
222
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
223
|
+
if (e.name === "node_modules" || e.name === "dist" || e.name === ".next" || e.name.startsWith(".")) continue;
|
|
224
|
+
const p = path.join(d, e.name);
|
|
225
|
+
if (e.isDirectory()) walk(p); else if (/\.(tsx?|jsx?|mjs)$/.test(e.name)) out.push(p);
|
|
226
|
+
}
|
|
227
|
+
})(dir);
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Cảnh báo từ mã nguồn: API đã bỏ, tên khối defineBlock không theo dạng "slug/tên" (tên không có "/" dành cho khối lõi). */
|
|
232
|
+
function checkSources(dir, manifest) {
|
|
233
|
+
const warnings = [];
|
|
234
|
+
for (const file of sourceFiles(dir)) {
|
|
235
|
+
const code = fs.readFileSync(file, "utf8");
|
|
236
|
+
const at = (i) => `${path.relative(dir, file)}:${code.slice(0, i).split("\n").length}`;
|
|
237
|
+
for (const d of DEPRECATIONS) for (const m of code.matchAll(d.re)) warnings.push(`${at(m.index)}: ${d.message} (đã cũ từ ${d.since}, bỏ ở bản major kế tiếp)`);
|
|
238
|
+
for (const m of code.matchAll(/defineBlock\(\s*\{[^]*?\bname:\s*["'`]([^"'`]+)["'`]/g))
|
|
239
|
+
if (!m[1].startsWith(`${manifest.slug}/`)) warnings.push(`${at(m.index)}: tên khối "${m[1]}" nên là "${manifest.slug}/${m[1].split("/").pop()}" — tên không có "/" dành cho khối lõi của CMS (vd callout, columns), dễ trùng ở bản sau`);
|
|
240
|
+
}
|
|
241
|
+
return warnings;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function findLegacyCalls(srcDir) {
|
|
245
|
+
const out = [];
|
|
246
|
+
if (!fs.existsSync(srcDir)) return out;
|
|
247
|
+
(function walk(d) {
|
|
248
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
249
|
+
const p = path.join(d, e.name);
|
|
250
|
+
if (e.isDirectory()) { if (e.name !== "node_modules") walk(p); continue; }
|
|
251
|
+
if (!/\.(tsx?|jsx?)$/.test(e.name)) continue;
|
|
252
|
+
const code = fs.readFileSync(p, "utf8");
|
|
253
|
+
for (const m of code.matchAll(/(?<![\w$.])__\(\s*["'`]/g)) out.push(`${path.relative(path.dirname(srcDir), p)}:${code.slice(0, m.index).split("\n").length}`);
|
|
254
|
+
}
|
|
255
|
+
})(srcDir);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Import tên cũ "@cms/…" (trước khi SDK lên npm): CMS vẫn chạy được, nhắc đổi sang tên gói npm. */
|
|
260
|
+
function checkOldImports(dir, warnings) {
|
|
261
|
+
const found = new Map();
|
|
262
|
+
const walk = (d, depth = 0) => {
|
|
263
|
+
if (depth > 6 || !fs.existsSync(d)) return;
|
|
264
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
265
|
+
if (["node_modules", "dist", ".next", ".git"].includes(e.name)) continue;
|
|
266
|
+
const p = path.join(d, e.name);
|
|
267
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
268
|
+
else if (/\.(tsx?|jsx?|mjs)$/.test(e.name))
|
|
269
|
+
for (const m of fs.readFileSync(p, "utf8").matchAll(/["'](@cms\/(theme-sdk|admin-sdk|content))["']/g))
|
|
270
|
+
if (!found.has(m[1])) found.set(m[1], path.relative(dir, p));
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
walk(dir);
|
|
274
|
+
const NEW = { "@cms/theme-sdk": "@ecomcx/nexus-theme-sdk", "@cms/admin-sdk": "@ecomcx/nexus-admin-sdk", "@cms/content": "@ecomcx/nexus-content" };
|
|
275
|
+
for (const [old, file] of found)
|
|
276
|
+
warnings.push(`${file}: import "${old}" là tên cũ — đổi sang "${NEW[old]}" (cài: npm i -D ${NEW[old]}; hoặc npx @ecomcx/create-nexus upgrade)`);
|
|
277
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecomcx/create-nexus",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.24.0",
|
|
4
|
+
"description": "CLI tạo, kiểm tra, đóng gói và triển khai theme/plugin cho CMS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-nexus": "bin/cms.mjs",
|
|
8
|
+
"create-cms": "bin/cms.mjs",
|
|
9
|
+
"cms": "bin/cms.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"lib",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@ecomcx/nexus-admin-sdk": "1.24.0",
|
|
18
|
+
"@ecomcx/nexus-content": "^1.13.0",
|
|
19
|
+
"adm-zip": "0.5.18",
|
|
20
|
+
"esbuild": "0.25.12",
|
|
21
|
+
"intl-messageformat": "12.1.3"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/Ecomcx-Jsc/ecomcx-sdk.git",
|
|
29
|
+
"directory": "packages/create-cms"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/Ecomcx-Jsc/ecomcx-sdk#readme",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"provenance": true
|
|
36
|
+
}
|
|
37
|
+
}
|