zeropress-theme 0.1.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 ADDED
@@ -0,0 +1,179 @@
1
+ # zeropress-theme
2
+
3
+ ZeroPress 테마 개발/검증/패키징을 위한 developer toolkit입니다.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # npx로 직접 실행 (설치 불필요)
9
+ npx zeropress-theme <command>
10
+
11
+ # 또는 글로벌 설치
12
+ npm install -g zeropress-theme
13
+ ```
14
+
15
+ ## Commands
16
+
17
+ ### dev — 프리뷰 서버
18
+
19
+ ```bash
20
+ npx zeropress-theme dev [themeDir] [options]
21
+ ```
22
+
23
+ 테마를 브라우저에서 실시간 프리뷰합니다. 파일 변경 시 WebSocket 기반 full reload를 지원합니다.
24
+
25
+ | Option | Description | Default |
26
+ |--------|-------------|---------|
27
+ | `--port <number>` | 서버 포트 | `4321` |
28
+ | `--host <ip>` | 바인드 주소 | `127.0.0.1` |
29
+ | `--data <path-or-url>` | 프리뷰 데이터 JSON 경로 또는 HTTPS URL | 내장 샘플 데이터 |
30
+ | `--open` | 브라우저 자동 열기 | - |
31
+ | `--no-js-check` | JS 관련 점검 생략 | - |
32
+
33
+ ```bash
34
+ # 현재 디렉토리 테마 프리뷰
35
+ npx zeropress-theme dev
36
+
37
+ # 경로 지정 + 커스텀 데이터
38
+ npx zeropress-theme dev ./my-theme --data ./preview.json
39
+
40
+ # 원격 데이터 사용
41
+ npx zeropress-theme dev ./my-theme --data https://signed-url/preview.json
42
+ ```
43
+
44
+ `--data` 미지정 시 toolkit 내장 샘플 데이터를 사용합니다. 원격 URL은 HTTPS만 허용하며, 최대 1MB, 타임아웃 5초입니다.
45
+
46
+ ---
47
+
48
+ ### validate — 테마 검증
49
+
50
+ ```bash
51
+ npx zeropress-theme validate [themeDir] [options]
52
+ ```
53
+
54
+ [Theme Spec Runtime v0.1](https://github.com/user/zeropress/blob/main/theme_guide_v2/THEME_SPEC.md) 계약 충족 여부를 확인합니다.
55
+
56
+ | Option | Description |
57
+ |--------|-------------|
58
+ | `--strict` | 경고도 실패로 처리 |
59
+ | `--json` | JSON 형식 출력 |
60
+
61
+ #### 검증 항목
62
+
63
+ **Error (업로드 차단)**
64
+ - `theme.json` 누락/파싱 실패/필수 필드(`name`, `version`, `author`) 누락
65
+ - `version` semver 불일치
66
+ - 필수 템플릿 누락: `layout.html`, `index.html`, `post.html`, `page.html`
67
+ - `assets/style.css` 누락
68
+ - `layout.html`에 `{{slot:content}}` 정확히 1개가 아닌 경우
69
+ - `layout.html`에 `<script>` 포함
70
+ - 허용 슬롯(`content`, `header`, `footer`, `meta`) 외 사용
71
+ - 중첩 슬롯, Mustache 블록 문법(`{{#...}}`, `{{/...}}`)
72
+ - 경로 이탈(`../`, absolute path, symlink escape)
73
+
74
+ **Warning**
75
+ - `archive.html`, `category.html`, `tag.html` 누락
76
+
77
+ #### 종료 코드
78
+
79
+ | Code | Meaning |
80
+ |------|---------|
81
+ | `0` | 오류/경고 없음 |
82
+ | `1` | 오류 존재 |
83
+ | `2` | 경고만 존재 |
84
+
85
+ `--strict` 사용 시 경고가 있으면 exit code `1`로 처리됩니다.
86
+
87
+ #### --json 출력 예시
88
+
89
+ ```json
90
+ {
91
+ "ok": false,
92
+ "summary": { "errors": 1, "warnings": 2, "checkedFiles": 14 },
93
+ "errors": [
94
+ { "code": "MISSING_REQUIRED_TEMPLATE", "path": "layout.html", "message": "Required template 'layout.html' is missing" }
95
+ ],
96
+ "warnings": [
97
+ { "code": "MISSING_OPTIONAL_TEMPLATE", "path": "archive.html", "message": "Optional template 'archive.html' is missing" }
98
+ ],
99
+ "meta": {
100
+ "schemaVersion": "1",
101
+ "tool": "zeropress-theme",
102
+ "toolVersion": "0.1.0",
103
+ "timestamp": "2026-02-14T00:00:00.000Z"
104
+ }
105
+ }
106
+ ```
107
+
108
+ ---
109
+
110
+ ### pack — zip 패키징
111
+
112
+ ```bash
113
+ npx zeropress-theme pack [themeDir] [options]
114
+ ```
115
+
116
+ 테마를 업로드용 zip으로 패키징합니다.
117
+
118
+ | Option | Description | Default |
119
+ |--------|-------------|---------|
120
+ | `--out <dir>` | 출력 디렉토리 | `dist` |
121
+ | `--name <file>` | zip 파일명 | `{name}-{version}.zip` |
122
+
123
+ ```bash
124
+ npx zeropress-theme pack
125
+ npx zeropress-theme pack ./my-theme --out artifacts
126
+ npx zeropress-theme pack ./my-theme --name my-theme-v1.zip
127
+ ```
128
+
129
+ 동작 순서:
130
+ 1. `validate` 선실행 (실패 시 중단)
131
+ 2. 불필요 파일 제외 후 zip 생성 (루트 평탄화)
132
+ 3. 생성된 zip을 다시 읽어 validate 재실행 (재검증 실패 시 zip 삭제)
133
+
134
+ 자동 제외 파일: `.git`, `node_modules`, `dist`, `*.log`, `__MACOSX`, `.DS_Store`, `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`
135
+
136
+ ## CI 사용 예시
137
+
138
+ ```bash
139
+ npx zeropress-theme validate ./theme --strict
140
+ npx zeropress-theme pack ./theme --out ./artifacts
141
+ ```
142
+
143
+ ## Preview Data
144
+
145
+ `dev` 명령에서 사용하는 프리뷰 데이터 JSON의 최소 구조:
146
+
147
+ ```json
148
+ {
149
+ "site": {
150
+ "title": "My Site",
151
+ "description": "Preview description",
152
+ "url": "https://example.com",
153
+ "language": "ko"
154
+ },
155
+ "posts": [],
156
+ "pages": [],
157
+ "categories": [],
158
+ "tags": []
159
+ }
160
+ ```
161
+
162
+ 필수: `site.title`, `site.description`, `site.url`, `site.language`
163
+ `posts`, `pages`, `categories`, `tags`는 생략 가능(빈 배열 처리).
164
+
165
+ 공식 스키마: [preview-data.schema.json](https://github.com/user/zeropress/blob/main/theme_guide_v2/preview-data.schema.json)
166
+
167
+ ## Requirements
168
+
169
+ - Node.js >= 18.18.0
170
+ - ESM only
171
+
172
+ ## Related
173
+
174
+ - [create-zeropress-theme](https://www.npmjs.com/package/create-zeropress-theme) — 테마 scaffolding CLI
175
+ - [ZeroPress Theme Spec](https://github.com/user/zeropress/blob/main/theme_guide_v2/THEME_SPEC.md)
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/index.js';
3
+
4
+ run(process.argv.slice(2)).catch((error) => {
5
+ console.error(`[zeropress-theme] ${error.message}`);
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "zeropress-theme",
3
+ "version": "0.1.0",
4
+ "description": "ZeroPress theme developer toolkit",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "zeropress-theme": "./bin/zeropress-theme.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.18.0"
16
+ },
17
+ "dependencies": {
18
+ "jszip": "^3.10.1",
19
+ "ws": "^8.18.0"
20
+ }
21
+ }
@@ -0,0 +1,20 @@
1
+ export const REQUIRED_TEMPLATES = ['layout.html', 'index.html', 'post.html', 'page.html'];
2
+ export const OPTIONAL_TEMPLATES = ['archive.html', 'category.html', 'tag.html'];
3
+ export const REQUIRED_FILES = ['theme.json', 'assets/style.css'];
4
+
5
+ export const ALLOWED_SLOTS = new Set(['content', 'header', 'footer', 'meta']);
6
+
7
+ export const EXCLUDE_DEFAULTS = new Set([
8
+ '.git',
9
+ 'node_modules',
10
+ 'dist',
11
+ '__MACOSX',
12
+ '.DS_Store',
13
+ 'package-lock.json',
14
+ 'pnpm-lock.yaml',
15
+ 'yarn.lock',
16
+ 'bun.lockb',
17
+ ]);
18
+
19
+ export const MAX_REMOTE_DATA_BYTES = 1024 * 1024;
20
+ export const REMOTE_TIMEOUT_MS = 5000;
package/src/dev.js ADDED
@@ -0,0 +1,406 @@
1
+ import fs from 'node:fs/promises';
2
+ import { watch as watchFs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import http from 'node:http';
5
+ import { spawn } from 'node:child_process';
6
+ import { WebSocketServer } from 'ws';
7
+ import { MAX_REMOTE_DATA_BYTES, REMOTE_TIMEOUT_MS } from './constants.js';
8
+ import { getThemeDir, isHttpUrl, isHttpsUrl } from './helpers.js';
9
+ import { validateThemeDirectory } from './validate.js';
10
+
11
+ export async function runDev(argv) {
12
+ const { positional, flags } = parseDevArgs(argv);
13
+ const themeDir = getThemeDir(positional[0]);
14
+ const host = flags.host || '127.0.0.1';
15
+ const port = Number(flags.port || 4321);
16
+ const noJsCheck = flags['no-js-check'] === true;
17
+
18
+ if (!Number.isInteger(port) || port <= 0) {
19
+ throw new Error(`Invalid port: ${flags.port}`);
20
+ }
21
+
22
+ const validation = await validateThemeDirectory(themeDir, { noJsCheck });
23
+ if (validation.errors.length > 0) {
24
+ throw new Error(`Theme validation failed before dev start (${validation.errors.length} errors)`);
25
+ }
26
+ if (validation.warnings.length > 0) {
27
+ console.log(`[dev] warnings: ${validation.warnings.length}`);
28
+ }
29
+
30
+ const previewData = await loadPreviewData(flags.data);
31
+ ensurePreviewDataMinimum(previewData);
32
+
33
+ const server = http.createServer((req, res) => handleRequest(req, res, themeDir, previewData));
34
+ const wss = new WebSocketServer({ server, path: '/__zeropress_ws' });
35
+
36
+ const watchers = await createWatchers(themeDir, async () => {
37
+ if (!noJsCheck) {
38
+ const quick = await validateThemeDirectory(themeDir, { noJsCheck: false });
39
+ if (quick.errors.length > 0) {
40
+ console.log(`[dev] validation errors on change: ${quick.errors.length}`);
41
+ }
42
+ }
43
+
44
+ for (const client of wss.clients) {
45
+ if (client.readyState === 1) {
46
+ client.send('reload');
47
+ }
48
+ }
49
+ });
50
+
51
+ server.listen(port, host, () => {
52
+ const url = `http://${host}:${port}`;
53
+ console.log(`[dev] running at ${url}`);
54
+ if (flags.open === true) {
55
+ openBrowser(url);
56
+ }
57
+ });
58
+
59
+ const shutdown = () => {
60
+ for (const watcher of watchers) {
61
+ watcher.close();
62
+ }
63
+ wss.close();
64
+ server.close(() => process.exit(0));
65
+ };
66
+
67
+ process.on('SIGINT', shutdown);
68
+ process.on('SIGTERM', shutdown);
69
+ }
70
+
71
+ function parseDevArgs(argv) {
72
+ const positional = [];
73
+ const flags = {};
74
+
75
+ for (let i = 0; i < argv.length; i += 1) {
76
+ const token = argv[i];
77
+ if (!token.startsWith('--')) {
78
+ positional.push(token);
79
+ continue;
80
+ }
81
+
82
+ const key = token.slice(2);
83
+ if (key === 'open' || key === 'no-js-check') {
84
+ flags[key] = true;
85
+ continue;
86
+ }
87
+
88
+ if (key === 'port' || key === 'host' || key === 'data') {
89
+ const value = argv[i + 1];
90
+ if (!value) {
91
+ throw new Error(`--${key} requires a value`);
92
+ }
93
+ flags[key] = value;
94
+ i += 1;
95
+ continue;
96
+ }
97
+
98
+ throw new Error(`Unknown option for dev: ${token}`);
99
+ }
100
+
101
+ return { positional, flags };
102
+ }
103
+
104
+ async function loadPreviewData(dataArg) {
105
+ if (!dataArg) {
106
+ return defaultPreviewData();
107
+ }
108
+
109
+ if (isHttpUrl(dataArg)) {
110
+ if (!isHttpsUrl(dataArg)) {
111
+ throw new Error('--data URL must use https');
112
+ }
113
+
114
+ const controller = new AbortController();
115
+ const timeout = setTimeout(() => controller.abort(), REMOTE_TIMEOUT_MS);
116
+
117
+ try {
118
+ const response = await fetch(dataArg, { redirect: 'follow', signal: controller.signal });
119
+ if (!response.ok) {
120
+ throw new Error(`Failed to fetch preview data (${response.status})`);
121
+ }
122
+ const raw = new Uint8Array(await response.arrayBuffer());
123
+ if (raw.byteLength > MAX_REMOTE_DATA_BYTES) {
124
+ throw new Error('Remote preview JSON exceeds 1MB limit');
125
+ }
126
+ return JSON.parse(new TextDecoder().decode(raw));
127
+ } finally {
128
+ clearTimeout(timeout);
129
+ }
130
+ }
131
+
132
+ const localPath = path.resolve(process.cwd(), dataArg);
133
+ const raw = await fs.readFile(localPath, 'utf8');
134
+ return JSON.parse(raw);
135
+ }
136
+
137
+ function ensurePreviewDataMinimum(data) {
138
+ if (!data || typeof data !== 'object') {
139
+ throw new Error('Preview data must be an object');
140
+ }
141
+ if (!data.site || typeof data.site !== 'object') {
142
+ throw new Error('Preview data must include site object');
143
+ }
144
+ for (const key of ['title', 'description', 'url', 'language']) {
145
+ if (typeof data.site[key] !== 'string' || data.site[key].trim() === '') {
146
+ throw new Error(`Preview data site.${key} is required`);
147
+ }
148
+ }
149
+ }
150
+
151
+ function defaultPreviewData() {
152
+ return {
153
+ site: {
154
+ title: 'ZeroPress Preview',
155
+ description: 'Default preview data',
156
+ url: 'https://example.com',
157
+ language: 'en',
158
+ },
159
+ posts: [
160
+ {
161
+ title: 'Hello ZeroPress',
162
+ slug: 'hello-zeropress',
163
+ html: '<p>Preview post content</p>',
164
+ excerpt: 'Preview excerpt',
165
+ published_at: '2026-02-14',
166
+ updated_at: '2026-02-14',
167
+ author_name: 'Admin',
168
+ },
169
+ ],
170
+ pages: [
171
+ {
172
+ title: 'About',
173
+ slug: 'about',
174
+ html: '<p>About page</p>',
175
+ },
176
+ ],
177
+ categories: [{ name: 'General', slug: 'general', postCount: 1 }],
178
+ tags: [{ name: 'Intro', slug: 'intro', postCount: 1 }],
179
+ };
180
+ }
181
+
182
+ async function handleRequest(req, res, themeDir, data) {
183
+ try {
184
+ const url = new URL(req.url, 'http://localhost');
185
+ const pathname = decodeURIComponent(url.pathname);
186
+
187
+ if (pathname.startsWith('/assets/')) {
188
+ const assetPath = path.join(themeDir, pathname);
189
+ const content = await fs.readFile(assetPath);
190
+ const type = pathname.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/octet-stream';
191
+ send(res, 200, type, content);
192
+ return;
193
+ }
194
+
195
+ const rendered = await renderRoute(pathname, themeDir, data);
196
+ if (rendered.notFound) {
197
+ send(res, 404, 'text/html; charset=utf-8', injectLiveReload(rendered.html));
198
+ return;
199
+ }
200
+
201
+ send(res, 200, 'text/html; charset=utf-8', injectLiveReload(rendered.html));
202
+ } catch (error) {
203
+ send(res, 500, 'text/plain; charset=utf-8', `Internal error: ${error.message}`);
204
+ }
205
+ }
206
+
207
+ async function renderRoute(pathname, themeDir, data) {
208
+ const normalized = pathname.replace(/\/+$/, '') || '/';
209
+
210
+ if (normalized === '/') {
211
+ return { html: await renderWithLayout(themeDir, 'index.html', { ...data, posts: renderPostList(data.posts) }) };
212
+ }
213
+
214
+ const postMatch = normalized.match(/^\/posts\/([^/]+)$/);
215
+ if (postMatch) {
216
+ const post = (data.posts || []).find((p) => p.slug === postMatch[1]);
217
+ if (!post) {
218
+ return { html: await render404(themeDir), notFound: true };
219
+ }
220
+ return { html: await renderWithLayout(themeDir, 'post.html', { ...data, post }) };
221
+ }
222
+
223
+ const pageMatch = normalized.match(/^\/([^/]+)$/);
224
+ if (pageMatch && pageMatch[1] !== 'archive') {
225
+ const page = (data.pages || []).find((p) => p.slug === pageMatch[1]);
226
+ if (!page) {
227
+ return { html: await render404(themeDir), notFound: true };
228
+ }
229
+ return { html: await renderWithLayout(themeDir, 'page.html', { ...data, page }) };
230
+ }
231
+
232
+ if (normalized === '/archive') {
233
+ if (!(await fileExists(path.join(themeDir, 'archive.html')))) {
234
+ return { html: await render404(themeDir), notFound: true };
235
+ }
236
+ return { html: await renderWithLayout(themeDir, 'archive.html', { ...data, posts: renderPostList(data.posts) }) };
237
+ }
238
+
239
+ const categoryMatch = normalized.match(/^\/categories\/([^/]+)$/);
240
+ if (categoryMatch) {
241
+ if (!(await fileExists(path.join(themeDir, 'category.html')))) {
242
+ return { html: await render404(themeDir), notFound: true };
243
+ }
244
+ const posts = (data.posts || []).filter((post) => {
245
+ const list = post.categories || [];
246
+ return list.includes(categoryMatch[1]) || list.includes(capitalize(categoryMatch[1]));
247
+ });
248
+ return { html: await renderWithLayout(themeDir, 'category.html', { ...data, posts: renderPostList(posts) }) };
249
+ }
250
+
251
+ const tagMatch = normalized.match(/^\/tags\/([^/]+)$/);
252
+ if (tagMatch) {
253
+ if (!(await fileExists(path.join(themeDir, 'tag.html')))) {
254
+ return { html: await render404(themeDir), notFound: true };
255
+ }
256
+ const posts = (data.posts || []).filter((post) => {
257
+ const list = post.tags || [];
258
+ return list.includes(tagMatch[1]) || list.includes(capitalize(tagMatch[1]));
259
+ });
260
+ return { html: await renderWithLayout(themeDir, 'tag.html', { ...data, posts: renderPostList(posts) }) };
261
+ }
262
+
263
+ return { html: await render404(themeDir), notFound: true };
264
+ }
265
+
266
+ function renderPostList(posts = []) {
267
+ if (!posts.length) {
268
+ return '<p>No posts</p>';
269
+ }
270
+ return posts
271
+ .map((post) => `<article><h2><a href="/posts/${post.slug}">${escapeHtml(post.title || '')}</a></h2><div>${post.excerpt || ''}</div></article>`)
272
+ .join('\n');
273
+ }
274
+
275
+ async function renderWithLayout(themeDir, templateName, data) {
276
+ const [layout, template] = await Promise.all([
277
+ fs.readFile(path.join(themeDir, 'layout.html'), 'utf8'),
278
+ fs.readFile(path.join(themeDir, templateName), 'utf8'),
279
+ ]);
280
+
281
+ const header = await readOptional(path.join(themeDir, 'partials', 'header.html'));
282
+ const footer = await readOptional(path.join(themeDir, 'partials', 'footer.html'));
283
+ const body = substitute(template, data);
284
+
285
+ const withSlots = layout
286
+ .replace(/\{\{slot:content\}\}/g, body)
287
+ .replace(/\{\{slot:header\}\}/g, header)
288
+ .replace(/\{\{slot:footer\}\}/g, footer)
289
+ .replace(/\{\{slot:meta\}\}/g, '');
290
+
291
+ return substitute(withSlots, data);
292
+ }
293
+
294
+ async function render404(themeDir) {
295
+ const custom404 = path.join(themeDir, '404.html');
296
+ if (await fileExists(custom404)) {
297
+ return renderWithLayout(themeDir, '404.html', {
298
+ site: {
299
+ title: '404',
300
+ description: 'Not found',
301
+ },
302
+ });
303
+ }
304
+ return '<!doctype html><html><body><h1>404</h1><p>Not Found</p></body></html>';
305
+ }
306
+
307
+ function substitute(template, data) {
308
+ return template.replace(/\{\{([a-zA-Z0-9_.-]+)\}\}/g, (_, key) => {
309
+ if (key.startsWith('slot:')) {
310
+ return `{{${key}}}`;
311
+ }
312
+ const value = getByPath(data, key);
313
+ return value == null ? '' : String(value);
314
+ });
315
+ }
316
+
317
+ function getByPath(obj, key) {
318
+ const parts = key.split('.');
319
+ let current = obj;
320
+ for (const part of parts) {
321
+ if (current == null || typeof current !== 'object') {
322
+ return undefined;
323
+ }
324
+ current = current[part];
325
+ }
326
+ return current;
327
+ }
328
+
329
+ function injectLiveReload(html) {
330
+ const script = `\n<script>\n(() => {\n const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/__zeropress_ws');\n ws.onmessage = (event) => { if (event.data === 'reload') location.reload(); };\n})();\n</script>\n`;
331
+ if (html.includes('</body>')) {
332
+ return html.replace('</body>', `${script}</body>`);
333
+ }
334
+ return `${html}${script}`;
335
+ }
336
+
337
+ function send(res, status, type, body) {
338
+ res.writeHead(status, { 'content-type': type });
339
+ res.end(body);
340
+ }
341
+
342
+ function escapeHtml(value) {
343
+ return value
344
+ .replace(/&/g, '&amp;')
345
+ .replace(/</g, '&lt;')
346
+ .replace(/>/g, '&gt;')
347
+ .replace(/"/g, '&quot;')
348
+ .replace(/'/g, '&#39;');
349
+ }
350
+
351
+ function capitalize(value) {
352
+ if (!value) return value;
353
+ return value[0].toUpperCase() + value.slice(1);
354
+ }
355
+
356
+ async function readOptional(filePath) {
357
+ try {
358
+ return await fs.readFile(filePath, 'utf8');
359
+ } catch (error) {
360
+ if (error.code === 'ENOENT') return '';
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ async function fileExists(filePath) {
366
+ try {
367
+ await fs.access(filePath);
368
+ return true;
369
+ } catch {
370
+ return false;
371
+ }
372
+ }
373
+
374
+ async function createWatchers(rootDir, onChange) {
375
+ const watchers = [];
376
+
377
+ async function watchDir(dir) {
378
+ const watcher = watchFs(dir, { persistent: true }, () => {
379
+ onChange().catch((error) => {
380
+ console.log(`[dev] reload trigger error: ${error.message}`);
381
+ });
382
+ });
383
+ watchers.push(watcher);
384
+
385
+ const entries = await fs.readdir(dir, { withFileTypes: true });
386
+ for (const entry of entries) {
387
+ if (entry.isDirectory()) {
388
+ await watchDir(path.join(dir, entry.name));
389
+ }
390
+ }
391
+ }
392
+
393
+ await watchDir(rootDir);
394
+ return watchers;
395
+ }
396
+
397
+ function openBrowser(url) {
398
+ const platform = process.platform;
399
+ if (platform === 'darwin') {
400
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
401
+ } else if (platform === 'win32') {
402
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
403
+ } else {
404
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
405
+ }
406
+ }
package/src/helpers.js ADDED
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export function parseCliArgs(argv) {
5
+ const positional = [];
6
+ const flags = {};
7
+
8
+ for (let i = 0; i < argv.length; i += 1) {
9
+ const token = argv[i];
10
+ if (!token.startsWith('--')) {
11
+ positional.push(token);
12
+ continue;
13
+ }
14
+
15
+ const [rawKey, inlineValue] = token.split('=');
16
+ const key = rawKey.slice(2);
17
+
18
+ if (inlineValue !== undefined) {
19
+ flags[key] = inlineValue;
20
+ continue;
21
+ }
22
+
23
+ const next = argv[i + 1];
24
+ if (!next || next.startsWith('--')) {
25
+ flags[key] = true;
26
+ continue;
27
+ }
28
+
29
+ flags[key] = next;
30
+ i += 1;
31
+ }
32
+
33
+ return { positional, flags };
34
+ }
35
+
36
+ export function toBoolean(value) {
37
+ return value === true || value === 'true' || value === '1';
38
+ }
39
+
40
+ export async function exists(filePath) {
41
+ try {
42
+ await fs.access(filePath);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export function isHttpUrl(value) {
50
+ return typeof value === 'string' && /^https?:\/\//i.test(value);
51
+ }
52
+
53
+ export function isHttpsUrl(value) {
54
+ return typeof value === 'string' && /^https:\/\//i.test(value);
55
+ }
56
+
57
+ export function normalizeSlashes(value) {
58
+ return value.replace(/\\/g, '/');
59
+ }
60
+
61
+ export function sanitizeFileName(value) {
62
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'theme';
63
+ }
64
+
65
+ export async function walkDirectory(rootDir) {
66
+ const output = [];
67
+
68
+ async function walk(currentDir) {
69
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ const fullPath = path.join(currentDir, entry.name);
72
+ const relativePath = normalizeSlashes(path.relative(rootDir, fullPath));
73
+ output.push({
74
+ fullPath,
75
+ relativePath,
76
+ entry,
77
+ });
78
+ if (entry.isDirectory()) {
79
+ await walk(fullPath);
80
+ }
81
+ }
82
+ }
83
+
84
+ await walk(rootDir);
85
+ return output;
86
+ }
87
+
88
+ export function getThemeDir(inputDir) {
89
+ return path.resolve(process.cwd(), inputDir || '.');
90
+ }
package/src/index.js ADDED
@@ -0,0 +1,39 @@
1
+ import { runValidate } from './validate.js';
2
+ import { runPack } from './pack.js';
3
+ import { runDev } from './dev.js';
4
+
5
+ export async function run(argv) {
6
+ const [command, ...rest] = argv;
7
+
8
+ if (!command || command === '--help' || command === '-h') {
9
+ printHelp();
10
+ return;
11
+ }
12
+
13
+ if (command === 'validate') {
14
+ const code = await runValidate(rest);
15
+ process.exit(code);
16
+ return;
17
+ }
18
+
19
+ if (command === 'pack') {
20
+ await runPack(rest);
21
+ return;
22
+ }
23
+
24
+ if (command === 'dev') {
25
+ await runDev(rest);
26
+ return;
27
+ }
28
+
29
+ throw new Error(`Unknown command: ${command}`);
30
+ }
31
+
32
+ function printHelp() {
33
+ console.log(`zeropress-theme v0.1.0
34
+
35
+ Usage:
36
+ zeropress-theme dev [themeDir] [--port <n>] [--host <ip>] [--data <path-or-url>] [--open] [--no-js-check]
37
+ zeropress-theme validate [themeDir] [--strict] [--json]
38
+ zeropress-theme pack [themeDir] [--out <dir>] [--name <zipFile>]`);
39
+ }
package/src/pack.js ADDED
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import JSZip from 'jszip';
4
+ import { EXCLUDE_DEFAULTS } from './constants.js';
5
+ import { getThemeDir, normalizeSlashes, sanitizeFileName, walkDirectory } from './helpers.js';
6
+ import { validateThemeDirectory, validateZipFile } from './validate.js';
7
+
8
+ export async function runPack(argv) {
9
+ const { positional, flags } = parsePackArgs(argv);
10
+ const themeDir = getThemeDir(positional[0]);
11
+ const outDir = path.resolve(process.cwd(), flags.out || 'dist');
12
+
13
+ const preValidation = await validateThemeDirectory(themeDir, { noJsCheck: false });
14
+ if (preValidation.errors.length > 0) {
15
+ throw new Error(`Pack aborted: validate failed with ${preValidation.errors.length} error(s)`);
16
+ }
17
+
18
+ const themeJsonRaw = await fs.readFile(path.join(themeDir, 'theme.json'), 'utf8');
19
+ const themeJson = JSON.parse(themeJsonRaw);
20
+ const defaultName = `${sanitizeFileName(themeJson.name)}-${sanitizeFileName(themeJson.version)}.zip`;
21
+ const fileName = flags.name || defaultName;
22
+
23
+ await fs.mkdir(outDir, { recursive: true });
24
+ const zipPath = path.join(outDir, fileName);
25
+
26
+ const zip = new JSZip();
27
+ const entries = await walkDirectory(themeDir);
28
+
29
+ const seenPaths = new Map();
30
+
31
+ for (const item of entries) {
32
+ if (!item.entry.isFile()) {
33
+ continue;
34
+ }
35
+
36
+ const rel = normalizeSlashes(item.relativePath);
37
+ if (shouldExclude(rel)) {
38
+ continue;
39
+ }
40
+
41
+ const zipRel = rel;
42
+ const key = zipRel.toLowerCase();
43
+ if (seenPaths.has(key)) {
44
+ throw new Error(`Pack aborted: flatten path collision detected (${zipRel} conflicts with ${seenPaths.get(key)})`);
45
+ }
46
+ seenPaths.set(key, zipRel);
47
+
48
+ const content = await fs.readFile(item.fullPath);
49
+ zip.file(zipRel, content);
50
+ }
51
+
52
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
53
+ await fs.writeFile(zipPath, buffer);
54
+
55
+ const postValidation = await validateZipFile(zipPath, { noJsCheck: false });
56
+ if (postValidation.errors.length > 0) {
57
+ await fs.unlink(zipPath).catch(() => {});
58
+ throw new Error(`Pack aborted: generated zip re-validation failed with ${postValidation.errors.length} error(s)`);
59
+ }
60
+
61
+ console.log(`Packed theme: ${zipPath}`);
62
+ if (postValidation.warnings.length > 0) {
63
+ console.log(`Pack warnings: ${postValidation.warnings.length}`);
64
+ }
65
+ }
66
+
67
+ function parsePackArgs(argv) {
68
+ const positional = [];
69
+ const flags = {};
70
+
71
+ for (let i = 0; i < argv.length; i += 1) {
72
+ const token = argv[i];
73
+ if (!token.startsWith('--')) {
74
+ positional.push(token);
75
+ continue;
76
+ }
77
+
78
+ if (token === '--out' || token === '--name') {
79
+ const value = argv[i + 1];
80
+ if (!value) {
81
+ throw new Error(`${token} requires a value`);
82
+ }
83
+ flags[token.slice(2)] = value;
84
+ i += 1;
85
+ continue;
86
+ }
87
+
88
+ throw new Error(`Unknown option for pack: ${token}`);
89
+ }
90
+
91
+ return { positional, flags };
92
+ }
93
+
94
+ function shouldExclude(relativePath) {
95
+ const parts = relativePath.split('/');
96
+ for (const part of parts) {
97
+ if (EXCLUDE_DEFAULTS.has(part)) {
98
+ return true;
99
+ }
100
+ }
101
+ if (relativePath.endsWith('.log')) {
102
+ return true;
103
+ }
104
+ return false;
105
+ }
@@ -0,0 +1,342 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import JSZip from 'jszip';
4
+ import { ALLOWED_SLOTS, OPTIONAL_TEMPLATES, REQUIRED_FILES, REQUIRED_TEMPLATES } from './constants.js';
5
+ import { getThemeDir, normalizeSlashes, walkDirectory } from './helpers.js';
6
+
7
+ export async function runValidate(argv) {
8
+ const { positional, flags } = parseValidateArgs(argv);
9
+ const themeDir = getThemeDir(positional[0]);
10
+ const strict = flags.strict === true;
11
+ const json = flags.json === true;
12
+
13
+ const result = await validateThemeDirectory(themeDir, { noJsCheck: false });
14
+
15
+ if (json) {
16
+ process.stdout.write(`${JSON.stringify(toJsonOutput(result), null, 2)}\n`);
17
+ } else {
18
+ printHuman(result, themeDir);
19
+ }
20
+
21
+ if (result.errors.length > 0) {
22
+ return 1;
23
+ }
24
+ if (result.warnings.length > 0) {
25
+ return strict ? 1 : 2;
26
+ }
27
+ return 0;
28
+ }
29
+
30
+ function parseValidateArgs(argv) {
31
+ const positional = [];
32
+ const flags = { strict: false, json: false };
33
+
34
+ for (let i = 0; i < argv.length; i += 1) {
35
+ const token = argv[i];
36
+ if (!token.startsWith('--')) {
37
+ positional.push(token);
38
+ continue;
39
+ }
40
+ if (token === '--strict') {
41
+ flags.strict = true;
42
+ continue;
43
+ }
44
+ if (token === '--json') {
45
+ flags.json = true;
46
+ continue;
47
+ }
48
+ throw new Error(`Unknown option for validate: ${token}`);
49
+ }
50
+
51
+ return { positional, flags };
52
+ }
53
+
54
+ export async function validateThemeDirectory(themeDir, options = {}) {
55
+ const noJsCheck = options.noJsCheck === true;
56
+ const errors = [];
57
+ const warnings = [];
58
+ let checkedFiles = 0;
59
+
60
+ const allEntries = await walkDirectory(themeDir);
61
+ checkedFiles = allEntries.filter((it) => it.entry.isFile()).length;
62
+
63
+ await validatePathSafety(themeDir, allEntries, errors);
64
+
65
+ const files = new Map();
66
+ for (const item of allEntries) {
67
+ if (item.entry.isFile()) {
68
+ files.set(item.relativePath, item.fullPath);
69
+ }
70
+ }
71
+
72
+ await validateFromFileMap(files, {
73
+ readFile: (relativePath) => fs.readFile(files.get(relativePath), 'utf8'),
74
+ exists: (relativePath) => files.has(relativePath),
75
+ errors,
76
+ warnings,
77
+ noJsCheck,
78
+ });
79
+
80
+ return { ok: errors.length === 0, errors, warnings, checkedFiles };
81
+ }
82
+
83
+ export async function validateZipFile(zipPath, options = {}) {
84
+ const noJsCheck = options.noJsCheck === true;
85
+ const errors = [];
86
+ const warnings = [];
87
+
88
+ const raw = await fs.readFile(zipPath);
89
+ const zip = await JSZip.loadAsync(raw);
90
+
91
+ const filePaths = Object.keys(zip.files).filter((p) => !zip.files[p].dir);
92
+ const basePrefix = detectBasePrefix(filePaths);
93
+
94
+ const files = new Set(filePaths.map((p) => normalizeSlashes(p)));
95
+
96
+ await validateFromFileMap(
97
+ new Proxy(
98
+ {},
99
+ {
100
+ get: (_, key) => files.has(`${basePrefix}${key}`),
101
+ }
102
+ ),
103
+ {
104
+ readFile: async (relativePath) => {
105
+ const file = zip.file(`${basePrefix}${relativePath}`);
106
+ if (!file) {
107
+ throw new Error(`Missing file: ${relativePath}`);
108
+ }
109
+ return file.async('string');
110
+ },
111
+ exists: (relativePath) => files.has(`${basePrefix}${relativePath}`),
112
+ errors,
113
+ warnings,
114
+ noJsCheck,
115
+ }
116
+ );
117
+
118
+ return {
119
+ ok: errors.length === 0,
120
+ errors,
121
+ warnings,
122
+ checkedFiles: filePaths.length,
123
+ };
124
+ }
125
+
126
+ function detectBasePrefix(filePaths) {
127
+ if (filePaths.includes('theme.json')) {
128
+ return '';
129
+ }
130
+ const topLevel = new Set(filePaths.map((p) => p.split('/')[0]).filter(Boolean));
131
+ if (topLevel.size === 1) {
132
+ const folder = Array.from(topLevel)[0];
133
+ if (filePaths.includes(`${folder}/theme.json`)) {
134
+ return `${folder}/`;
135
+ }
136
+ }
137
+ return '';
138
+ }
139
+
140
+ async function validateFromFileMap(fileMap, context) {
141
+ const { exists, readFile, errors, warnings, noJsCheck } = context;
142
+
143
+ for (const requiredPath of REQUIRED_FILES) {
144
+ if (!exists(requiredPath)) {
145
+ errors.push({
146
+ code: 'MISSING_REQUIRED_FILE',
147
+ path: requiredPath,
148
+ message: `Required file '${requiredPath}' is missing`,
149
+ });
150
+ }
151
+ }
152
+
153
+ for (const template of REQUIRED_TEMPLATES) {
154
+ if (!exists(template)) {
155
+ errors.push({
156
+ code: 'MISSING_REQUIRED_TEMPLATE',
157
+ path: template,
158
+ message: `Required template '${template}' is missing`,
159
+ });
160
+ }
161
+ }
162
+
163
+ for (const template of OPTIONAL_TEMPLATES) {
164
+ if (!exists(template)) {
165
+ warnings.push({
166
+ code: 'MISSING_OPTIONAL_TEMPLATE',
167
+ path: template,
168
+ message: `Optional template '${template}' is missing`,
169
+ });
170
+ }
171
+ }
172
+
173
+ if (exists('theme.json')) {
174
+ try {
175
+ const raw = await readFile('theme.json');
176
+ const data = JSON.parse(raw);
177
+ validateThemeJson(data, errors);
178
+ } catch (error) {
179
+ errors.push({
180
+ code: 'INVALID_THEME_JSON',
181
+ path: 'theme.json',
182
+ message: `Invalid theme.json: ${error.message}`,
183
+ });
184
+ }
185
+ }
186
+
187
+ const templatesToCheck = [...REQUIRED_TEMPLATES, ...OPTIONAL_TEMPLATES, 'layout.html', '404.html'];
188
+ for (const template of new Set(templatesToCheck)) {
189
+ if (!exists(template)) {
190
+ continue;
191
+ }
192
+ const content = await readFile(template);
193
+ validateTemplateSyntax(template, content, errors, noJsCheck);
194
+ }
195
+
196
+ if (exists('partials')) {
197
+ // no-op for virtual sources
198
+ }
199
+ }
200
+
201
+ function validateThemeJson(themeJson, errors) {
202
+ if (!themeJson || typeof themeJson !== 'object') {
203
+ errors.push({
204
+ code: 'INVALID_THEME_JSON',
205
+ path: 'theme.json',
206
+ message: 'theme.json must be an object',
207
+ });
208
+ return;
209
+ }
210
+
211
+ for (const key of ['name', 'version', 'author']) {
212
+ if (typeof themeJson[key] !== 'string' || themeJson[key].trim() === '') {
213
+ errors.push({
214
+ code: 'INVALID_THEME_METADATA',
215
+ path: 'theme.json',
216
+ message: `theme.json field '${key}' must be a non-empty string`,
217
+ });
218
+ }
219
+ }
220
+
221
+ if (typeof themeJson.version === 'string') {
222
+ const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
223
+ if (!semver.test(themeJson.version)) {
224
+ errors.push({
225
+ code: 'INVALID_SEMVER',
226
+ path: 'theme.json',
227
+ message: "Theme version must follow semantic versioning (e.g. 1.0.0)",
228
+ });
229
+ }
230
+ }
231
+ }
232
+
233
+ function validateTemplateSyntax(templatePath, content, errors, noJsCheck) {
234
+ const slotRegex = /\{\{slot:([a-zA-Z0-9_-]+)\}\}/g;
235
+ const contentSlotMatches = content.match(/\{\{slot:content\}\}/g) || [];
236
+
237
+ if (templatePath === 'layout.html') {
238
+ if (contentSlotMatches.length !== 1) {
239
+ errors.push({
240
+ code: 'INVALID_LAYOUT_SLOT',
241
+ path: 'layout.html',
242
+ message: 'layout.html must contain exactly one {{slot:content}}',
243
+ });
244
+ }
245
+ if (!noJsCheck && /<script\b/i.test(content)) {
246
+ errors.push({
247
+ code: 'LAYOUT_SCRIPT_NOT_ALLOWED',
248
+ path: 'layout.html',
249
+ message: 'layout.html must not contain <script> tags',
250
+ });
251
+ }
252
+ }
253
+
254
+ let match;
255
+ while ((match = slotRegex.exec(content)) !== null) {
256
+ const slotName = match[1];
257
+ if (!ALLOWED_SLOTS.has(slotName)) {
258
+ errors.push({
259
+ code: 'UNKNOWN_SLOT',
260
+ path: templatePath,
261
+ message: `Unknown slot '${slotName}' in ${templatePath}`,
262
+ });
263
+ }
264
+ }
265
+
266
+ if (/\{\{slot:[^}]*\{\{slot:/.test(content)) {
267
+ errors.push({
268
+ code: 'NESTED_SLOT',
269
+ path: templatePath,
270
+ message: `Nested slots are not allowed in ${templatePath}`,
271
+ });
272
+ }
273
+
274
+ if (/\{\{[#/][^}]+\}\}/.test(content)) {
275
+ errors.push({
276
+ code: 'MUSTACHE_BLOCK_NOT_ALLOWED',
277
+ path: templatePath,
278
+ message: `Mustache block syntax is not allowed in ${templatePath}`,
279
+ });
280
+ }
281
+ }
282
+
283
+ async function validatePathSafety(themeDir, entries, errors) {
284
+ const rootRealPath = await fs.realpath(themeDir);
285
+ for (const item of entries) {
286
+ const rel = item.relativePath;
287
+ if (rel.includes('..') || path.isAbsolute(rel)) {
288
+ errors.push({
289
+ code: 'PATH_ESCAPE',
290
+ path: rel,
291
+ message: `Invalid path outside theme root: ${rel}`,
292
+ });
293
+ continue;
294
+ }
295
+
296
+ const full = item.fullPath;
297
+ const stat = await fs.lstat(full);
298
+ if (!stat.isSymbolicLink()) {
299
+ continue;
300
+ }
301
+ const resolved = await fs.realpath(full);
302
+ if (!resolved.startsWith(rootRealPath)) {
303
+ errors.push({
304
+ code: 'SYMLINK_ESCAPE',
305
+ path: rel,
306
+ message: `Symlink escapes theme root: ${rel}`,
307
+ });
308
+ }
309
+ }
310
+ }
311
+
312
+ function printHuman(result, themeDir) {
313
+ console.log(`Validating theme: ${themeDir}`);
314
+ for (const error of result.errors) {
315
+ console.log(`ERROR ${error.code} ${error.path}: ${error.message}`);
316
+ }
317
+ for (const warning of result.warnings) {
318
+ console.log(`WARN ${warning.code} ${warning.path}: ${warning.message}`);
319
+ }
320
+ if (result.errors.length === 0 && result.warnings.length === 0) {
321
+ console.log('OK Theme is valid');
322
+ }
323
+ }
324
+
325
+ function toJsonOutput(result) {
326
+ return {
327
+ ok: result.errors.length === 0,
328
+ summary: {
329
+ errors: result.errors.length,
330
+ warnings: result.warnings.length,
331
+ checkedFiles: result.checkedFiles,
332
+ },
333
+ errors: result.errors,
334
+ warnings: result.warnings,
335
+ meta: {
336
+ schemaVersion: '1',
337
+ tool: 'zeropress-theme',
338
+ toolVersion: '0.1.0',
339
+ timestamp: new Date().toISOString(),
340
+ },
341
+ };
342
+ }