@zeropress/build-core 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/LICENSE +21 -0
- package/README.md +212 -0
- package/package.json +39 -0
- package/src/api/build-site-from-theme-dir.js +12 -0
- package/src/api/build-site.js +455 -0
- package/src/assets/asset-processor.js +48 -0
- package/src/index.d.ts +95 -0
- package/src/index.js +5 -0
- package/src/output/filesystem-writer.js +17 -0
- package/src/output/memory-writer.js +21 -0
- package/src/render/slot-resolver.js +14 -0
- package/src/render/variable-resolver.js +29 -0
- package/src/render/zeropress-engine.js +43 -0
- package/src/theme/load-theme-dir.js +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZeroPress
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# @zeropress/build-core
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Shared build and partial-render core for ZeroPress.
|
|
8
|
+
|
|
9
|
+
This package is the deterministic rendering engine used by:
|
|
10
|
+
|
|
11
|
+
- `zeropress-admin-api-v2`
|
|
12
|
+
- future `@zeropress/build` CLI flows
|
|
13
|
+
|
|
14
|
+
It accepts canonical preview-data plus a validated theme package and produces static HTML artifacts through a writer interface.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @zeropress/build-core
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Exports
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import {
|
|
30
|
+
buildSite,
|
|
31
|
+
buildSelectedRoutes,
|
|
32
|
+
buildSiteFromThemeDir,
|
|
33
|
+
MemoryWriter,
|
|
34
|
+
FilesystemWriter,
|
|
35
|
+
} from '@zeropress/build-core';
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Purpose
|
|
41
|
+
|
|
42
|
+
`@zeropress/build-core` is responsible for:
|
|
43
|
+
|
|
44
|
+
- validating preview-data input
|
|
45
|
+
- validating in-memory theme packages
|
|
46
|
+
- rendering route HTML
|
|
47
|
+
- processing theme assets
|
|
48
|
+
- generating special files such as:
|
|
49
|
+
- `sitemap.xml`
|
|
50
|
+
- `feed.xml`
|
|
51
|
+
- `robots.txt`
|
|
52
|
+
- `meta.json`
|
|
53
|
+
- writing outputs through a pluggable writer
|
|
54
|
+
|
|
55
|
+
It does not talk to databases, queues, KV, Durable Objects, R2, or deployment infrastructure directly.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Core APIs
|
|
60
|
+
|
|
61
|
+
### `buildSite(input)`
|
|
62
|
+
|
|
63
|
+
Renders a full static artifact from preview-data and a theme package.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import { buildSite, MemoryWriter } from '@zeropress/build-core';
|
|
67
|
+
|
|
68
|
+
const writer = new MemoryWriter();
|
|
69
|
+
|
|
70
|
+
const result = await buildSite({
|
|
71
|
+
previewData,
|
|
72
|
+
themePackage,
|
|
73
|
+
writer,
|
|
74
|
+
options: {
|
|
75
|
+
writeManifest: true,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
{
|
|
84
|
+
files: [
|
|
85
|
+
{
|
|
86
|
+
path: 'index.html',
|
|
87
|
+
contentType: 'text/html',
|
|
88
|
+
size: 1234,
|
|
89
|
+
sha256: '...'
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
manifest: {
|
|
93
|
+
generatedAt: '2026-04-02T00:00:00.000Z',
|
|
94
|
+
files: [
|
|
95
|
+
{
|
|
96
|
+
path: 'index.html',
|
|
97
|
+
contentType: 'text/html',
|
|
98
|
+
size: 1234,
|
|
99
|
+
sha256: '...'
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `buildSelectedRoutes(input)`
|
|
107
|
+
|
|
108
|
+
Renders only selected post and list routes while preserving full-build parity for those outputs.
|
|
109
|
+
|
|
110
|
+
This is intended for selective publish and other partial-render workflows.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
await buildSelectedRoutes({
|
|
114
|
+
previewData,
|
|
115
|
+
themePackage,
|
|
116
|
+
writer,
|
|
117
|
+
selection: {
|
|
118
|
+
posts: ['hello-zeropress'],
|
|
119
|
+
indexRoutes: ['/'],
|
|
120
|
+
archiveRoutes: ['/archive/'],
|
|
121
|
+
categoryRoutes: ['/categories/general/'],
|
|
122
|
+
tagRoutes: ['/tags/intro/'],
|
|
123
|
+
includeAssets: false,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `buildSiteFromThemeDir(input)`
|
|
129
|
+
|
|
130
|
+
Loads a theme directory from disk and renders it using the same core pipeline.
|
|
131
|
+
|
|
132
|
+
This is useful for local tooling and CLI flows.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Writers
|
|
137
|
+
|
|
138
|
+
### `MemoryWriter`
|
|
139
|
+
|
|
140
|
+
Collects generated files in memory.
|
|
141
|
+
|
|
142
|
+
Best for:
|
|
143
|
+
|
|
144
|
+
- tests
|
|
145
|
+
- comparisons
|
|
146
|
+
- in-process orchestration
|
|
147
|
+
|
|
148
|
+
### `FilesystemWriter`
|
|
149
|
+
|
|
150
|
+
Writes generated files to a target directory.
|
|
151
|
+
|
|
152
|
+
Best for:
|
|
153
|
+
|
|
154
|
+
- local output generation
|
|
155
|
+
- CLI workflows
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Input Contracts
|
|
160
|
+
|
|
161
|
+
### Preview Data
|
|
162
|
+
|
|
163
|
+
`previewData` must satisfy the canonical ZeroPress preview-data contract enforced by:
|
|
164
|
+
|
|
165
|
+
- [`@zeropress/preview-data-validator`](https://www.npmjs.com/package/@zeropress/preview-data-validator)
|
|
166
|
+
|
|
167
|
+
### Theme Package
|
|
168
|
+
|
|
169
|
+
`themePackage` must contain:
|
|
170
|
+
|
|
171
|
+
- `metadata`
|
|
172
|
+
- `templates`
|
|
173
|
+
- `partials`
|
|
174
|
+
- `assets`
|
|
175
|
+
|
|
176
|
+
Theme validation is enforced through:
|
|
177
|
+
|
|
178
|
+
- [`@zeropress/theme-validator`](https://www.npmjs.com/package/@zeropress/theme-validator)
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Build Options
|
|
183
|
+
|
|
184
|
+
Supported options:
|
|
185
|
+
|
|
186
|
+
- `assetHashing`
|
|
187
|
+
- `generateSpecialFiles`
|
|
188
|
+
- `injectHtmx`
|
|
189
|
+
- `writeManifest`
|
|
190
|
+
|
|
191
|
+
These options apply to both full builds and partial renders where relevant.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Requirements
|
|
196
|
+
|
|
197
|
+
- Node.js >= 18.18.0
|
|
198
|
+
- ESM only
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Related
|
|
203
|
+
|
|
204
|
+
- [@zeropress/preview-data-validator](https://www.npmjs.com/package/@zeropress/preview-data-validator)
|
|
205
|
+
- [@zeropress/theme-validator](https://www.npmjs.com/package/@zeropress/theme-validator)
|
|
206
|
+
- [zeropress-theme](https://www.npmjs.com/package/zeropress-theme)
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zeropress/build-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared ZeroPress build and partial-render core",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/zeropress-app/zeropress-build-core.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/zeropress-app/zeropress-build-core/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/zeropress-app/zeropress-build-core#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"zeropress",
|
|
24
|
+
"ssg",
|
|
25
|
+
"static-site-generator",
|
|
26
|
+
"build-core",
|
|
27
|
+
"theme"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.18.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@zeropress/preview-data-validator": "^0.2.0",
|
|
37
|
+
"@zeropress/theme-validator": "^0.2.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { buildSite } from './build-site.js';
|
|
2
|
+
import { loadThemePackageFromDir } from '../theme/load-theme-dir.js';
|
|
3
|
+
|
|
4
|
+
export async function buildSiteFromThemeDir(input) {
|
|
5
|
+
const themePackage = await loadThemePackageFromDir(input.themeDir);
|
|
6
|
+
return buildSite({
|
|
7
|
+
previewData: input.previewData,
|
|
8
|
+
themePackage,
|
|
9
|
+
writer: input.writer,
|
|
10
|
+
options: input.options,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assertPreviewData } from '@zeropress/preview-data-validator';
|
|
3
|
+
import { validateThemeFiles } from '@zeropress/theme-validator';
|
|
4
|
+
import { AssetProcessor } from '../assets/asset-processor.js';
|
|
5
|
+
import { ZeroPressEngine } from '../render/zeropress-engine.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_OPTIONS = {
|
|
8
|
+
assetHashing: true,
|
|
9
|
+
generateSpecialFiles: true,
|
|
10
|
+
injectHtmx: true,
|
|
11
|
+
writeManifest: false,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function buildSite(input) {
|
|
15
|
+
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
16
|
+
const state = await createBuildState(input, options);
|
|
17
|
+
|
|
18
|
+
for (const route of input.previewData.routes.index) {
|
|
19
|
+
await renderRoute(state, 'index', route);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const post of input.previewData.content.posts) {
|
|
23
|
+
await renderPost(state, post);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const page of input.previewData.content.pages) {
|
|
27
|
+
await renderPage(state, page);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const route of input.previewData.routes.categories) {
|
|
31
|
+
await renderRoute(state, 'category', route);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const route of input.previewData.routes.tags) {
|
|
35
|
+
await renderRoute(state, 'tag', route);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const route of input.previewData.routes.archive) {
|
|
39
|
+
await renderRoute(state, 'archive', route);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const assetOutput of state.assetOutputs) {
|
|
43
|
+
await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (options.generateSpecialFiles) {
|
|
47
|
+
await writeOutput(state.writer, state.summaries, 'sitemap.xml', buildSitemapXml(input.previewData), 'application/xml');
|
|
48
|
+
await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(input.previewData), 'application/rss+xml');
|
|
49
|
+
await writeOutput(state.writer, state.summaries, 'robots.txt', buildRobotsTxt(input.previewData), 'text/plain');
|
|
50
|
+
await writeOutput(state.writer, state.summaries, 'meta.json', buildMetaJson(input.previewData), 'application/json');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return finalizeBuildResult(state.writer, state.summaries, options);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function buildSelectedRoutes(input) {
|
|
57
|
+
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
58
|
+
validateSelection(input.selection);
|
|
59
|
+
|
|
60
|
+
const state = await createBuildState(input, options);
|
|
61
|
+
const selectedPostSlugs = new Set((input.selection.posts || []).map((slug) => String(slug)));
|
|
62
|
+
const selectedIndexRoutes = new Set((input.selection.indexRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
63
|
+
const selectedArchiveRoutes = new Set((input.selection.archiveRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
64
|
+
const selectedCategoryRoutes = new Set((input.selection.categoryRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
65
|
+
const selectedTagRoutes = new Set((input.selection.tagRoutes || []).map((route) => normalizeRoutePath(route)));
|
|
66
|
+
|
|
67
|
+
for (const post of input.previewData.content.posts) {
|
|
68
|
+
if (selectedPostSlugs.has(post.slug)) {
|
|
69
|
+
await renderPost(state, post);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const route of input.previewData.routes.index) {
|
|
74
|
+
if (selectedIndexRoutes.has(normalizeRoutePath(route.path))) {
|
|
75
|
+
await renderRoute(state, 'index', route);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const route of input.previewData.routes.archive) {
|
|
80
|
+
if (selectedArchiveRoutes.has(normalizeRoutePath(route.path))) {
|
|
81
|
+
await renderRoute(state, 'archive', route);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const route of input.previewData.routes.categories) {
|
|
86
|
+
if (selectedCategoryRoutes.has(normalizeRoutePath(route.path))) {
|
|
87
|
+
await renderRoute(state, 'category', route);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const route of input.previewData.routes.tags) {
|
|
92
|
+
if (selectedTagRoutes.has(normalizeRoutePath(route.path))) {
|
|
93
|
+
await renderRoute(state, 'tag', route);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (input.selection.includeAssets) {
|
|
98
|
+
for (const assetOutput of state.assetOutputs) {
|
|
99
|
+
await writeOutput(state.writer, state.summaries, assetOutput.path, assetOutput.content, assetOutput.contentType);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return finalizeBuildResult(state.writer, state.summaries, options);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function createBuildState(input, options) {
|
|
107
|
+
if (!input?.writer || typeof input.writer.write !== 'function') {
|
|
108
|
+
throw new Error('buildSite requires a writer with an async write(file) method');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
assertPreviewData(input.previewData);
|
|
112
|
+
await assertValidThemePackage(input.themePackage);
|
|
113
|
+
|
|
114
|
+
const engine = new ZeroPressEngine();
|
|
115
|
+
const assetProcessor = new AssetProcessor();
|
|
116
|
+
const summaries = [];
|
|
117
|
+
const themePackage = input.themePackage;
|
|
118
|
+
|
|
119
|
+
engine.initialize(themePackage);
|
|
120
|
+
|
|
121
|
+
const assetOutputs = await buildAssetOutputs(themePackage.assets, assetProcessor, options);
|
|
122
|
+
const assetMap = new Map(
|
|
123
|
+
assetOutputs.map((asset) => [`/assets/${asset.originalPath}`, `/${asset.path}`]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
writer: input.writer,
|
|
128
|
+
previewData: input.previewData,
|
|
129
|
+
engine,
|
|
130
|
+
assetProcessor,
|
|
131
|
+
summaries,
|
|
132
|
+
assetOutputs,
|
|
133
|
+
assetMap,
|
|
134
|
+
options,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function finalizeBuildResult(writer, summaries, options) {
|
|
139
|
+
let manifest;
|
|
140
|
+
if (options.writeManifest) {
|
|
141
|
+
manifest = {
|
|
142
|
+
generatedAt: new Date().toISOString(),
|
|
143
|
+
files: summaries.map((file) => ({ ...file })),
|
|
144
|
+
};
|
|
145
|
+
await writeOutput(
|
|
146
|
+
writer,
|
|
147
|
+
summaries,
|
|
148
|
+
'build-manifest.json',
|
|
149
|
+
JSON.stringify(manifest, null, 2),
|
|
150
|
+
'application/json',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
files: summaries,
|
|
156
|
+
manifest,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function renderRoute(state, templateName, route) {
|
|
161
|
+
let html = await state.engine.render(
|
|
162
|
+
templateName,
|
|
163
|
+
{ ...route },
|
|
164
|
+
createRenderContext(state.previewData.site, route.path),
|
|
165
|
+
);
|
|
166
|
+
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
167
|
+
await writeOutput(state.writer, state.summaries, routePathToOutputPath(route.path), html, 'text/html');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function renderPost(state, post) {
|
|
171
|
+
let html = await state.engine.render(
|
|
172
|
+
'post',
|
|
173
|
+
{ post },
|
|
174
|
+
createRenderContext(state.previewData.site, `/posts/${encodeSlugSegment(post.slug)}/`),
|
|
175
|
+
);
|
|
176
|
+
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
177
|
+
if (state.options.injectHtmx) {
|
|
178
|
+
html = injectHtmxScript(html);
|
|
179
|
+
}
|
|
180
|
+
await writeOutput(state.writer, state.summaries, `posts/${encodeSlugSegment(post.slug)}/index.html`, html, 'text/html');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function renderPage(state, page) {
|
|
184
|
+
let html = await state.engine.render(
|
|
185
|
+
'page',
|
|
186
|
+
{ page },
|
|
187
|
+
createRenderContext(state.previewData.site, `/${encodeSlugSegment(page.slug)}/`),
|
|
188
|
+
);
|
|
189
|
+
html = state.assetProcessor.updateAssetReferences(html, state.assetMap);
|
|
190
|
+
await writeOutput(state.writer, state.summaries, `${encodeSlugSegment(page.slug)}/index.html`, html, 'text/html');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateSelection(selection) {
|
|
194
|
+
if (!selection || typeof selection !== 'object') {
|
|
195
|
+
throw new Error('buildSelectedRoutes requires a selection object');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const keys = ['posts', 'indexRoutes', 'archiveRoutes', 'categoryRoutes', 'tagRoutes'];
|
|
199
|
+
for (const key of keys) {
|
|
200
|
+
if (!Array.isArray(selection[key])) {
|
|
201
|
+
throw new Error(`buildSelectedRoutes selection.${key} must be an array`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof selection.includeAssets !== 'boolean') {
|
|
206
|
+
throw new Error('buildSelectedRoutes selection.includeAssets must be a boolean');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function assertValidThemePackage(themePackage) {
|
|
211
|
+
if (!themePackage?.templates || !themePackage?.partials || !themePackage?.assets || !themePackage?.metadata) {
|
|
212
|
+
throw new Error('Invalid themePackage: expected metadata, templates, partials, and assets');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const fileMap = new Map();
|
|
216
|
+
fileMap.set('theme.json', JSON.stringify({
|
|
217
|
+
name: themePackage.metadata.name,
|
|
218
|
+
namespace: themePackage.metadata.namespace,
|
|
219
|
+
slug: themePackage.metadata.slug,
|
|
220
|
+
version: themePackage.metadata.version,
|
|
221
|
+
license: themePackage.metadata.license,
|
|
222
|
+
runtime: themePackage.metadata.runtime,
|
|
223
|
+
...(themePackage.metadata.author ? { author: themePackage.metadata.author } : {}),
|
|
224
|
+
...(themePackage.metadata.description ? { description: themePackage.metadata.description } : {}),
|
|
225
|
+
...(themePackage.metadata.thumbnail ? { thumbnail: themePackage.metadata.thumbnail } : {}),
|
|
226
|
+
settings: themePackage.metadata.settings || {},
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
for (const [templateName, templateContent] of themePackage.templates.entries()) {
|
|
230
|
+
fileMap.set(`${templateName}.html`, templateContent);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const [partialName, partialContent] of themePackage.partials.entries()) {
|
|
234
|
+
fileMap.set(`partials/${partialName}.html`, partialContent);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const [assetPath, assetContent] of themePackage.assets.entries()) {
|
|
238
|
+
fileMap.set(`assets/${assetPath}`, assetContent);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const validation = await validateThemeFiles(fileMap);
|
|
242
|
+
if (!validation.ok) {
|
|
243
|
+
throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function buildAssetOutputs(assets, assetProcessor, options) {
|
|
248
|
+
const outputs = [];
|
|
249
|
+
|
|
250
|
+
for (const [assetPath, content] of assets.entries()) {
|
|
251
|
+
const ext = assetPath.split('.').pop()?.toLowerCase();
|
|
252
|
+
let processedContent = content;
|
|
253
|
+
let contentType = getContentType(assetPath);
|
|
254
|
+
|
|
255
|
+
if (ext === 'css') {
|
|
256
|
+
processedContent = await assetProcessor.processCSS(new TextDecoder().decode(content));
|
|
257
|
+
contentType = 'text/css';
|
|
258
|
+
} else if (ext === 'js') {
|
|
259
|
+
processedContent = await assetProcessor.processJavaScript(new TextDecoder().decode(content));
|
|
260
|
+
contentType = 'application/javascript';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(content)}` : '';
|
|
264
|
+
const targetPath = `assets/${assetPath.replace(/(\.[^.]+)$/, `${hash}$1`)}`;
|
|
265
|
+
|
|
266
|
+
outputs.push({
|
|
267
|
+
originalPath: assetPath,
|
|
268
|
+
path: targetPath,
|
|
269
|
+
content: processedContent,
|
|
270
|
+
contentType,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return outputs;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function createRenderContext(site, currentUrl) {
|
|
278
|
+
return {
|
|
279
|
+
site,
|
|
280
|
+
currentUrl,
|
|
281
|
+
language: site.language,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function writeOutput(writer, summaries, path, content, contentType) {
|
|
286
|
+
const normalizedPath = normalizeOutputPath(path);
|
|
287
|
+
await writer.write({ path: normalizedPath, content, contentType });
|
|
288
|
+
summaries.push({
|
|
289
|
+
path: normalizedPath,
|
|
290
|
+
contentType,
|
|
291
|
+
size: getContentSize(content),
|
|
292
|
+
sha256: sha256(content),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function routePathToOutputPath(routePath) {
|
|
297
|
+
const normalizedPath = normalizeRoutePath(routePath);
|
|
298
|
+
if (normalizedPath === '/') {
|
|
299
|
+
return 'index.html';
|
|
300
|
+
}
|
|
301
|
+
return `${normalizedPath.replace(/^\//, '')}index.html`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeRoutePath(routePath) {
|
|
305
|
+
if (!routePath || routePath === '/') {
|
|
306
|
+
return '/';
|
|
307
|
+
}
|
|
308
|
+
return `/${String(routePath).replace(/^\/+|\/+$/g, '')}/`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeOutputPath(filePath) {
|
|
312
|
+
return String(filePath || '').replace(/^\/+/, '');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getContentSize(content) {
|
|
316
|
+
return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function sha256(content) {
|
|
320
|
+
const hash = createHash('sha256');
|
|
321
|
+
hash.update(content);
|
|
322
|
+
return hash.digest('hex');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function injectHtmxScript(html) {
|
|
326
|
+
return html.replace('</body>', '<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>\n</body>');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildSitemapXml(previewData) {
|
|
330
|
+
const entries = [
|
|
331
|
+
{ url: '/', lastmod: new Date(), changefreq: 'daily', priority: 1.0 },
|
|
332
|
+
...previewData.content.posts.map((post) => ({
|
|
333
|
+
url: `/posts/${encodeSlugSegment(post.slug)}/`,
|
|
334
|
+
lastmod: toDate(post.updated_at_iso),
|
|
335
|
+
changefreq: 'weekly',
|
|
336
|
+
priority: 0.8,
|
|
337
|
+
})),
|
|
338
|
+
...previewData.content.pages.map((page) => ({
|
|
339
|
+
url: `/${encodeSlugSegment(page.slug)}/`,
|
|
340
|
+
lastmod: new Date(),
|
|
341
|
+
changefreq: 'monthly',
|
|
342
|
+
priority: 0.7,
|
|
343
|
+
})),
|
|
344
|
+
...previewData.content.categories.filter((category) => category.postCount > 0).map((category) => ({
|
|
345
|
+
url: `/categories/${encodeSlugSegment(category.slug)}/`,
|
|
346
|
+
lastmod: new Date(),
|
|
347
|
+
changefreq: 'weekly',
|
|
348
|
+
priority: 0.6,
|
|
349
|
+
})),
|
|
350
|
+
...previewData.content.tags.filter((tag) => tag.postCount > 0).map((tag) => ({
|
|
351
|
+
url: `/tags/${encodeSlugSegment(tag.slug)}/`,
|
|
352
|
+
lastmod: new Date(),
|
|
353
|
+
changefreq: 'weekly',
|
|
354
|
+
priority: 0.5,
|
|
355
|
+
})),
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
const body = entries.map((entry) => {
|
|
359
|
+
const loc = escapeXml(new URL(entry.url, previewData.site.url).toString());
|
|
360
|
+
const lastmod = entry.lastmod.toISOString().split('T')[0];
|
|
361
|
+
return ` <url>\n <loc>${loc}</loc>\n <lastmod>${lastmod}</lastmod>\n <changefreq>${entry.changefreq}</changefreq>\n <priority>${entry.priority.toFixed(1)}</priority>\n </url>`;
|
|
362
|
+
}).join('\n');
|
|
363
|
+
|
|
364
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function buildFeedXml(previewData) {
|
|
368
|
+
const items = [...previewData.content.posts]
|
|
369
|
+
.sort((a, b) => toDate(b.published_at_iso).getTime() - toDate(a.published_at_iso).getTime())
|
|
370
|
+
.slice(0, 20)
|
|
371
|
+
.map((post) => {
|
|
372
|
+
const url = new URL(`/posts/${encodeSlugSegment(post.slug)}/`, previewData.site.url).toString();
|
|
373
|
+
return ` <item>\n <title>${escapeXml(post.title)}</title>\n <link>${escapeXml(url)}</link>\n <guid>${escapeXml(url)}</guid>\n <pubDate>${toDate(post.published_at_iso).toUTCString()}</pubDate>\n <description>${escapeXml(post.excerpt)}</description>\n </item>`;
|
|
374
|
+
})
|
|
375
|
+
.join('\n');
|
|
376
|
+
|
|
377
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(previewData.site.title)}</title>\n <link>${escapeXml(previewData.site.url)}</link>\n <description>${escapeXml(previewData.site.description)}</description>\n <language>${previewData.site.language}</language>\n <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(new URL('/feed.xml', previewData.site.url).toString())}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function buildRobotsTxt(previewData) {
|
|
381
|
+
return `User-agent: *\nAllow: /\n\nSitemap: ${previewData.site.url}/sitemap.xml\n`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function buildMetaJson(previewData) {
|
|
385
|
+
const pages = [
|
|
386
|
+
...previewData.content.posts.map((post) => ({
|
|
387
|
+
url: `/posts/${encodeSlugSegment(post.slug)}/`,
|
|
388
|
+
title: post.title,
|
|
389
|
+
description: post.excerpt,
|
|
390
|
+
publishedAt: post.published_at_iso,
|
|
391
|
+
updatedAt: post.updated_at_iso,
|
|
392
|
+
type: 'post',
|
|
393
|
+
metadata: { status: post.status },
|
|
394
|
+
})),
|
|
395
|
+
...previewData.content.pages.map((page) => ({
|
|
396
|
+
url: `/${encodeSlugSegment(page.slug)}/`,
|
|
397
|
+
title: page.title,
|
|
398
|
+
type: 'page',
|
|
399
|
+
metadata: { status: page.status },
|
|
400
|
+
})),
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
return JSON.stringify({
|
|
404
|
+
generated: new Date().toISOString(),
|
|
405
|
+
site: {
|
|
406
|
+
title: previewData.site.title,
|
|
407
|
+
description: previewData.site.description,
|
|
408
|
+
url: previewData.site.url,
|
|
409
|
+
language: previewData.site.language,
|
|
410
|
+
},
|
|
411
|
+
pages,
|
|
412
|
+
}, null, 2);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getContentType(assetPath) {
|
|
416
|
+
const ext = assetPath.split('.').pop()?.toLowerCase();
|
|
417
|
+
const contentTypes = {
|
|
418
|
+
html: 'text/html',
|
|
419
|
+
css: 'text/css',
|
|
420
|
+
js: 'application/javascript',
|
|
421
|
+
json: 'application/json',
|
|
422
|
+
xml: 'application/xml',
|
|
423
|
+
txt: 'text/plain',
|
|
424
|
+
jpg: 'image/jpeg',
|
|
425
|
+
jpeg: 'image/jpeg',
|
|
426
|
+
png: 'image/png',
|
|
427
|
+
gif: 'image/gif',
|
|
428
|
+
webp: 'image/webp',
|
|
429
|
+
avif: 'image/avif',
|
|
430
|
+
svg: 'image/svg+xml',
|
|
431
|
+
ico: 'image/x-icon',
|
|
432
|
+
woff: 'font/woff',
|
|
433
|
+
woff2: 'font/woff2',
|
|
434
|
+
ttf: 'font/ttf',
|
|
435
|
+
eot: 'application/vnd.ms-fontobject',
|
|
436
|
+
};
|
|
437
|
+
return contentTypes[ext || ''] || 'application/octet-stream';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function encodeSlugSegment(slug) {
|
|
441
|
+
return encodeURIComponent(String(slug));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function toDate(value) {
|
|
445
|
+
return value ? new Date(value) : new Date();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function escapeXml(str) {
|
|
449
|
+
return String(str || '')
|
|
450
|
+
.replace(/&/g, '&')
|
|
451
|
+
.replace(/</g, '<')
|
|
452
|
+
.replace(/>/g, '>')
|
|
453
|
+
.replace(/"/g, '"')
|
|
454
|
+
.replace(/'/g, ''');
|
|
455
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export class AssetProcessor {
|
|
4
|
+
async processCSS(css) {
|
|
5
|
+
return css
|
|
6
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
7
|
+
.replace(/\s+/g, ' ')
|
|
8
|
+
.replace(/\s*([{}:;,>+~])\s*/g, '$1')
|
|
9
|
+
.replace(/;}/g, '}')
|
|
10
|
+
.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async processJavaScript(js) {
|
|
14
|
+
return js
|
|
15
|
+
.replace(/\/\/(?![^\r\n]*https?:)[^\r\n]*/g, '')
|
|
16
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.replace(/\s*([{}();,=+\-*/<>!&|])\s*/g, '$1')
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
generateAssetHash(content) {
|
|
23
|
+
const hash = createHash('sha256');
|
|
24
|
+
hash.update(content);
|
|
25
|
+
return hash.digest('hex').substring(0, 8);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
updateAssetReferences(html, assetMap) {
|
|
29
|
+
let updatedHtml = html;
|
|
30
|
+
|
|
31
|
+
for (const [original, hashed] of assetMap.entries()) {
|
|
32
|
+
const cssRegex = new RegExp(`href=["']${escapeRegex(original)}["']`, 'g');
|
|
33
|
+
updatedHtml = updatedHtml.replace(cssRegex, `href="${hashed}"`);
|
|
34
|
+
|
|
35
|
+
const srcRegex = new RegExp(`src=["']${escapeRegex(original)}["']`, 'g');
|
|
36
|
+
updatedHtml = updatedHtml.replace(srcRegex, `src="${hashed}"`);
|
|
37
|
+
|
|
38
|
+
const urlRegex = new RegExp(`url\\(["']?${escapeRegex(original)}["']?\\)`, 'g');
|
|
39
|
+
updatedHtml = updatedHtml.replace(urlRegex, `url("${hashed}")`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return updatedHtml;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function escapeRegex(str) {
|
|
47
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
48
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { PreviewDataV03 } from '@zeropress/preview-data-validator';
|
|
2
|
+
|
|
3
|
+
export interface ThemePackage {
|
|
4
|
+
metadata: {
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
author?: string | null;
|
|
8
|
+
description?: string;
|
|
9
|
+
thumbnail?: string;
|
|
10
|
+
settings?: Record<string, unknown>;
|
|
11
|
+
namespace?: string;
|
|
12
|
+
slug?: string;
|
|
13
|
+
license?: 'MIT' | 'Apache-2.0' | 'BSD-3-Clause' | 'GPL-3.0-only' | 'GPL-3.0-or-later';
|
|
14
|
+
runtime?: '0.2';
|
|
15
|
+
};
|
|
16
|
+
templates: Map<string, string>;
|
|
17
|
+
partials: Map<string, string>;
|
|
18
|
+
assets: Map<string, Uint8Array>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BuildCoreFile {
|
|
22
|
+
path: string;
|
|
23
|
+
content: string | Uint8Array;
|
|
24
|
+
contentType: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BuildWriter {
|
|
28
|
+
write(file: BuildCoreFile): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BuildOptions {
|
|
32
|
+
assetHashing?: boolean;
|
|
33
|
+
generateSpecialFiles?: boolean;
|
|
34
|
+
injectHtmx?: boolean;
|
|
35
|
+
writeManifest?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BuildSelection {
|
|
39
|
+
posts: string[];
|
|
40
|
+
indexRoutes: string[];
|
|
41
|
+
archiveRoutes: string[];
|
|
42
|
+
categoryRoutes: string[];
|
|
43
|
+
tagRoutes: string[];
|
|
44
|
+
includeAssets: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BuildSummaryFile {
|
|
48
|
+
path: string;
|
|
49
|
+
contentType: string;
|
|
50
|
+
size: number;
|
|
51
|
+
sha256: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BuildManifest {
|
|
55
|
+
generatedAt: string;
|
|
56
|
+
files: BuildSummaryFile[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BuildSiteResult {
|
|
60
|
+
files: BuildSummaryFile[];
|
|
61
|
+
manifest?: BuildManifest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildSite(input: {
|
|
65
|
+
previewData: PreviewDataV03;
|
|
66
|
+
themePackage: ThemePackage;
|
|
67
|
+
writer: BuildWriter;
|
|
68
|
+
options?: BuildOptions;
|
|
69
|
+
}): Promise<BuildSiteResult>;
|
|
70
|
+
|
|
71
|
+
export function buildSelectedRoutes(input: {
|
|
72
|
+
previewData: PreviewDataV03;
|
|
73
|
+
themePackage: ThemePackage;
|
|
74
|
+
writer: BuildWriter;
|
|
75
|
+
selection: BuildSelection;
|
|
76
|
+
options?: BuildOptions;
|
|
77
|
+
}): Promise<BuildSiteResult>;
|
|
78
|
+
|
|
79
|
+
export function buildSiteFromThemeDir(input: {
|
|
80
|
+
previewData: PreviewDataV03;
|
|
81
|
+
themeDir: string;
|
|
82
|
+
writer: BuildWriter;
|
|
83
|
+
options?: BuildOptions;
|
|
84
|
+
}): Promise<BuildSiteResult>;
|
|
85
|
+
|
|
86
|
+
export class MemoryWriter implements BuildWriter {
|
|
87
|
+
constructor();
|
|
88
|
+
write(file: BuildCoreFile): Promise<void>;
|
|
89
|
+
getFiles(): BuildCoreFile[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class FilesystemWriter implements BuildWriter {
|
|
93
|
+
constructor(options: { outDir: string });
|
|
94
|
+
write(file: BuildCoreFile): Promise<void>;
|
|
95
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { buildSite } from './api/build-site.js';
|
|
2
|
+
export { buildSelectedRoutes } from './api/build-site.js';
|
|
3
|
+
export { buildSiteFromThemeDir } from './api/build-site-from-theme-dir.js';
|
|
4
|
+
export { MemoryWriter } from './output/memory-writer.js';
|
|
5
|
+
export { FilesystemWriter } from './output/filesystem-writer.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class FilesystemWriter {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
if (!options?.outDir) {
|
|
4
|
+
throw new Error('FilesystemWriter requires outDir');
|
|
5
|
+
}
|
|
6
|
+
this.outDir = options.outDir;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async write(file) {
|
|
10
|
+
const fs = await import('node:fs/promises');
|
|
11
|
+
const path = await import('node:path');
|
|
12
|
+
const relativePath = String(file.path || '').replace(/^\/+/, '');
|
|
13
|
+
const fullPath = path.join(this.outDir, relativePath);
|
|
14
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
15
|
+
await fs.writeFile(fullPath, file.content);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class MemoryWriter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.files = [];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
async write(file) {
|
|
7
|
+
this.files.push({
|
|
8
|
+
path: normalizeOutputPath(file.path),
|
|
9
|
+
content: file.content,
|
|
10
|
+
contentType: file.contentType,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getFiles() {
|
|
15
|
+
return [...this.files];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeOutputPath(filePath) {
|
|
20
|
+
return String(filePath || '').replace(/^\/+/, '');
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class SlotResolver {
|
|
2
|
+
static SLOT_PATTERN = /\{\{slot:([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
|
3
|
+
|
|
4
|
+
resolve(template, partials, content) {
|
|
5
|
+
return template.replace(SlotResolver.SLOT_PATTERN, (_, slotName) => {
|
|
6
|
+
if (slotName === 'content') {
|
|
7
|
+
return content;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const partialContent = partials.get(slotName);
|
|
11
|
+
return partialContent !== undefined ? partialContent : '';
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class VariableResolver {
|
|
2
|
+
static VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
|
3
|
+
static STANDALONE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
|
|
4
|
+
|
|
5
|
+
resolve(template, data) {
|
|
6
|
+
let result = template.replace(VariableResolver.VARIABLE_PATTERN, (_, variablePath) => {
|
|
7
|
+
const [prefix, field] = variablePath.split('.');
|
|
8
|
+
const prefixData = data[prefix];
|
|
9
|
+
|
|
10
|
+
if (!prefixData || typeof prefixData !== 'object') {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const value = prefixData[field];
|
|
15
|
+
return value == null ? '' : String(value);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
result = result.replace(VariableResolver.STANDALONE_PATTERN, (match, variableName) => {
|
|
19
|
+
if (variableName.startsWith('slot:')) {
|
|
20
|
+
return match;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const value = data[variableName];
|
|
24
|
+
return value == null ? '' : String(value);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SlotResolver } from './slot-resolver.js';
|
|
2
|
+
import { VariableResolver } from './variable-resolver.js';
|
|
3
|
+
|
|
4
|
+
export class ZeroPressEngine {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.slotResolver = new SlotResolver();
|
|
7
|
+
this.variableResolver = new VariableResolver();
|
|
8
|
+
this.themePackage = null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
initialize(themePackage) {
|
|
12
|
+
this.themePackage = themePackage;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async render(templateName, data, context) {
|
|
16
|
+
if (!this.themePackage) {
|
|
17
|
+
throw new Error('Theme package not initialized');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const template = this.themePackage.templates.get(templateName);
|
|
21
|
+
if (!template) {
|
|
22
|
+
throw new Error(`Template "${templateName}" not found`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const layout = this.themePackage.templates.get('layout');
|
|
26
|
+
if (!layout) {
|
|
27
|
+
throw new Error('Template "layout" not found');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const renderedContent = this.variableResolver.resolve(template, this.combineRenderData(data, context));
|
|
31
|
+
const layoutWithSlots = this.slotResolver.resolve(layout, this.themePackage.partials, renderedContent);
|
|
32
|
+
return this.variableResolver.resolve(layoutWithSlots, this.combineRenderData(data, context));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
combineRenderData(data, context) {
|
|
36
|
+
return {
|
|
37
|
+
...data,
|
|
38
|
+
site: context.site,
|
|
39
|
+
currentUrl: context.currentUrl,
|
|
40
|
+
language: context.language,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { validateThemeFiles } from '@zeropress/theme-validator';
|
|
2
|
+
|
|
3
|
+
const TEXT_FILE_EXTENSIONS = new Set(['.html', '.json', '.css', '.js', '.txt', '.svg', '.xml']);
|
|
4
|
+
|
|
5
|
+
export async function loadThemePackageFromDir(themeDir) {
|
|
6
|
+
const fs = await import('node:fs/promises');
|
|
7
|
+
const path = await import('node:path');
|
|
8
|
+
|
|
9
|
+
const fileMap = new Map();
|
|
10
|
+
await readThemeDir(fs, path, themeDir, themeDir, fileMap);
|
|
11
|
+
|
|
12
|
+
const validation = await validateThemeFiles(fileMap);
|
|
13
|
+
if (!validation.ok) {
|
|
14
|
+
throw new Error(`Theme validation failed: ${validation.errors[0]?.message || 'Unknown error'}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rawThemeJson = String(fileMap.get('theme.json'));
|
|
18
|
+
const themeJson = JSON.parse(rawThemeJson);
|
|
19
|
+
const templates = new Map();
|
|
20
|
+
const partials = new Map();
|
|
21
|
+
const assets = new Map();
|
|
22
|
+
|
|
23
|
+
for (const [filePath, value] of fileMap.entries()) {
|
|
24
|
+
if (filePath === 'theme.json') {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (filePath.startsWith('partials/') && filePath.endsWith('.html')) {
|
|
29
|
+
const partialName = filePath.replace(/^partials\//, '').replace(/\.html$/, '');
|
|
30
|
+
partials.set(partialName, String(value));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (filePath.startsWith('assets/')) {
|
|
35
|
+
const assetPath = filePath.replace(/^assets\//, '');
|
|
36
|
+
assets.set(assetPath, toUint8Array(value));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (filePath.endsWith('.html') && !filePath.includes('/')) {
|
|
41
|
+
const templateName = filePath.replace(/\.html$/, '');
|
|
42
|
+
templates.set(templateName, String(value));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
metadata: {
|
|
48
|
+
name: themeJson.name,
|
|
49
|
+
version: themeJson.version,
|
|
50
|
+
author: themeJson.author,
|
|
51
|
+
description: themeJson.description,
|
|
52
|
+
thumbnail: themeJson.thumbnail,
|
|
53
|
+
settings: themeJson.settings || {},
|
|
54
|
+
namespace: themeJson.namespace,
|
|
55
|
+
slug: themeJson.slug,
|
|
56
|
+
license: themeJson.license,
|
|
57
|
+
runtime: themeJson.runtime,
|
|
58
|
+
},
|
|
59
|
+
templates,
|
|
60
|
+
partials,
|
|
61
|
+
assets,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readThemeDir(fs, path, rootDir, currentDir, fileMap) {
|
|
66
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
70
|
+
const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, '/');
|
|
71
|
+
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
await readThemeDir(fs, path, rootDir, fullPath, fileMap);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
78
|
+
if (TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
79
|
+
fileMap.set(relativePath, await fs.readFile(fullPath, 'utf8'));
|
|
80
|
+
} else {
|
|
81
|
+
fileMap.set(relativePath, new Uint8Array(await fs.readFile(fullPath)));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toUint8Array(value) {
|
|
87
|
+
if (value instanceof Uint8Array) {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new TextEncoder().encode(String(value));
|
|
92
|
+
}
|