@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/lib/pack.mjs ADDED
@@ -0,0 +1,147 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+ import AdmZip from "adm-zip";
7
+ import { c, run, backendSources, expandSources } from "./util.mjs";
8
+ import { validate } from "./validate.mjs";
9
+
10
+ const EXCLUDE = new Set(["node_modules", ".git", "dist", ".DS_Store", "bin", "obj", ".next"]);
11
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ function copyFiltered(src, dest, filter = () => true) {
14
+ fs.mkdirSync(dest, { recursive: true });
15
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
16
+ if (EXCLUDE.has(e.name) || !filter(path.join(src, e.name), e)) continue;
17
+ const s = path.join(src, e.name), d = path.join(dest, e.name);
18
+ if (e.isDirectory()) copyFiltered(s, d, filter);
19
+ else if (e.isFile()) fs.copyFileSync(s, d);
20
+ }
21
+ }
22
+
23
+ /** Mã của @ecomcx/nexus-admin-sdk: trong repo CMS dùng src/*.ts; cài từ npm dùng dist/*.js (bản đã biên dịch). */
24
+ function adminSdkFiles() {
25
+ const local = path.resolve(cliDir, "../../admin-sdk/src");
26
+ if (fs.existsSync(path.join(local, "index.ts"))) return { dir: local, ext: ".ts" };
27
+ return { dir: path.join(path.dirname(createRequire(import.meta.url).resolve("@ecomcx/nexus-admin-sdk/package.json")), "dist"), ext: ".js" };
28
+ }
29
+
30
+ /** esbuild plugin: import "./x.css" → chèn <style> khi bundle được nạp. */
31
+ const cssInject = {
32
+ name: "css-inject",
33
+ setup(build) {
34
+ build.onLoad({ filter: /\.css$/ }, async (args) => ({
35
+ contents: `const s=document.createElement("style");s.textContent=${JSON.stringify(await fs.promises.readFile(args.path, "utf8"))};document.head.appendChild(s);`,
36
+ loader: "js",
37
+ }));
38
+ },
39
+ };
40
+
41
+ /** name: "index" (trang quản trị) hoặc "login" (admin.loginEntry — nạp trên trang đăng nhập, vd ô captcha). */
42
+ export async function buildAdmin(dir, outFile, name = "index") {
43
+ const entry = [".tsx", ".ts", ".jsx", ".js"].map((ext) => path.join(dir, "admin", "src", name + ext)).find(fs.existsSync);
44
+ if (!entry) return false;
45
+ const esbuild = await import("esbuild");
46
+ const { dir: sdk, ext } = adminSdkFiles();
47
+ await esbuild.build({
48
+ entryPoints: [entry],
49
+ outfile: outFile,
50
+ bundle: true,
51
+ format: "esm",
52
+ platform: "browser",
53
+ target: "es2022",
54
+ jsx: "automatic",
55
+ minify: true,
56
+ sourcemap: false,
57
+ legalComments: "none",
58
+ plugins: [cssInject],
59
+ alias: {
60
+ react: path.join(sdk, "react" + ext),
61
+ "react/jsx-runtime": path.join(sdk, "jsx-runtime" + ext),
62
+ "react/jsx-dev-runtime": path.join(sdk, "jsx-runtime" + ext),
63
+ "@ecomcx/nexus-admin-sdk": path.join(sdk, "index" + ext),
64
+ // defineBlock (file khối dùng chung admin + website): dùng bản đi kèm CLI — plugin không cần cài gì thêm
65
+ "@ecomcx/nexus-content": createRequire(import.meta.url).resolve("@ecomcx/nexus-content"),
66
+ // Tên cũ (plugin viết trước khi SDK lên npm)
67
+ "@cms/admin-sdk": path.join(sdk, "index" + ext),
68
+ "@cms/content": createRequire(import.meta.url).resolve("@ecomcx/nexus-content"),
69
+ },
70
+ logLevel: "warning",
71
+ });
72
+ return true;
73
+ }
74
+
75
+ export async function pack(dir, { out } = {}) {
76
+ dir = path.resolve(dir);
77
+ const { kind, manifest, errors, warnings } = validate(dir);
78
+ warnings.forEach((w) => console.log(c.yellow(" ! " + w)));
79
+ if (errors.length) throw new Error("Gói không hợp lệ:\n" + errors.map((e) => " - " + e).join("\n"));
80
+
81
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), "cms-pack-"));
82
+ const root = path.join(staging, manifest.slug);
83
+ try {
84
+ if (kind === "theme") {
85
+ copyFiltered(dir, root);
86
+ } else {
87
+ fs.mkdirSync(root, { recursive: true });
88
+ fs.copyFileSync(path.join(dir, "plugin.json"), path.join(root, "plugin.json"));
89
+ for (const extra of ["README.md", "LICENSE", "screenshot.png"]) if (fs.existsSync(path.join(dir, extra))) fs.copyFileSync(path.join(dir, extra), path.join(root, extra));
90
+ // Bản dịch theo khoá (locales/{mã}.json) — CMS nạp cho cả admin lẫn backend
91
+ if (fs.existsSync(path.join(dir, "locales"))) copyFiltered(path.join(dir, "locales"), path.join(root, "locales"), (p) => p.endsWith(".json"));
92
+
93
+ const sources = backendSources(manifest);
94
+ if (sources) {
95
+ // Mã nguồn C#: CMS tự biên dịch khi cài — chỉ chép file .cs
96
+ for (const f of expandSources(dir, sources)) {
97
+ fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true });
98
+ fs.copyFileSync(path.join(dir, f), path.join(root, f));
99
+ }
100
+ } else if (manifest.backend) {
101
+ const csproj = fs.existsSync(path.join(dir, "backend")) ? fs.readdirSync(path.join(dir, "backend")).find((f) => f.endsWith(".csproj")) : null;
102
+ if (csproj) {
103
+ console.log(c.cyan(" • Build backend (dotnet publish)…"));
104
+ const pub = path.join(staging, "publish");
105
+ await run("dotnet", ["publish", path.join(dir, "backend", csproj), "-c", "Release", "-o", pub, "--nologo", "-v", "q"], { quiet: true });
106
+ fs.mkdirSync(path.join(root, "backend"), { recursive: true });
107
+ for (const f of fs.readdirSync(pub)) {
108
+ if (/^Cms\.Plugin\.Sdk\./.test(f) || f.endsWith(".pdb") && !process.env.CMS_PACK_PDB) continue;
109
+ const s = path.join(pub, f);
110
+ if (fs.statSync(s).isDirectory()) copyFiltered(s, path.join(root, "backend", f));
111
+ else fs.copyFileSync(s, path.join(root, "backend", f));
112
+ }
113
+ if (!fs.existsSync(path.join(root, manifest.backend.assembly))) throw new Error(`Build xong nhưng không thấy ${manifest.backend.assembly}`);
114
+ } else {
115
+ copyFiltered(path.join(dir, "backend"), path.join(root, "backend"), (p) => !p.endsWith(".cs"));
116
+ }
117
+ }
118
+
119
+ if (manifest.admin) {
120
+ console.log(c.cyan(" • Bundle admin (esbuild)…"));
121
+ const outFile = path.join(root, manifest.admin.entry);
122
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
123
+ if (!(await buildAdmin(dir, outFile))) fs.copyFileSync(path.join(dir, manifest.admin.entry), outFile);
124
+ if (manifest.admin.loginEntry) {
125
+ const loginOut = path.join(root, manifest.admin.loginEntry);
126
+ fs.mkdirSync(path.dirname(loginOut), { recursive: true });
127
+ if (!(await buildAdmin(dir, loginOut, "login"))) fs.copyFileSync(path.join(dir, manifest.admin.loginEntry), loginOut);
128
+ }
129
+ }
130
+
131
+ if (manifest.frontend) {
132
+ const feDir = path.dirname(path.join(dir, manifest.frontend.entry));
133
+ copyFiltered(feDir, path.join(root, path.relative(dir, feDir)));
134
+ }
135
+ }
136
+
137
+ const outDir = path.resolve(out ?? path.join(dir, "dist"));
138
+ fs.mkdirSync(outDir, { recursive: true });
139
+ const zipPath = path.join(outDir, `${manifest.slug}-${manifest.version}.zip`);
140
+ const zip = new AdmZip();
141
+ zip.addLocalFolder(root, manifest.slug);
142
+ zip.writeZip(zipPath);
143
+ return { kind, manifest, zipPath, size: fs.statSync(zipPath).size };
144
+ } finally {
145
+ fs.rmSync(staging, { recursive: true, force: true });
146
+ }
147
+ }
@@ -0,0 +1,504 @@
1
+ import { ADMIN_SDK, CLI, THEME_SDK, VERSION } from "./npm.mjs";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { c, pascal, slugify, write, findCmsRepo } from "./util.mjs";
5
+
6
+ export function scaffoldTheme(name, { parent, dir, blocks = false } = {}) {
7
+ const slug = slugify(name);
8
+ const root = path.resolve(dir ?? slug);
9
+ if (fs.existsSync(root) && fs.readdirSync(root).length) throw new Error(`Thư mục ${root} đã tồn tại và không rỗng`);
10
+
11
+ write(path.join(root, "theme.json"), JSON.stringify({
12
+ name, slug, version: "1.0.0", author: "", description: `Theme ${name}`,
13
+ ...(parent ? { parent } : {}),
14
+ requires: { cms: blocks ? ">=1.11.0 <2.0.0" : ">=1.8.0 <2.0.0" },
15
+ ...(blocks ? { editing: "blocks" } : {}),
16
+ // Nhãn trong admin: "%khoá%" → locales/{vi,en}.json của theme (CMS ≥ 1.8.0)
17
+ menuLocations: { primary: "%admin.menu.primary%", footer: "%admin.menu.footer%" },
18
+ widgetAreas: { sidebar: "%admin.widgets.sidebar%" },
19
+ supports: ["post-thumbnails", "custom-logo", "comments", "menus", "widgets"],
20
+ settings: [
21
+ { key: "primaryColor", type: "color", label: "%admin.setting.primaryColor%", default: "#0ea5e9", section: "%admin.section.colors%" },
22
+ { key: "footerText", type: "textarea", label: "%admin.setting.footerText%", default: "", section: "%admin.section.footer%" },
23
+ ],
24
+ }, null, 2) + "\n");
25
+ const labels = {
26
+ vi: { "admin.menu.primary": "Menu chính", "admin.menu.footer": "Menu chân trang", "admin.widgets.sidebar": "Sidebar", "admin.setting.primaryColor": "Màu chủ đạo",
27
+ "admin.setting.footerText": "Chữ chân trang", "admin.section.colors": "Màu sắc", "admin.section.footer": "Chân trang" },
28
+ en: { "admin.menu.primary": "Main menu", "admin.menu.footer": "Footer menu", "admin.widgets.sidebar": "Sidebar", "admin.setting.primaryColor": "Accent color",
29
+ "admin.setting.footerText": "Footer text", "admin.section.colors": "Colors", "admin.section.footer": "Footer" },
30
+ };
31
+ for (const [code, dict] of Object.entries(labels)) write(path.join(root, "locales", `${code}.json`), JSON.stringify(dict, null, 2) + "\n");
32
+
33
+ write(path.join(root, "package.json"), JSON.stringify({
34
+ name: `cms-theme-${slug}`, private: true, version: "1.0.0",
35
+ description: "Thêm dependencies npm mà theme cần vào đây — Builder sẽ tự cài khi build.",
36
+ dependencies: {},
37
+ devDependencies: {
38
+ [THEME_SDK]: `^${VERSION}`,
39
+ "@types/react": "^19",
40
+ typescript: "5.9.3",
41
+ },
42
+ }, null, 2) + "\n");
43
+
44
+ write(path.join(root, "tsconfig.json"), JSON.stringify({
45
+ compilerOptions: { target: "ES2022", module: "esnext", moduleResolution: "bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true },
46
+ include: ["**/*.ts", "**/*.tsx"],
47
+ }, null, 2) + "\n");
48
+
49
+ if (blocks && !parent) scaffoldBlockFiles(root);
50
+ else if (!parent) {
51
+ write(path.join(root, "layout.tsx"), `
52
+ import "./styles.css";
53
+ import type { CSSProperties } from "react";
54
+ import { EditableRegion, Menu, setting, type LayoutProps } from "@ecomcx/nexus-theme-sdk";
55
+
56
+ /** Khung chung của mọi trang. Header, footer bọc trong EditableRegion để plugin thay được (vùng chuẩn của core). */
57
+ export default function Layout({ site, settings, data, children }: LayoutProps) {
58
+ return (
59
+ <div className="site" style={{ "--primary": setting(settings, "primaryColor", "#0ea5e9") } as CSSProperties}>
60
+ <EditableRegion name="header" data={data}>
61
+ <header className="site-header">
62
+ <a href="/" className="site-title">{site.title}</a>
63
+ <Menu location="primary" className="primary-nav" />
64
+ </header>
65
+ </EditableRegion>
66
+ <main className="site-main">{children}</main>
67
+ <EditableRegion name="footer" data={data}>
68
+ <footer className="site-footer">{setting(settings, "footerText", "") || \`© \${new Date().getFullYear()} \${site.title}\`}</footer>
69
+ </EditableRegion>
70
+ </div>
71
+ );
72
+ }
73
+ `);
74
+ write(path.join(root, "templates", "index.tsx"), `
75
+ import { Pagination, PostContent, type TemplateProps } from "@ecomcx/nexus-theme-sdk";
76
+
77
+ /** Template dự phòng cuối cùng (giống index.php). */
78
+ export default function Index({ data }: TemplateProps) {
79
+ if (data.post) {
80
+ return (
81
+ <article>
82
+ <h1>{data.post.title}</h1>
83
+ <PostContent post={data.post} />
84
+ </article>
85
+ );
86
+ }
87
+ return (
88
+ <section>
89
+ <h1>{data.title}</h1>
90
+ {(data.posts ?? []).map((p) => (
91
+ <article key={p.id}>
92
+ <h2><a href={p.link}>{p.title}</a></h2>
93
+ <p>{p.excerpt}</p>
94
+ </article>
95
+ ))}
96
+ <Pagination data={data} />
97
+ </section>
98
+ );
99
+ }
100
+ `);
101
+ write(path.join(root, "templates", "single.tsx"), `
102
+ import { Comments, FeaturedImage, PostContent, PostDate, type TemplateProps } from "@ecomcx/nexus-theme-sdk";
103
+
104
+ /** Một bài viết (giống single.php). Tạo single-{postType}.tsx để riêng cho từng post type. */
105
+ export default function Single({ data, site }: TemplateProps) {
106
+ const post = data.post!;
107
+ return (
108
+ <article>
109
+ <h1>{post.title}</h1>
110
+ <p><PostDate post={post} site={site} /></p>
111
+ <FeaturedImage media={post.featuredMedia} />
112
+ <PostContent post={post} />
113
+ <Comments post={post} />
114
+ </article>
115
+ );
116
+ }
117
+ `);
118
+ write(path.join(root, "templates", "404.tsx"), `
119
+ import { SearchForm, type TemplateProps } from "@ecomcx/nexus-theme-sdk";
120
+
121
+ export default function NotFound(_: TemplateProps) {
122
+ return (
123
+ <section>
124
+ <h1>Không tìm thấy trang</h1>
125
+ <SearchForm />
126
+ </section>
127
+ );
128
+ }
129
+ `);
130
+ write(path.join(root, "styles.css"), `
131
+ body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.6; }
132
+ a { color: var(--primary); }
133
+ .site-header, .site-main, .site-footer { max-width: 960px; margin: 0 auto; padding: 16px; }
134
+ .site-header { display: flex; align-items: center; justify-content: space-between; }
135
+ .site-title { font-weight: 700; font-size: 1.25rem; color: inherit; text-decoration: none; }
136
+ .primary-nav .menu { list-style: none; display: flex; gap: 16px; margin: 0; padding: 0; }
137
+ `);
138
+ } else {
139
+ write(path.join(root, "templates", "home.tsx"), `
140
+ import type { TemplateProps } from "@ecomcx/nexus-theme-sdk";
141
+
142
+ /** Child theme: chỉ ghi đè template cần thiết, phần còn lại lấy từ theme cha "${parent}". */
143
+ export default function Home({ data }: TemplateProps) {
144
+ return (
145
+ <div style={{ maxWidth: 960, margin: "0 auto", padding: 16 }}>
146
+ <h1>Trang chủ của ${name}</h1>
147
+ <ul>{(data.posts ?? []).map((p) => <li key={p.id}><a href={p.link}>{p.title}</a></li>)}</ul>
148
+ </div>
149
+ );
150
+ }
151
+ `);
152
+ }
153
+
154
+ write(path.join(root, "extend.tsx"), `
155
+ import { definePlugin } from "@ecomcx/nexus-theme-sdk";
156
+
157
+ /**
158
+ * Theme có thể đăng ký shortcode, widget, slot, filter.
159
+ * Ví dụ shortcode: gõ [xin-chao ten="Lan"] trong nội dung bài viết.
160
+ */
161
+ export default definePlugin({
162
+ shortcodes: {
163
+ "xin-chao": ({ attrs }) => <strong>Xin chào {attrs.ten ?? "bạn"}!</strong>,
164
+ },
165
+ });
166
+ `);
167
+ write(path.join(root, "README.md"), `# ${name}\n\nTheme cho CMS.\n\n- \`npx ${CLI} validate\` — kiểm tra theme\n- \`npx ${CLI} pack\` — đóng gói thành zip để upload trong Giao diện › Thêm mới\n- \`npx ${CLI} dev --password ...\` — tự đóng gói và cài lên CMS local mỗi khi sửa file\n`);
168
+ return { root, slug };
169
+ }
170
+
171
+ const doc = (...content) => JSON.stringify({ version: 1, blocks: [{ id: "main", type: "richtext", data: { type: "doc", content } }] }, null, 2) + "\n";
172
+ const p = (text) => ({ type: "paragraph", content: [{ type: "text", text }] });
173
+ const part = (slug, tagName) => ({ type: "templatePart", attrs: { slug, tagName } });
174
+ const main = (...content) => ({ type: "group", attrs: { padding: "none" }, content });
175
+
176
+ /** Theme dựng bằng khối: layout chỉ là khung trơn; header, footer, từng template là JSON khối — sửa trong Giao diện › Trình dựng. */
177
+ function scaffoldBlockFiles(root) {
178
+ write(path.join(root, "layout.tsx"), `
179
+ import "./styles.css";
180
+ import type { CSSProperties } from "react";
181
+ import { setting, type LayoutProps } from "@ecomcx/nexus-theme-sdk";
182
+
183
+ /** Khung trơn: header, footer và nội dung từng trang là khối (parts/, templates/), sửa trong Giao diện › Trình dựng. */
184
+ export default function Layout({ settings, children }: LayoutProps) {
185
+ return <div className="site" style={{ "--primary": setting(settings, "primaryColor", "#0ea5e9") } as CSSProperties}>{children}</div>;
186
+ }
187
+ `);
188
+ write(path.join(root, "styles.css"), `
189
+ body { margin: 0; font-family: system-ui, sans-serif; line-height: 1.6; }
190
+ a { color: var(--primary); }
191
+ .ecx-template > * { box-sizing: border-box; max-width: 960px; margin-inline: auto; padding-inline: 16px; }
192
+ .ecx-template-part-header { padding-block: 16px; }
193
+ .ecx-template-part-footer { margin-top: 48px; padding-block: 24px; }
194
+ `);
195
+ write(path.join(root, "parts", "header.json"), doc({ type: "columns", attrs: { verticalAlign: "center" }, content: [
196
+ { type: "column", content: [{ type: "siteTitle" }] },
197
+ { type: "column", content: [{ type: "navigation", attrs: { location: "primary" } }] },
198
+ ] }));
199
+ write(path.join(root, "parts", "footer.json"), doc({ type: "copyright" }));
200
+ write(path.join(root, "templates", "index.json"), doc(part("header", "header"), main(
201
+ { type: "query", attrs: { inherit: true }, content: [
202
+ { type: "postTemplate", content: [{ type: "postTitle", attrs: { level: 2, link: true } }, { type: "postDate" }, { type: "postExcerpt" }] },
203
+ { type: "pagination" },
204
+ { type: "queryEmpty", content: [p("Chưa có bài viết.")] },
205
+ ] }), part("footer", "footer")));
206
+ write(path.join(root, "templates", "single.json"), doc(part("header", "header"), main(
207
+ { type: "postTitle" }, { type: "postDate" }, { type: "featuredImage" }, { type: "postContent" }, { type: "postTerms" }, { type: "comments" }), part("footer", "footer")));
208
+ write(path.join(root, "templates", "page.json"), doc(part("header", "header"), main({ type: "postTitle" }, { type: "postContent" }), part("footer", "footer")));
209
+ write(path.join(root, "templates", "404.json"), doc(part("header", "header"), main(
210
+ { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Không tìm thấy trang" }] }, { type: "search" }), part("footer", "footer")));
211
+ }
212
+
213
+ /**
214
+ * Plugin một file C# (CMS ≥ 1.7.0 tự biên dịch khi cài — không cần .NET SDK, csproj, NuGet):
215
+ * bảng dữ liệu EF + trang quản trị tự sinh + form trên website ([cms-form]) + hook.
216
+ */
217
+ /** Mã C# mặc định của plugin: bảng EF + trang quản trị và form website tự sinh + hook + endpoint. */
218
+ function pluginSource(name, slug, cls) {
219
+ return `namespace ${cls};
220
+
221
+ /// Bảng dữ liệu: CMS tự tạo bảng plg_${slug.replace(/-/g, "_")}__entries khi bật plugin, tự thêm cột khi bạn thêm thuộc tính.
222
+ [PluginTable("entries")]
223
+ [AdminList("${name}", Icon = "inbox", Columns = "Name,Email,CreatedAt", Search = "Name,Email,Message")] // trang quản trị tự sinh
224
+ [PublicCreate(PerMinute = 5, SuccessMessage = "Cảm ơn bạn!")] // form trên website: [cms-form plugin="${slug}" table="entries"]
225
+ public class Entry
226
+ {
227
+ public long Id { get; set; }
228
+ [Required, MaxLength(100), Display(Name = "Họ tên")] public string Name { get; set; } = "";
229
+ [Required, EmailAddress, Display(Name = "Email")] public string Email { get; set; } = "";
230
+ [MaxLength(2000), Display(Name = "Lời nhắn")] public string? Message { get; set; }
231
+ [AdminOnly, Display(Name = "Đã xử lý")] public bool Done { get; set; } // chỉ quản trị viên đặt
232
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
233
+ }
234
+
235
+ public sealed class ${cls}Plugin : CmsPlugin
236
+ {
237
+ public override void Register(IPluginContext ctx)
238
+ {
239
+ ctx.Data.Table<Entry>();
240
+
241
+ // Có bản ghi mới từ website → ghi log (đổi thành gửi email, gọi API… tuỳ ý)
242
+ ctx.Hooks.On<PluginDataEvent>("${slug}:entries.created", (e, h) =>
243
+ {
244
+ Console.WriteLine($"[${slug}] mới: {e.Item["name"]} <{e.Item["email"]}>");
245
+ return Task.CompletedTask;
246
+ });
247
+
248
+ // Endpoint riêng (tuỳ chọn): GET /api/plugins/${slug}/stats
249
+ ctx.Endpoints.Map(r => r.MapGet("/stats", async (IPluginDb db) =>
250
+ Results.Ok(new { total = await db.Set<Entry>().CountAsync(), done = await db.Set<Entry>().CountAsync(x => x.Done) })));
251
+ }
252
+ }
253
+ `;
254
+ }
255
+
256
+ /** @deprecated Giữ cho lệnh cũ `plugin --simple` — giờ là mặc định của scaffoldPlugin. */
257
+ export function scaffoldSimplePlugin(name, { dir, tests = false } = {}) {
258
+ return scaffoldPlugin(name, { dir, tests });
259
+ }
260
+
261
+ /** Dự án test xUnit dùng Cms.Plugin.Testing — chạy bằng `create-cms test` (cần .NET SDK). */
262
+ function scaffoldTests(root, cls, slug) {
263
+ write(path.join(root, "tests", `${cls}.Tests.csproj`), `<Project Sdk="Microsoft.NET.Sdk">
264
+ <PropertyGroup>
265
+ <TargetFramework>net10.0</TargetFramework>
266
+ <Nullable>enable</Nullable>
267
+ <ImplicitUsings>enable</ImplicitUsings>
268
+ <IsPackable>false</IsPackable>
269
+ </PropertyGroup>
270
+ <ItemGroup>
271
+ <PackageReference Include="Cms.Plugin.Testing" Version="1.8.0" />
272
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
273
+ <PackageReference Include="xunit" Version="2.9.3" />
274
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
275
+ </ItemGroup>
276
+ </Project>
277
+ `);
278
+ write(path.join(root, "tests", `${cls}Tests.cs`), `using System.Net;
279
+ using System.Net.Http.Json;
280
+ using Cms.Plugin.Testing;
281
+ using Xunit;
282
+
283
+ /// Chạy: create-cms env up (một lần) rồi create-cms test — gói plugin được cài + bật trên CMS thử.
284
+ public class ${cls}Tests : PluginTest
285
+ {
286
+ [Fact]
287
+ public async Task Khach_gui_form_duoc_luu()
288
+ {
289
+ var email = $"t{Guid.NewGuid():N}@test.vn";
290
+ var res = await Anonymous.PostAsJsonAsync(Anonymous.Plugin("/data/entries"), new { name = "An", email, message = "Xin chào", done = true });
291
+ Assert.Equal(HttpStatusCode.OK, res.StatusCode);
292
+ var row = Assert.Single(await Rows("entries", search: email));
293
+ Assert.False(row["done"]!.GetValue<bool>()); // [AdminOnly]: khách không đặt được
294
+ }
295
+
296
+ [Fact]
297
+ public async Task Du_lieu_sai_bi_tu_choi()
298
+ {
299
+ var res = await Anonymous.PostAsJsonAsync(Anonymous.Plugin("/data/entries"), new { name = "", email = "sai" });
300
+ Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
301
+ }
302
+
303
+ [Fact]
304
+ public async Task Tac_gia_khong_xem_duoc_du_lieu()
305
+ {
306
+ var author = await As("author");
307
+ Assert.Equal(HttpStatusCode.Forbidden, (await author.GetAsync($"/api/admin/plugin-data/{Slug}/entries")).StatusCode);
308
+ }
309
+ }
310
+ `);
311
+ }
312
+
313
+ /**
314
+ * Tạo plugin. Mặc định: plugin.json + một file C# (CMS tự biên dịch khi cài — không cần .NET SDK/csproj).
315
+ * backend: "source" (mặc định) | "project" (dự án csproj, khi cần nhiều file + thư viện NuGet) | false.
316
+ * admin / frontend: thêm giao diện React riêng — chỉ khi trang tự sinh ([AdminList], settings) không đủ.
317
+ */
318
+ export function scaffoldPlugin(name, { backend = "source", admin = false, frontend = false, tests = false, dir } = {}) {
319
+ const slug = slugify(name);
320
+ const cls = pascal(name) || "MyPlugin";
321
+ const root = path.resolve(dir ?? slug);
322
+ if (fs.existsSync(root) && fs.readdirSync(root).length) throw new Error(`Thư mục ${root} đã tồn tại và không rỗng`);
323
+ if (backend === true) backend = "project";
324
+ if (!backend && !admin && !frontend) throw new Error("Plugin cần ít nhất một phần: bỏ --no-backend hoặc thêm --admin / --frontend");
325
+
326
+ const manifest = { name, slug, version: "1.0.0", author: "", description: `Plugin ${name}`, requires: { cms: ">=1.8.0 <2.0.0" } };
327
+ if (backend === "source") manifest.backend = `${cls}.cs`;
328
+ if (backend === "project") manifest.backend = { assembly: `backend/${cls}.dll`, entry: `${cls}.${cls}Plugin` };
329
+ if (admin) {
330
+ manifest.admin = { entry: "admin/index.js" };
331
+ // Trang settings + menu khai báo trong plugin.json — trang admin tự viết ở admin/src đọc/ghi cùng nhóm settings
332
+ manifest.settings = { general: { title: "%menu.title%", fields: [{ key: "greeting", type: "text", label: "%settings.greeting%", default: "Xin chào!" }] } };
333
+ manifest.menus = [{ id: slug, title: "%menu.title%", path: `/${slug}`, icon: "puzzle" }];
334
+ }
335
+ if (frontend) manifest.frontend = { entry: "frontend/index.tsx" };
336
+ write(path.join(root, "plugin.json"), JSON.stringify(manifest, null, 2) + "\n");
337
+
338
+ if (admin || backend === "project") {
339
+ // Bản dịch theo khoá (ICU MessageFormat), namespace = slug: admin dùng createT("${slug}"), backend dùng Text.T("${slug}:khoá")
340
+ const messages = {
341
+ vi: { "menu.title": name, "page.description": "Trang quản trị của plugin.", "settings.title": "Cài đặt", "settings.greeting": "Lời chào",
342
+ "settings.saved": "Đã lưu", "settings.save": "Lưu", "metabox.note": "Ghi chú nội bộ", "metabox.title": name },
343
+ en: { "menu.title": name, "page.description": "The plugin's admin page.", "settings.title": "Settings", "settings.greeting": "Greeting",
344
+ "settings.saved": "Saved", "settings.save": "Save", "metabox.note": "Internal note", "metabox.title": name },
345
+ };
346
+ for (const [code, dict] of Object.entries(messages)) write(path.join(root, "locales", `${code}.json`), JSON.stringify(dict, null, 2) + "\n");
347
+ }
348
+
349
+ if (backend === "source") write(path.join(root, `${cls}.cs`), pluginSource(name, slug, cls));
350
+
351
+ if (backend === "project") {
352
+ const repo = findCmsRepo(root);
353
+ // Nguồn Cms.Plugin.Sdk: nuget.org; thêm feed local khi ở trong repo CMS hoặc khi đặt CMS_NUGET_FEED (thư mục chứa .nupkg)
354
+ const feed = process.env.CMS_NUGET_FEED ? path.resolve(process.env.CMS_NUGET_FEED) : repo ? path.join(repo, "sdk", "nuget") : null;
355
+ write(path.join(root, "backend", `${cls}.csproj`), `
356
+ <Project Sdk="Microsoft.NET.Sdk">
357
+ <PropertyGroup>
358
+ <TargetFramework>net10.0</TargetFramework>
359
+ <Nullable>enable</Nullable>
360
+ <ImplicitUsings>enable</ImplicitUsings>
361
+ <RootNamespace>${cls}</RootNamespace>
362
+ <AssemblyName>${cls}</AssemblyName>
363
+ <!-- Plugin được nạp vào CMS: không tự chứa runtime, dùng chung SDK của CMS -->
364
+ <EnableDynamicLoading>true</EnableDynamicLoading>
365
+ </PropertyGroup>
366
+ <ItemGroup>
367
+ <FrameworkReference Include="Microsoft.AspNetCore.App" />
368
+ <PackageReference Include="Cms.Plugin.Sdk" Version="1.8.0">
369
+ <Private>false</Private>
370
+ <ExcludeAssets>runtime</ExcludeAssets>
371
+ </PackageReference>
372
+ </ItemGroup>
373
+ <ItemGroup>
374
+ <!-- using như plugin một file (CMS tự biên dịch) -->
375
+ <Using Include="System.ComponentModel.DataAnnotations" />
376
+ <Using Include="System.Text.Json.Nodes" />
377
+ <Using Include="Microsoft.AspNetCore.Builder" />
378
+ <Using Include="Microsoft.AspNetCore.Http" />
379
+ <Using Include="Microsoft.AspNetCore.Routing" />
380
+ <Using Include="Microsoft.EntityFrameworkCore" />
381
+ <Using Include="Microsoft.Extensions.DependencyInjection" />
382
+ <Using Include="Cms.Plugin.Sdk" />
383
+ <Using Include="Cms.Plugin.Sdk.Contracts" />
384
+ <Using Include="Cms.Plugin.Sdk.Data" />
385
+ <Using Include="Cms.Plugin.Sdk.Hooks" />
386
+ <Using Include="Cms.Plugin.Sdk.Jobs" />
387
+ <Using Include="Cms.Plugin.Sdk.Localization" />
388
+ <Using Include="Cms.Plugin.Sdk.Registry" />
389
+ <Using Include="Cms.Plugin.Sdk.Services" />
390
+ </ItemGroup>
391
+ </Project>
392
+ `);
393
+ if (feed) write(path.join(root, "backend", "nuget.config"), `
394
+ <?xml version="1.0" encoding="utf-8"?>
395
+ <configuration>
396
+ <packageSources>
397
+ <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
398
+ <add key="cms-local" value="${feed}" />
399
+ </packageSources>
400
+ </configuration>
401
+ `);
402
+ write(path.join(root, "backend", `${cls}Plugin.cs`), pluginSource(name, slug, cls));
403
+ }
404
+
405
+ if (admin) {
406
+ write(path.join(root, "admin", "src", "index.tsx"), `
407
+ import { useState } from "react";
408
+ import { Button, Card, Field, Input, PageHeader, api, createT, notify, registerMetaBox, registerPage, useApi } from "@ecomcx/nexus-admin-sdk";
409
+
410
+ /** Dịch theo khoá trong locales/*.json của plugin (namespace "${slug}"). */
411
+ const t = createT("${slug}");
412
+
413
+ /** Trang admin — đường dẫn khớp với ctx.Admin.AddMenu(... "/${slug}" ...) ở backend. */
414
+ function SettingsPage() {
415
+ const { data, reload } = useApi<{ greeting: string }>("/admin/settings/plugins/${slug}/general");
416
+ const [greeting, setGreeting] = useState<string | null>(null);
417
+ const value = greeting ?? data?.greeting ?? "";
418
+ return (
419
+ <>
420
+ <PageHeader title={t("menu.title")} description={t("page.description")} />
421
+ <Card title={t("settings.title")}>
422
+ <Field label={t("settings.greeting")}><Input value={value} onChange={(e) => setGreeting(e.target.value)} /></Field>
423
+ <Button variant="primary" onClick={async () => {
424
+ await api.put("/admin/settings/plugins/${slug}/general", { greeting: value });
425
+ notify.success(t("settings.saved"));
426
+ reload();
427
+ }}>{t("settings.save")}</Button>
428
+ </Card>
429
+ </>
430
+ );
431
+ }
432
+
433
+ registerPage({ path: "/${slug}", title: t("menu.title"), component: SettingsPage });
434
+
435
+ /** Meta box trong trang sửa bài — giá trị lưu vào post.meta khi bấm Lưu. */
436
+ registerMetaBox({
437
+ id: "${slug}-note",
438
+ title: t("metabox.title"),
439
+ postTypes: ["post"],
440
+ context: "side",
441
+ component: ({ meta, setMeta }) => (
442
+ <Field label={t("metabox.note")}>
443
+ <Input value={meta["${slug}_note"] ?? ""} onChange={(e) => setMeta("${slug}_note", e.target.value)} />
444
+ </Field>
445
+ ),
446
+ });
447
+ `);
448
+ }
449
+
450
+ if (frontend) {
451
+ write(path.join(root, "frontend", "index.tsx"), `
452
+ import { definePlugin } from "@ecomcx/nexus-theme-sdk";
453
+
454
+ /** Phần hiển thị ngoài website: shortcode, widget, slot, filter. */
455
+ export default definePlugin({
456
+ shortcodes: {
457
+ // Gõ [${slug}] trong nội dung bài viết
458
+ "${slug}": ({ post }) => <div className="${slug}-box">Xin chào từ plugin ${name}{post ? \` — bài: \${post.title}\` : ""}</div>,
459
+ },
460
+ filters: {
461
+ "document.metadata": (meta) => ({ ...meta, extra: { ...meta.extra, "generator-plugin": "${slug}" } }),
462
+ },
463
+ });
464
+ `);
465
+ }
466
+
467
+ if (admin || frontend) {
468
+ const devDeps = { "@types/react": "^19", typescript: "5.9.3" };
469
+ if (admin) devDeps[ADMIN_SDK] = `^${VERSION}`;
470
+ if (frontend) devDeps[THEME_SDK] = `^${VERSION}`;
471
+ write(path.join(root, "package.json"), JSON.stringify({
472
+ name: `cms-plugin-${slug}`, private: true, version: "1.0.0",
473
+ description: `Dependencies cho giao diện admin/frontend của plugin ${name}`,
474
+ dependencies: {},
475
+ devDependencies: devDeps,
476
+ }, null, 2) + "\n");
477
+ write(path.join(root, "tsconfig.json"), JSON.stringify({
478
+ compilerOptions: { target: "ES2022", module: "esnext", moduleResolution: "bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true },
479
+ include: ["admin/src/**/*", "frontend/**/*"],
480
+ }, null, 2) + "\n");
481
+ }
482
+
483
+ const parts = [
484
+ backend === "source" && `- \`${cls}.cs\` — logic C#: bảng dữ liệu (EF), hook, endpoint. CMS tự biên dịch khi cài (không cần .NET SDK)`,
485
+ backend === "project" && "- `backend/` — dự án C# (csproj): build bằng `dotnet build`, CMS nạp DLL",
486
+ admin && "- `admin/src/` — trang quản trị tự viết (@ecomcx/nexus-admin-sdk); settings + menu khai báo trong plugin.json",
487
+ frontend && "- `frontend/` — hiển thị ngoài website (@ecomcx/nexus-theme-sdk): shortcode, widget, slot",
488
+ (admin || backend === "project") && `- \`locales/\` — bản dịch theo khoá, namespace \`${slug}\``,
489
+ ].filter(Boolean).join("\n");
490
+ write(path.join(root, "README.md"), `# ${name}
491
+
492
+ ${parts}
493
+
494
+ Lệnh (\`npx ${CLI} <lệnh>\`):
495
+
496
+ - \`env up\` (một lần) rồi \`dev\` — cài lên CMS thử, sửa file là cài lại
497
+ - \`check\` — chạy thử như khi cài / nộp Store: manifest, biên dịch, quét bảo mật
498
+ - \`pack\` → \`dist/${slug}-1.0.0.zip\` · \`deploy --activate\` — cài lên CMS thật
499
+ ${backend === "source" ? `
500
+ Trang quản trị: menu **${name}** · Form trên website: \`[cms-form plugin="${slug}" table="entries"]\`
501
+ ` : ""}`);
502
+ if (tests && backend) scaffoldTests(root, cls, slug);
503
+ return { root, slug };
504
+ }