czon 0.6.4 → 0.6.6

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.
@@ -51,25 +51,24 @@ const sitemap_1 = require("./sitemap");
51
51
  /**
52
52
  * 验证构建配置
53
53
  */
54
- async function validateConfig(options) {
55
- const { verbose = false } = options;
56
- if (verbose) {
57
- console.log(`🚀 Starting CZON build...`);
58
- if (options.langs && options.langs.length > 0) {
59
- console.log(`🌐 Target languages: ${options.langs.join(', ')}`);
60
- }
61
- console.log(`🔍 Verbose mode enabled`);
54
+ async function applyConfig(options) {
55
+ metadata_1.MetaData.options ?? (metadata_1.MetaData.options = {});
56
+ if (options.langs !== undefined) {
57
+ console.log(`🌐 Target languages: ${options.langs.join(', ')}`);
58
+ metadata_1.MetaData.options.langs = options.langs;
59
+ }
60
+ if (options.baseUrl !== undefined) {
61
+ metadata_1.MetaData.options.baseUrl = options.baseUrl;
62
62
  }
63
- metadata_1.MetaData.options = options;
64
63
  }
65
64
  /**
66
65
  * 构建管道(函数组合)
67
66
  */
68
67
  async function buildPipeline(options) {
68
+ // 验证配置
69
+ await applyConfig(options);
69
70
  // 安装 OpenCode 代理到全局目录
70
71
  await (0, opencode_1.installAgentsToGlobal)();
71
- // 验证配置
72
- await validateConfig(options);
73
72
  // 清理输出目录
74
73
  await fs.rm(paths_1.CZON_DIST_DIR, { recursive: true, force: true });
75
74
  // 确保 .czon/.gitignore 文件
package/dist/cli.js CHANGED
@@ -1,11 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
3
36
  Object.defineProperty(exports, "__esModule", { value: true });
4
37
  const clipanion_1 = require("clipanion");
5
38
  const dotenv_1 = require("dotenv");
39
+ const fs = __importStar(require("fs/promises"));
40
+ const path = __importStar(require("path"));
6
41
  const pipeline_1 = require("./build/pipeline");
7
42
  const findEntries_1 = require("./findEntries");
8
43
  const summary_1 = require("./process/summary");
44
+ const writeFile_1 = require("./utils/writeFile");
9
45
  const version_1 = require("./version");
10
46
  // 加载 .env 文件中的环境变量
11
47
  (0, dotenv_1.config)();
@@ -48,13 +84,9 @@ class SummaryCommand extends clipanion_1.Command {
48
84
  this.model = clipanion_1.Option.String('--model', 'opencode/big-pickle', {
49
85
  description: 'OpenCode model to use for summarization',
50
86
  });
51
- this.verbose = clipanion_1.Option.Boolean('-v,--verbose');
52
87
  }
53
88
  async execute() {
54
89
  try {
55
- if (this.verbose) {
56
- process.env.VERBOSE = 'true';
57
- }
58
90
  await (0, summary_1.processSummary)(this.model);
59
91
  return 0;
60
92
  }
@@ -86,14 +118,51 @@ SummaryCommand.usage = clipanion_1.Command.Usage({
86
118
  Examples:
87
119
  $ czon summary
88
120
  $ czon summary --model opencode/gpt-4o
89
- $ czon summary --verbose
121
+ `,
122
+ });
123
+ // ConfigGithub 命令
124
+ class ConfigGithubCommand extends clipanion_1.Command {
125
+ async execute() {
126
+ try {
127
+ const targetDir = process.cwd();
128
+ const templatePath = 'templates/pages.yml';
129
+ const targetPath = path.join(targetDir, '.github', 'workflows', 'pages.yml');
130
+ // 检查模板文件是否存在
131
+ try {
132
+ await fs.access(templatePath);
133
+ }
134
+ catch {
135
+ this.context.stderr.write(`❌ Template file not found: ${templatePath}\n`);
136
+ return 1;
137
+ }
138
+ // 读取模板文件
139
+ const content = await fs.readFile(templatePath, 'utf-8');
140
+ // 确保目标目录存在并写入文件
141
+ await (0, writeFile_1.writeFile)(targetPath, content);
142
+ this.context.stdout.write(`✅ GitHub Actions workflow copied to ${targetPath}\n`);
143
+ return 0;
144
+ }
145
+ catch (error) {
146
+ this.context.stderr.write(`❌ Failed to copy workflow template: ${error}\n`);
147
+ return 1;
148
+ }
149
+ }
150
+ }
151
+ ConfigGithubCommand.paths = [['config', 'github']];
152
+ ConfigGithubCommand.usage = clipanion_1.Command.Usage({
153
+ description: 'Copy GitHub Pages deployment workflow template to .github/workflows/pages.yml',
154
+ details: `
155
+ This command copies the GitHub Pages deployment workflow template (templates/pages.yml)
156
+ to the current directory's .github/workflows/pages.yml location.
157
+
158
+ Examples:
159
+ $ czon config github
90
160
  `,
91
161
  });
92
162
  // Build 命令
93
163
  class BuildCommand extends clipanion_1.Command {
94
164
  constructor() {
95
165
  super(...arguments);
96
- this.verbose = clipanion_1.Option.Boolean('-v,--verbose');
97
166
  this.lang = clipanion_1.Option.Array('--lang', {
98
167
  description: 'Target languages for translation (e.g., en-US, ja-JP)',
99
168
  });
@@ -104,7 +173,6 @@ class BuildCommand extends clipanion_1.Command {
104
173
  async execute() {
105
174
  try {
106
175
  await (0, pipeline_1.buildSite)({
107
- verbose: this.verbose,
108
176
  langs: this.lang,
109
177
  baseUrl: this.baseUrl,
110
178
  });
@@ -139,6 +207,7 @@ const cli = new clipanion_1.Cli({
139
207
  cli.register(BuildCommand);
140
208
  cli.register(LsFilesCommand);
141
209
  cli.register(SummaryCommand);
210
+ cli.register(ConfigGithubCommand);
142
211
  // 运行 CLI
143
212
  cli.runExit(process.argv.slice(2), {
144
213
  ...clipanion_1.Cli.defaultContext,
@@ -11,7 +11,6 @@ const formatFileForCategoryExtraction = (file) => {
11
11
  ].join('\n');
12
12
  };
13
13
  const processExtractCategory = async () => {
14
- const verbose = metadata_1.MetaData.options.verbose;
15
14
  // 如果所有文件都已经有 category,则跳过本阶段
16
15
  const allHaveCategory = metadata_1.MetaData.files.filter(f => f.metadata).every(file => file.category);
17
16
  if (allHaveCategory) {
@@ -19,11 +18,9 @@ const processExtractCategory = async () => {
19
18
  return;
20
19
  }
21
20
  const markdownFiles = metadata_1.MetaData.files.filter(f => f.path.endsWith('.md') && f.metadata);
22
- if (verbose) {
23
- console.info(`📂 Extracting categories for ${markdownFiles.length} markdown files...`);
24
- for (const file of markdownFiles) {
25
- console.info(` - File: ${file.path}`);
26
- }
21
+ console.info(`📂 Extracting categories for ${markdownFiles.length} markdown files...`);
22
+ for (const file of markdownFiles) {
23
+ console.info(` - File: ${file.path}`);
27
24
  }
28
25
  // 提取类别标签列表
29
26
  const categories = await (0, openai_1.completeMessages)([
@@ -14,11 +14,10 @@ const writeFile_1 = require("../utils/writeFile");
14
14
  * 存储母语文件到 .czon/src
15
15
  */
16
16
  async function storeNativeFiles() {
17
- const { options: { verbose }, files, } = metadata_1.MetaData;
17
+ const { files } = metadata_1.MetaData;
18
18
  for (const file of metadata_1.MetaData.files) {
19
19
  if (!file.path.endsWith('.md')) {
20
- if (verbose)
21
- console.info(`ℹ️ Skipping ${file.path}, not a Markdown file`);
20
+ console.info(`ℹ️ Skipping ${file.path}, not a Markdown file`);
22
21
  continue;
23
22
  }
24
23
  try {
@@ -41,7 +40,7 @@ async function storeNativeFiles() {
41
40
  console.warn(`⚠️ Failed to store native file ${file.path}:`, error);
42
41
  }
43
42
  }
44
- if (verbose && files.length > 0) {
43
+ if (files.length > 0) {
45
44
  console.log(`💾 Stored ${files.length} native language files to .czon/src`);
46
45
  }
47
46
  }
@@ -9,8 +9,7 @@ const metadata_1 = require("../metadata");
9
9
  */
10
10
  async function extractMetadataByAI() {
11
11
  const { files } = metadata_1.MetaData;
12
- if (metadata_1.MetaData.options.verbose)
13
- console.log(`🤖 Running AI metadata extraction...`);
12
+ console.log(`🤖 Running AI metadata extraction...`);
14
13
  console.log(`🤖 Processing ${files.length} files with AI...`);
15
14
  const results = await Promise.allSettled(files.map(async (file) => {
16
15
  if (!file.path.endsWith('.md')) {
@@ -89,37 +89,32 @@ const translateWithLLMCall = async (sourcePath, targetPath, lang) => {
89
89
  * 处理翻译
90
90
  */
91
91
  async function processTranslations() {
92
- const { files, options: { langs = [], verbose }, } = metadata_1.MetaData;
92
+ const { files, options: { langs = [] }, } = metadata_1.MetaData;
93
93
  await Promise.all(files.flatMap(async (file) => {
94
94
  if (!file.path.endsWith('.md')) {
95
- if (verbose)
96
- console.info(`ℹ️ Skipping ${file.path}, not a Markdown file`);
95
+ console.info(`ℹ️ Skipping ${file.path}, not a Markdown file`);
97
96
  return;
98
97
  }
99
98
  return Promise.all(langs.map(async (lang) => {
100
- if (verbose)
101
- console.info(`📄 Processing file for translation: ${file.path}`);
99
+ console.info(`📄 Processing file for translation: ${file.path}`);
102
100
  if (!file.metadata) {
103
101
  console.warn(`⚠️ Missing metadata for file: ${file.path}, skipping translation.`);
104
102
  return;
105
103
  }
106
- if (verbose)
107
- console.log(`🌐 Translating to ${lang}...`);
104
+ console.log(`🌐 Translating to ${lang}...`);
108
105
  // 存储翻译文件到 .czon/src/{lang}
109
106
  const sourcePath = path_1.default.join(paths_1.CZON_SRC_DIR, file.metadata.inferred_lang, file.path); // 使用已经加强的母语文件路径
110
107
  const targetPath = path_1.default.join(paths_1.CZON_SRC_DIR, lang, file.path);
111
108
  try {
112
109
  const content = await (0, promises_1.readFile)(sourcePath, 'utf-8');
113
110
  if (file.metadata.inferred_lang === lang) {
114
- if (verbose)
115
- console.log(`ℹ️ Skipping translation for ${file.path}, already in target language`);
111
+ console.log(`ℹ️ Skipping translation for ${file.path}, already in target language`);
116
112
  return;
117
113
  }
118
114
  const hash = (0, sha256_1.sha256)(content);
119
115
  const isTargetExists = await (0, promises_1.access)(targetPath).then(() => true, () => false);
120
116
  if (hash === file.nativeMarkdownHash && isTargetExists) {
121
- if (verbose)
122
- console.info(`ℹ️ Content unchanged for ${file.path}, skipping translation.`);
117
+ console.info(`ℹ️ Content unchanged for ${file.path}, skipping translation.`);
123
118
  return;
124
119
  }
125
120
  await translateWithLLMCall(sourcePath, targetPath, lang);
@@ -129,8 +124,7 @@ async function processTranslations() {
129
124
  // translationMeta.token_used = translatedResponse.usage; // 记录 token 使用情况
130
125
  // 存储已增强内容的哈希值
131
126
  file.nativeMarkdownHash = hash;
132
- if (verbose)
133
- console.log(`✅ Translated file saved: ${targetPath}`);
127
+ console.log(`✅ Translated file saved: ${targetPath}`);
134
128
  }
135
129
  catch (error) {
136
130
  console.error(`❌ Failed to translate to ${lang}:`, error);
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.completeMessages = void 0;
4
- const metadata_1 = require("../metadata");
5
4
  const startTime = Date.now();
6
5
  let totalContentGenerated = 0;
7
6
  const processingTaskIds = new Set();
@@ -68,11 +67,9 @@ const completeMessages = async (messages, options) => {
68
67
  requestBody.response_format = options.response_format;
69
68
  }
70
69
  // 打印请求信息 (for debug)
71
- // if (MetaData.options.verbose) {
72
70
  // for (const msg of messages) {
73
71
  // console.info(`💬 [${msg.role}] ${msg.content}`);
74
72
  // }
75
- // }
76
73
  const response = await fetch(`${baseUrl}/chat/completions`, {
77
74
  method: 'POST',
78
75
  headers: {
@@ -165,9 +162,7 @@ const completeMessages = async (messages, options) => {
165
162
  total_tokens: 0,
166
163
  },
167
164
  };
168
- if (metadata_1.MetaData.options.verbose) {
169
- console.info('🤖 AI Token Usages', finalResponse.usage);
170
- }
165
+ console.info('🤖 AI Token Usages', finalResponse.usage);
171
166
  // 验证响应
172
167
  if (!finalResponse.choices?.[0]?.message?.content?.trim()) {
173
168
  throw new Error('Empty response from OpenAI API');
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.installAgentsToGlobal = exports.runOpenCode = void 0;
4
4
  const promises_1 = require("fs/promises");
5
5
  const path_1 = require("path");
6
- const metadata_1 = require("../metadata");
7
6
  const paths_1 = require("../paths");
8
7
  const writeFile_1 = require("../utils/writeFile");
9
8
  function parseModelString(model) {
@@ -34,10 +33,7 @@ const runOpenCode = (prompt, options) => {
34
33
  const signal = options?.signal;
35
34
  const cwd = options?.cwd || process.cwd();
36
35
  const agent = options?.agent;
37
- const verbose = metadata_1.MetaData.options.verbose;
38
- if (verbose) {
39
- console.info(`🛠️ Running OpenCode with model: ${model}, agent: ${agent || 'none'}, prompt: ${prompt}`);
40
- }
36
+ console.info(`🛠️ Running OpenCode with model: ${model}, agent: ${agent || 'none'}, prompt: ${prompt}`);
41
37
  return new Promise(async (resolve, reject) => {
42
38
  const agentInfo = agent ? ` with agent ${agent}` : '';
43
39
  console.info(`🚀 Running OpenCode with model ${model}${agentInfo}`);
@@ -125,20 +121,15 @@ const runOpenCode = (prompt, options) => {
125
121
  };
126
122
  exports.runOpenCode = runOpenCode;
127
123
  const installAgentsToGlobal = async () => {
128
- const { verbose } = metadata_1.MetaData.options;
129
124
  const installedAgents = await (0, promises_1.readdir)(paths_1.LOCAL_OPENCODE_AGENT_DIR)
130
125
  .then(files => files.filter(f => f.startsWith('czon-')))
131
126
  .catch(() => []);
132
127
  // 3. Copy local agents from .opencode/agent to global directory
133
128
  for (const agentFile of installedAgents) {
134
- if (verbose) {
135
- console.log(`📁 Installing OpenCode agent: ${agentFile} to global directory...`);
136
- }
129
+ console.log(`📁 Installing OpenCode agent: ${agentFile} to global directory...`);
137
130
  await (0, writeFile_1.writeFile)((0, path_1.join)(paths_1.GLOBAL_OPENCODE_AGENT_DIR, agentFile), await (0, promises_1.readFile)((0, path_1.join)(paths_1.LOCAL_OPENCODE_AGENT_DIR, agentFile)));
138
131
  }
139
- if (verbose) {
140
- console.log(`✅ Installed ${installedAgents.length} OpenCode agents to global directory.`);
141
- }
132
+ console.log(`✅ Installed ${installedAgents.length} OpenCode agents to global directory.`);
142
133
  };
143
134
  exports.installAgentsToGlobal = installAgentsToGlobal;
144
135
  //# sourceMappingURL=opencode.js.map
@@ -51,7 +51,7 @@ const ContentPage = props => {
51
51
  react_1.default.createElement(PageLayout_1.PageLayout, { header: react_1.default.createElement(CZONHeader_1.CZONHeader, { ctx: props.ctx, lang: props.lang, file: props.file }), navigator: react_1.default.createElement("nav", { className: "sidebar hidden md:block" },
52
52
  react_1.default.createElement(Navigator_1.Navigator, { ctx: props.ctx, file: props.file, lang: props.lang })), rightSidebar: react_1.default.createElement("aside", null,
53
53
  react_1.default.createElement("h2", { className: "text-2xl font-semibold mb-2" }, "Table of Contents"),
54
- props.content.headings.map(heading => (react_1.default.createElement("a", { key: heading.id, href: `#${heading.id}`, className: `block ps-${heading.depth * 4} mb-2` }, heading.text)))), main: react_1.default.createElement("main", { className: "content" },
54
+ props.content.headings.map(heading => (react_1.default.createElement("a", { key: heading.id, href: `#${heading.id}`, className: `block ps-${heading.depth * 4} mb-2` }, heading.text)))), main: react_1.default.createElement("main", { className: "content max-w-4xl mx-auto my-8 px-4" },
55
55
  react_1.default.createElement(ContentMeta_1.ContentMeta, { ctx: props.ctx, file: props.file, lang: props.lang }),
56
56
  react_1.default.createElement("div", { className: "border-b mb-4 pb-2 xl:hidden" },
57
57
  react_1.default.createElement("h2", { className: "text-2xl font-semibold mb-2" }, "Table of Contents"),
package/dist/ssg/style.js CHANGED
@@ -70,7 +70,6 @@ html:not(.dark) body {
70
70
 
71
71
  .sidebar-right {
72
72
  background: var(--sidebar-bg);
73
- border-left: 1px solid var(--border-color);
74
73
  padding: 2rem 1rem;
75
74
  }
76
75
 
@@ -129,14 +128,6 @@ html:not(.dark) body {
129
128
  color: var(--text-primary);
130
129
  }
131
130
 
132
- .content {
133
- flex: 1;
134
- padding: 3rem 2rem;
135
- max-width: 1200px;
136
- width: 100%;
137
- box-sizing: border-box;
138
- }
139
-
140
131
  .content-header {
141
132
  margin-bottom: 2rem;
142
133
  padding-bottom: 1rem;
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.writeFile = void 0;
4
4
  const promises_1 = require("fs/promises");
5
5
  const path_1 = require("path");
6
- const metadata_1 = require("../metadata");
7
6
  /**
8
7
  * Ensure directory exists and write data to file
9
8
  * @param path - file path
@@ -12,8 +11,7 @@ const metadata_1 = require("../metadata");
12
11
  const writeFile = async (path, data) => {
13
12
  await (0, promises_1.mkdir)((0, path_1.dirname)(path), { recursive: true });
14
13
  await (0, promises_1.writeFile)(path, data, 'utf-8');
15
- if (metadata_1.MetaData.options.verbose)
16
- console.log(`✅ Wrote file: ${path}`);
14
+ console.log(`✅ Wrote file: ${path}`);
17
15
  };
18
16
  exports.writeFile = writeFile;
19
17
  //# sourceMappingURL=writeFile.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "czon",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "CZone - AI enhanced Markdown content engine",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,7 +8,7 @@
8
8
  "scripts": {
9
9
  "build": "npx rimraf dist && tsc",
10
10
  "dev": "ts-node src/cli.ts",
11
- "build:doc": "npm run build && node dist/cli.js build --lang zh-Hans --lang en-US --lang ja-JP --lang ko-KR --lang es-ES --lang fr-FR --lang de-DE --lang ru-RU --lang pt-PT --lang it-IT --lang nl-NL --lang pl-PL --lang sv-SE --lang fi-FI --lang da-DK --lang no-NO --lang zh-Hant --lang hi-IN --lang ar-SA --lang th-TH --lang vi-VN --lang id-ID --lang pt-BR --lang es-MX --lang tr-TR --lang uk-UA --verbose --baseUrl 'https://czon.zccz14.com/'",
11
+ "build:doc": "npm run build && node dist/cli.js build",
12
12
  "test": "npm run build && node --test dist/**/*.test.js",
13
13
  "test:types": "tsc --noEmit",
14
14
  "test:build": "npm run build && test -f dist/index.js && test -f dist/cli.js",
@@ -0,0 +1,61 @@
1
+ name: Deploy to GitHub Pages
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ schedule:
7
+ - cron: '0 * * * *' # 每小时运行一次
8
+ workflow_dispatch: # 允许手动触发
9
+
10
+ # 设置 GITHUB_TOKEN 的权限以允许部署到 GitHub Pages
11
+ permissions:
12
+ contents: read
13
+ pages: write
14
+ id-token: write
15
+
16
+ # 只允许一个并发部署,跳过正在运行的队列
17
+ concurrency:
18
+ group: 'pages'
19
+ cancel-in-progress: false
20
+
21
+ jobs:
22
+ build:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - name: Checkout repository
26
+ uses: actions/checkout@v4
27
+ with:
28
+ # 从远程分支开始时,需要先同步分支保证代码正确
29
+ fetch-depth: 0
30
+ ref: ${{ github.head_ref || github.ref }}
31
+ # 设置 GitHub Pages (确保启用 GitHub Pages 功能)
32
+ - name: Setup GitHub Pages
33
+ uses: actions/configure-pages@v4
34
+
35
+ - name: Setup Node.js
36
+ uses: actions/setup-node@v4
37
+ with:
38
+ node-version: '24.x'
39
+
40
+ - name: Create documentation site
41
+ run: |
42
+ npx czon@latest build
43
+
44
+ - name: Setup Pages
45
+ uses: actions/configure-pages@v4
46
+
47
+ - name: Upload artifact
48
+ uses: actions/upload-pages-artifact@v3
49
+ with:
50
+ path: './.czon/dist'
51
+
52
+ deploy:
53
+ environment:
54
+ name: github-pages
55
+ url: ${{ steps.deployment.outputs.page_url }}
56
+ runs-on: ubuntu-latest
57
+ needs: build
58
+ steps:
59
+ - name: Deploy to GitHub Pages
60
+ id: deployment
61
+ uses: actions/deploy-pages@v4