@ecomcx/create-ecomweb 1.30.0 → 1.31.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/bin/ecomweb.mjs +3 -1
- package/lib/deploy.mjs +36 -16
- package/lib/scaffold.mjs +44 -16
- package/lib/util.mjs +2 -1
- package/lib/validate.mjs +53 -0
- package/package.json +3 -3
package/bin/ecomweb.mjs
CHANGED
|
@@ -29,7 +29,7 @@ const HELP = `${c.bold("create-ecomweb")} — công cụ viết theme/plugin cho
|
|
|
29
29
|
${c.cyan("test")} [thư-mục] đóng gói + chạy test xUnit trong tests/ trên CMS thử (Cms.Plugin.Testing)
|
|
30
30
|
${c.cyan("check")} [thư-mục] chạy thử trên CMS: manifest, biên dịch, quét bảo mật (như khi cài / nộp Store)
|
|
31
31
|
${c.cyan("pack")} [thư-mục] [--out dist] đóng gói → dist/<slug>-<version>.zip
|
|
32
|
-
${c.cyan("deploy")} [thư-mục] [--activate] đóng gói + cài lên CMS (--url, --user, --password)
|
|
32
|
+
${c.cyan("deploy")} [thư-mục] [--activate] đóng gói + cài lên CMS (--url, --user, --password; --accept-permissions, --accept-risk)
|
|
33
33
|
${c.cyan("dev")} [thư-mục] tự đóng gói + cài lại mỗi khi sửa file
|
|
34
34
|
${c.cyan("env")} up [--dev] [--build DIR] | status | logs [api|builder] [-f] | down [--purge]
|
|
35
35
|
CMS chạy bằng Docker để thử theme/plugin (--dev: sửa là thấy, không build lại)
|
|
@@ -47,6 +47,8 @@ function connection() {
|
|
|
47
47
|
user: args.user ?? (process.env.CMS_USER ? undefined : saved?.user),
|
|
48
48
|
password: args.password ?? (process.env.CMS_PASSWORD ? undefined : saved?.password),
|
|
49
49
|
activate: !!args.activate,
|
|
50
|
+
acceptPermissions: !!args["accept-permissions"],
|
|
51
|
+
acceptRisk: !!args["accept-risk"],
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
54
|
|
package/lib/deploy.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { c } from "./util.mjs";
|
|
3
3
|
|
|
4
4
|
/** Đăng nhập CMS và upload gói zip (dùng cho `cms deploy` / `cms dev`). */
|
|
5
|
-
export async function deploy({ zipPath, kind, manifest }, { url, user, password, activate }) {
|
|
5
|
+
export async function deploy({ zipPath, kind, manifest }, { url, user, password, activate, acceptPermissions = false, acceptRisk = false }) {
|
|
6
6
|
const base = (url ?? process.env.CMS_URL ?? "http://localhost:5100").replace(/\/$/, "");
|
|
7
7
|
user ??= process.env.CMS_USER ?? "admin";
|
|
8
8
|
password ??= process.env.CMS_PASSWORD;
|
|
@@ -13,30 +13,30 @@ export async function deploy({ zipPath, kind, manifest }, { url, user, password,
|
|
|
13
13
|
const { accessToken } = await login.json();
|
|
14
14
|
const auth = { authorization: `Bearer ${accessToken}` };
|
|
15
15
|
|
|
16
|
+
// CMS hỏi lại khi gói xin quyền mới (409) hoặc bộ quét thấy hành vi rủi ro cao (422, canOverride): quản trị viên phải đồng ý.
|
|
17
|
+
// Trên máy dev của chính bạn: --accept-permissions / --accept-risk để đồng ý thay (giống bấm "Đồng ý" trong trang Plugin).
|
|
18
|
+
const query = new URLSearchParams();
|
|
19
|
+
if (acceptPermissions) query.set("acceptPermissions", manifest.slug);
|
|
20
|
+
if (acceptRisk) query.set("acceptRisk", "true");
|
|
16
21
|
const form = new FormData();
|
|
17
22
|
form.append("file", new Blob([fs.readFileSync(zipPath)], { type: "application/zip" }), zipPath.split("/").pop());
|
|
18
|
-
const res = await fetch(`${base}/api/admin/${kind === "theme" ? "themes" : "plugins"}/upload`, { method: "POST", headers: auth, body: form });
|
|
23
|
+
const res = await fetch(`${base}/api/admin/${kind === "theme" ? "themes" : "plugins"}/upload${query.size ? `?${query}` : ""}`, { method: "POST", headers: auth, body: form });
|
|
19
24
|
const body = await res.json().catch(() => ({}));
|
|
20
25
|
if (!res.ok) {
|
|
21
26
|
const details = body.errors ? "\n" + Object.values(body.errors).flat().map((e) => " - " + e).join("\n") : "";
|
|
27
|
+
const asked = body.data?.permissionsRequired;
|
|
28
|
+
if (res.status === 409 && asked)
|
|
29
|
+
throw new Error(`${body.title} (409)\n Quyền xin thêm: ${asked.added.join(", ")} — đồng ý: chạy lại với --accept-permissions`);
|
|
30
|
+
if (res.status === 422 && body.canOverride) {
|
|
31
|
+
const findings = (body.scan?.findings ?? []).filter((f) => f.severity !== "info").map((f) => ` - [${f.severity}] ${f.message} (${f.file ?? ""})`).join("\n");
|
|
32
|
+
throw new Error(`${body.title} (422)\n${findings}\n Tin nguồn gốc gói: chạy lại với --accept-risk`);
|
|
33
|
+
}
|
|
22
34
|
throw new Error(`${body.title ?? "Upload thất bại"} (${res.status})${details}`);
|
|
23
35
|
}
|
|
24
36
|
console.log(c.green(` ✓ ${body.message}`));
|
|
25
37
|
if (body.restartRequired) await waitHealthy(base);
|
|
26
38
|
|
|
27
|
-
if (body.buildId)
|
|
28
|
-
process.stdout.write(c.dim(` • Đang build #${body.buildId}`));
|
|
29
|
-
for (let i = 0; i < 600; i++) {
|
|
30
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
31
|
-
let b;
|
|
32
|
-
try { b = await (await fetch(`${base}/api/admin/builds/${body.buildId}`, { headers: auth })).json(); }
|
|
33
|
-
catch { process.stdout.write("?"); continue; } // CMS đang khởi động lại
|
|
34
|
-
|
|
35
|
-
if (b.status === "succeeded") { console.log(c.green(" ✓ build thành công")); break; }
|
|
36
|
-
if (b.status === "failed") { console.log(c.red(" ✗ build lỗi\n") + (b.log ?? "").split("\n").slice(-25).join("\n")); throw new Error("Build thất bại"); }
|
|
37
|
-
process.stdout.write(".");
|
|
38
|
-
}
|
|
39
|
-
}
|
|
39
|
+
if (body.buildId) await waitBuild(base, auth, body.buildId);
|
|
40
40
|
|
|
41
41
|
if (kind === "plugin" && activate) {
|
|
42
42
|
const r = await fetch(`${base}/api/admin/plugins/${manifest.slug}/activate`, { method: "POST", headers: auth });
|
|
@@ -44,14 +44,34 @@ export async function deploy({ zipPath, kind, manifest }, { url, user, password,
|
|
|
44
44
|
if (!r.ok) throw new Error(b.title ?? "Kích hoạt thất bại");
|
|
45
45
|
console.log(c.green(` ✓ ${b.message}`));
|
|
46
46
|
if (b.restartRequired) await waitHealthy(base);
|
|
47
|
+
// Plugin có phần website: được bật khi website build xong — chờ để báo đúng kết quả
|
|
48
|
+
if (b.buildId) await waitBuild(base, auth, b.buildId);
|
|
47
49
|
}
|
|
48
50
|
if (kind === "theme" && activate) {
|
|
49
51
|
const r = await fetch(`${base}/api/admin/themes/${manifest.slug}/activate`, { method: "POST", headers: auth });
|
|
50
|
-
|
|
52
|
+
const b = await r.json().catch(() => ({}));
|
|
53
|
+
if (!r.ok) throw new Error(b.title ?? "Kích hoạt theme thất bại");
|
|
54
|
+
// Bật theme = build website với theme mới; website chỉ chuyển theme khi build xong (lỗi → giữ theme cũ)
|
|
55
|
+
if (b.buildId) await waitBuild(base, auth, b.buildId);
|
|
51
56
|
console.log(c.green(" ✓ Đã kích hoạt theme"));
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/** Chờ một lượt build của website; lỗi → in 25 dòng cuối của log và ném lỗi (website vẫn chạy bản trước). */
|
|
61
|
+
async function waitBuild(base, auth, id) {
|
|
62
|
+
process.stdout.write(c.dim(` • Đang build #${id}`));
|
|
63
|
+
for (let i = 0; i < 900; i++) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
65
|
+
let b;
|
|
66
|
+
try { b = await (await fetch(`${base}/api/admin/builds/${id}`, { headers: auth })).json(); }
|
|
67
|
+
catch { process.stdout.write("?"); continue; } // CMS đang khởi động lại
|
|
68
|
+
if (b.status === "succeeded") { console.log(c.green(" ✓ build thành công")); return; }
|
|
69
|
+
if (b.status === "failed") { console.log(c.red(" ✗ build lỗi\n") + (b.log ?? "").split("\n").slice(-25).join("\n")); throw new Error("Build thất bại — website vẫn chạy bản trước"); }
|
|
70
|
+
process.stdout.write(".");
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Build #${id} chưa xong sau 15 phút — xem Giao diện › Lịch sử build`);
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
async function waitHealthy(base) {
|
|
56
76
|
process.stdout.write(c.dim(" • Chờ CMS khởi động lại"));
|
|
57
77
|
await new Promise((r) => setTimeout(r, 1500));
|
package/lib/scaffold.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { c, pascal, slugify, write, findCmsRepo } from "./util.mjs";
|
|
5
5
|
|
|
6
|
+
/** Bản Ecomweb.Plugin.Sdk / .Testing cho plugin dạng dự án và dự án test — cùng bản với core có các API mà SDK npm này nhắm tới (CMS 1.20.0). */
|
|
7
|
+
export const PLUGIN_SDK_VERSION = "1.20.0";
|
|
8
|
+
|
|
6
9
|
export function scaffoldTheme(name, { parent, dir, blocks = false } = {}) {
|
|
7
10
|
const slug = slugify(name);
|
|
8
11
|
const root = path.resolve(dir ?? slug);
|
|
@@ -197,15 +200,25 @@ a { color: var(--primary); }
|
|
|
197
200
|
{ type: "column", content: [{ type: "navigation", attrs: { location: "primary" } }] },
|
|
198
201
|
] }));
|
|
199
202
|
write(path.join(root, "parts", "footer.json"), doc({ type: "copyright" }));
|
|
203
|
+
// SEO: mỗi trang đúng một H1 — trang danh sách / lưu trữ dùng queryTitle (tên chuyên mục, kết quả tìm kiếm…), bài / trang dùng postTitle
|
|
200
204
|
write(path.join(root, "templates", "index.json"), doc(part("header", "header"), main(
|
|
205
|
+
{ type: "queryTitle" },
|
|
206
|
+
{ type: "query", attrs: { inherit: true }, content: [
|
|
207
|
+
{ type: "postTemplate", content: [{ type: "postTitle", attrs: { level: 2, link: true } }, { type: "postDate" }, { type: "postExcerpt" }] },
|
|
208
|
+
{ type: "pagination" },
|
|
209
|
+
{ type: "queryEmpty", content: [p("Chưa có bài viết.")] },
|
|
210
|
+
] }), part("footer", "footer")));
|
|
211
|
+
write(path.join(root, "templates", "archive.json"), doc(part("header", "header"), main(
|
|
212
|
+
{ type: "breadcrumbs" }, { type: "queryTitle" }, { type: "termDescription" },
|
|
201
213
|
{ type: "query", attrs: { inherit: true }, content: [
|
|
202
214
|
{ type: "postTemplate", content: [{ type: "postTitle", attrs: { level: 2, link: true } }, { type: "postDate" }, { type: "postExcerpt" }] },
|
|
203
215
|
{ type: "pagination" },
|
|
204
216
|
{ type: "queryEmpty", content: [p("Chưa có bài viết.")] },
|
|
205
217
|
] }), part("footer", "footer")));
|
|
206
218
|
write(path.join(root, "templates", "single.json"), doc(part("header", "header"), main(
|
|
207
|
-
{ type: "
|
|
208
|
-
|
|
219
|
+
{ type: "breadcrumbs" }, { type: "postTitle" }, { type: "postDate" }, { type: "featuredImage" }, { type: "postContent" }, { type: "postTerms" },
|
|
220
|
+
{ type: "postNavigation" }, { type: "comments" }), part("footer", "footer")));
|
|
221
|
+
write(path.join(root, "templates", "page.json"), doc(part("header", "header"), main({ type: "breadcrumbs" }, { type: "postTitle" }, { type: "postContent" }), part("footer", "footer")));
|
|
209
222
|
write(path.join(root, "templates", "404.json"), doc(part("header", "header"), main(
|
|
210
223
|
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Không tìm thấy trang" }] }, { type: "search" }), part("footer", "footer")));
|
|
211
224
|
}
|
|
@@ -258,8 +271,32 @@ export function scaffoldSimplePlugin(name, { dir, tests = false } = {}) {
|
|
|
258
271
|
return scaffoldPlugin(name, { dir, tests });
|
|
259
272
|
}
|
|
260
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Nguồn gói Ecomweb.Plugin.Sdk / .Testing: nuget.org, cộng feed local khi đặt CMS_NUGET_FEED (thư mục chứa .nupkg) hoặc khi
|
|
276
|
+
* plugin nằm trong repo core (sdk/nuget, tạo bằng scripts/pack-sdk.sh).
|
|
277
|
+
*/
|
|
278
|
+
function nugetFeed(root) {
|
|
279
|
+
if (process.env.CMS_NUGET_FEED) return path.resolve(process.env.CMS_NUGET_FEED);
|
|
280
|
+
const repo = findCmsRepo(root);
|
|
281
|
+
return repo ? path.join(repo, "sdk", "nuget") : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** nuget.config: khai báo XML phải là ký tự đầu tiên của file (dòng trống phía trước → dotnet restore báo lỗi đọc file). */
|
|
285
|
+
function writeNugetConfig(dir, feed) {
|
|
286
|
+
write(path.join(dir, "nuget.config"), `<?xml version="1.0" encoding="utf-8"?>
|
|
287
|
+
<configuration>
|
|
288
|
+
<packageSources>
|
|
289
|
+
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
|
290
|
+
<add key="cms-local" value="${feed}" />
|
|
291
|
+
</packageSources>
|
|
292
|
+
</configuration>
|
|
293
|
+
`);
|
|
294
|
+
}
|
|
295
|
+
|
|
261
296
|
/** Dự án test xUnit dùng Ecomweb.Plugin.Testing (namespace Cms.Plugin.Testing) — chạy bằng `create-ecomweb test` (cần .NET SDK). */
|
|
262
297
|
function scaffoldTests(root, cls, slug) {
|
|
298
|
+
const feed = nugetFeed(root);
|
|
299
|
+
if (feed) writeNugetConfig(path.join(root, "tests"), feed);
|
|
263
300
|
write(path.join(root, "tests", `${cls}.Tests.csproj`), `<Project Sdk="Microsoft.NET.Sdk">
|
|
264
301
|
<PropertyGroup>
|
|
265
302
|
<TargetFramework>net10.0</TargetFramework>
|
|
@@ -268,7 +305,7 @@ function scaffoldTests(root, cls, slug) {
|
|
|
268
305
|
<IsPackable>false</IsPackable>
|
|
269
306
|
</PropertyGroup>
|
|
270
307
|
<ItemGroup>
|
|
271
|
-
<PackageReference Include="Ecomweb.Plugin.Testing" Version="
|
|
308
|
+
<PackageReference Include="Ecomweb.Plugin.Testing" Version="${PLUGIN_SDK_VERSION}" />
|
|
272
309
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
273
310
|
<PackageReference Include="xunit" Version="2.9.3" />
|
|
274
311
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
@@ -349,9 +386,7 @@ export function scaffoldPlugin(name, { backend = "source", admin = false, fronte
|
|
|
349
386
|
if (backend === "source") write(path.join(root, `${cls}.cs`), pluginSource(name, slug, cls));
|
|
350
387
|
|
|
351
388
|
if (backend === "project") {
|
|
352
|
-
const
|
|
353
|
-
// Nguồn Ecomweb.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;
|
|
389
|
+
const feed = nugetFeed(root);
|
|
355
390
|
write(path.join(root, "backend", `${cls}.csproj`), `
|
|
356
391
|
<Project Sdk="Microsoft.NET.Sdk">
|
|
357
392
|
<PropertyGroup>
|
|
@@ -365,7 +400,7 @@ export function scaffoldPlugin(name, { backend = "source", admin = false, fronte
|
|
|
365
400
|
</PropertyGroup>
|
|
366
401
|
<ItemGroup>
|
|
367
402
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
|
368
|
-
<PackageReference Include="Ecomweb.Plugin.Sdk" Version="
|
|
403
|
+
<PackageReference Include="Ecomweb.Plugin.Sdk" Version="${PLUGIN_SDK_VERSION}">
|
|
369
404
|
<Private>false</Private>
|
|
370
405
|
<ExcludeAssets>runtime</ExcludeAssets>
|
|
371
406
|
</PackageReference>
|
|
@@ -390,15 +425,7 @@ export function scaffoldPlugin(name, { backend = "source", admin = false, fronte
|
|
|
390
425
|
</ItemGroup>
|
|
391
426
|
</Project>
|
|
392
427
|
`);
|
|
393
|
-
if (feed)
|
|
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
|
-
`);
|
|
428
|
+
if (feed) writeNugetConfig(path.join(root, "backend"), feed);
|
|
402
429
|
write(path.join(root, "backend", `${cls}Plugin.cs`), pluginSource(name, slug, cls));
|
|
403
430
|
}
|
|
404
431
|
|
|
@@ -451,6 +478,7 @@ registerMetaBox({
|
|
|
451
478
|
write(path.join(root, "frontend", "index.tsx"), `
|
|
452
479
|
import { definePlugin } from "@ecomcx/ecomweb-theme-sdk";
|
|
453
480
|
|
|
481
|
+
|
|
454
482
|
/** Phần hiển thị ngoài website: shortcode, widget, slot, filter. */
|
|
455
483
|
export default definePlugin({
|
|
456
484
|
shortcodes: {
|
package/lib/util.mjs
CHANGED
|
@@ -85,7 +85,8 @@ export function findCmsRepo(start = process.cwd()) {
|
|
|
85
85
|
if (process.env.CMS_REPO && fs.existsSync(path.join(process.env.CMS_REPO, "sdk"))) return path.resolve(process.env.CMS_REPO);
|
|
86
86
|
let dir = path.resolve(start);
|
|
87
87
|
for (let i = 0; i < 8; i++) {
|
|
88
|
-
|
|
88
|
+
// Repo core: backend/src/Cms.Plugin.Sdk (bố cục cũ còn có sdk/theme-sdk — SDK npm nay ở repo ecomweb-sdk riêng)
|
|
89
|
+
if (fs.existsSync(path.join(dir, "backend", "src", "Cms.Plugin.Sdk")) || (fs.existsSync(path.join(dir, "sdk", "theme-sdk", "package.json")) && fs.existsSync(path.join(dir, "backend")))) return dir;
|
|
89
90
|
const parent = path.dirname(dir);
|
|
90
91
|
if (parent === dir) break;
|
|
91
92
|
dir = parent;
|
package/lib/validate.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export function validate(dir) {
|
|
|
34
34
|
if (!okShape) errors.push('translations phải có dạng { "en": { "Chuỗi gốc": "Bản dịch" } }');
|
|
35
35
|
}
|
|
36
36
|
if (!manifest.requires?.cms) warnings.push('Nên khai báo requires.cms, vd ">=1.0.0 <2.0.0"');
|
|
37
|
+
else if (narrowCmsUpperBound(manifest.requires.cms)) warnings.push(narrowCmsUpperBound(manifest.requires.cms));
|
|
37
38
|
checkPermissions(manifest.permissions, errors, warnings);
|
|
38
39
|
checkCsp(manifest.csp, errors);
|
|
39
40
|
|
|
@@ -65,6 +66,7 @@ export function validate(dir) {
|
|
|
65
66
|
if (manifest.editing !== "blocks" && folder === "templates" && fs.readdirSync(d).some((f) => f.endsWith(".json")))
|
|
66
67
|
warnings.push('templates/*.json chỉ được dùng khi theme.json có "editing": "blocks"');
|
|
67
68
|
}
|
|
69
|
+
warnings.push(...seoStructure(dir));
|
|
68
70
|
for (const f of manifest.settings ?? []) {
|
|
69
71
|
if (!f.key) errors.push("settings: thiếu key");
|
|
70
72
|
if (!FIELD_TYPES.includes(f.type)) errors.push(`settings.${f.key}: type "${f.type}" không hợp lệ (${FIELD_TYPES.join(", ")})`);
|
|
@@ -287,3 +289,54 @@ function contentDeclarations(manifest, errors, checkNames = false) {
|
|
|
287
289
|
if (checkNames) for (const name of [...Object.keys(manifest.postTypes ?? {}), ...Object.keys(manifest.taxonomies ?? {})])
|
|
288
290
|
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(name)) errors.push(`Tên kiểu nội dung / phân loại "${name}" không hợp lệ (a-z, 0-9, _ -, bắt đầu bằng chữ, tối đa 32 ký tự)`);
|
|
289
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Giới hạn trên hẹp hơn hết major (vd "<1.9.1", "<=1.9.0", "~1.6.0"): core 1.9.2 vẫn cài được (chỉ cảnh báo "chưa kiểm thử",
|
|
295
|
+
* CMS ≥ 1.20.0) nhưng core cũ hơn 1.20.0 sẽ TỪ CHỐI cài. Core không bớt API trong một major → nên để mở tới major kế tiếp.
|
|
296
|
+
*/
|
|
297
|
+
export function narrowCmsUpperBound(range) {
|
|
298
|
+
const parts = String(range).trim().split(/\s+/);
|
|
299
|
+
const min = parts.map((p) => /^(?:>=|\^|~)?v?(\d+)\.(\d+)/.exec(p)).find(Boolean);
|
|
300
|
+
const major = min ? Number(min[1]) : null;
|
|
301
|
+
const narrow = parts.some((p) => {
|
|
302
|
+
const m = /^(<=?|~)v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(p);
|
|
303
|
+
if (!m) return false;
|
|
304
|
+
if (m[1] === "~") return true;
|
|
305
|
+
const [maj, minor, patch] = [Number(m[2]), Number(m[3] ?? 0), Number(m[4] ?? 0)];
|
|
306
|
+
return m[1] === "<=" || minor !== 0 || patch !== 0 || (major !== null && maj === major);
|
|
307
|
+
});
|
|
308
|
+
if (!narrow) return null;
|
|
309
|
+
const suggest = major !== null && min ? `">=${min[1]}.${min[2]}.0 <${major + 1}.0.0"` : '">=1.0.0 <2.0.0"';
|
|
310
|
+
return `requires.cms "${range}" chặn trước các bản core sau này dù vẫn tương thích (trong một major core chỉ thêm API, không bớt) — nên dùng ${suggest}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* SEO: mỗi template khối (ghép cả phần dùng chung) nên có đúng một H1 — tiêu đề bài (postTitle), tiêu đề trang lưu trữ (queryTitle)
|
|
315
|
+
* hoặc khối tiêu đề cấp 1. Header có tên website là H1 làm mọi trang thành hai H1.
|
|
316
|
+
*/
|
|
317
|
+
export function seoStructure(dir) {
|
|
318
|
+
const out = [];
|
|
319
|
+
const read = (folder, name) => { try { const j = JSON.parse(fs.readFileSync(path.join(dir, folder, `${name}.json`), "utf8")); return j.blocks?.find((b) => b.type === "richtext")?.data ?? (j.type === "doc" ? j : null); } catch { return null; } };
|
|
320
|
+
const h1s = (node, seen = new Set(), inLoop = false) => {
|
|
321
|
+
if (!node || typeof node !== "object") return 0;
|
|
322
|
+
let n = 0;
|
|
323
|
+
const lvl = node.attrs?.level;
|
|
324
|
+
if (!inLoop && ((node.type === "postTitle" || node.type === "queryTitle") && (lvl ?? 1) === 1)) n++;
|
|
325
|
+
if (node.type === "heading" && lvl === 1) n++;
|
|
326
|
+
if (node.type === "siteTitle" && lvl === 1) n++;
|
|
327
|
+
if (node.type === "templatePart" && node.attrs?.slug && !seen.has(node.attrs.slug)) { seen.add(node.attrs.slug); n += h1s(read("parts", node.attrs.slug), seen, inLoop); }
|
|
328
|
+
const loop = inLoop || node.type === "postTemplate";
|
|
329
|
+
for (const c of node.content ?? []) n += h1s(c, seen, loop);
|
|
330
|
+
return n;
|
|
331
|
+
};
|
|
332
|
+
const tdir = path.join(dir, "templates");
|
|
333
|
+
if (!fs.existsSync(tdir)) return out;
|
|
334
|
+
for (const f of fs.readdirSync(tdir).filter((x) => x.endsWith(".json"))) {
|
|
335
|
+
const name = f.replace(/\.json$/, "");
|
|
336
|
+
const n = h1s(read("templates", name));
|
|
337
|
+
if (n === 0) out.push(`SEO: templates/${f} không có thẻ H1 — thêm "Tiêu đề bài" (postTitle) hoặc "Tiêu đề trang lưu trữ" (queryTitle)`);
|
|
338
|
+
else if (n > 1) out.push(`SEO: templates/${f} có ${n} thẻ H1 (tính cả header / footer) — mỗi trang chỉ nên có một H1`);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecomcx/create-ecomweb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "CLI tạo, kiểm tra, đóng gói và triển khai theme/plugin cho Ecomweb",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@ecomcx/ecomweb-admin-sdk": "1.
|
|
16
|
+
"@ecomcx/ecomweb-admin-sdk": "1.31.0",
|
|
17
17
|
"@ecomcx/ecomweb-content": "^1.14.0",
|
|
18
|
-
"adm-zip": "0.
|
|
18
|
+
"adm-zip": "0.6.1",
|
|
19
19
|
"esbuild": "0.25.12",
|
|
20
20
|
"intl-messageformat": "12.1.3"
|
|
21
21
|
},
|