@coraltravelcenter/b2c-landing-builder 2.0.0-next.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 +180 -0
- package/bin/b2c-landing.mjs +8 -0
- package/package.json +48 -0
- package/src/blocks/add.mjs +40 -0
- package/src/blocks/rename.mjs +36 -0
- package/src/blocks/utils.mjs +26 -0
- package/src/cli/index.mjs +71 -0
- package/src/config/load-config.mjs +20 -0
- package/src/config/resolve-preset.mjs +13 -0
- package/src/config/validate-config.mjs +32 -0
- package/src/config/validate-project.mjs +52 -0
- package/src/lib/_utils.mjs +63 -0
- package/src/lib/build-cms.mjs +184 -0
- package/src/lib/landingBlocksPlugin.mjs +97 -0
- package/src/lib/processMarkup.mjs +9 -0
- package/src/lib/pugMarkup.mjs +31 -0
- package/src/lib/rewriteAssetsBuild.mjs +180 -0
- package/src/lib/rewriteAssetsDev.js +128 -0
- package/src/presets/coral.mjs +5 -0
- package/src/presets/sunmar.mjs +5 -0
- package/src/update/check-update.mjs +66 -0
- package/src/vite/client.js +48 -0
- package/src/vite/config.mjs +57 -0
- package/src/vite/dependencies.mjs +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# @coraltravelcenter/b2c-landing-builder
|
|
2
|
+
|
|
3
|
+
Глобальный CLI для разработки B2C-лендингов через Tampermonkey и сборки
|
|
4
|
+
CMS-готовых HTML-файлов.
|
|
5
|
+
|
|
6
|
+
## Установка
|
|
7
|
+
|
|
8
|
+
Требуется Node.js 20.19 или новее.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install --global @coraltravelcenter/b2c-landing-builder
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Builder устанавливается один раз и не копируется в проекты лендингов.
|
|
15
|
+
|
|
16
|
+
## Команды
|
|
17
|
+
|
|
18
|
+
Команды выполняются из корня проекта:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
b2c-landing check
|
|
22
|
+
b2c-landing dev
|
|
23
|
+
b2c-landing build
|
|
24
|
+
b2c-landing block:add hero
|
|
25
|
+
b2c-landing block:rename hero main-hero
|
|
26
|
+
b2c-landing update
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `check` проверяет конфигурацию, `order.json` и контракт JS-блоков.
|
|
30
|
+
- `dev` запускает Vite и userscript для Tampermonkey.
|
|
31
|
+
- `build` создаёт CMS-файлы в `@CMS/` и переписывает пути ассетов на CDN.
|
|
32
|
+
- `block:add` создаёт файлы в выбранных форматах разметки и стилей.
|
|
33
|
+
- `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
|
|
34
|
+
- `update` проверяет npm registry и показывает команду ручного обновления.
|
|
35
|
+
|
|
36
|
+
Builder никогда не обновляется автоматически:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
b2c-landing update
|
|
40
|
+
# Update manually: npm install --global @coraltravelcenter/b2c-landing-builder@latest
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Создание проекта
|
|
44
|
+
|
|
45
|
+
Новые проекты создаются отдельным пакетом:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm create @coraltravelcenter/b2c-landing-vite
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Репозиторий генератора:
|
|
52
|
+
[CoralTravelCenter/create-b2c-landing-vite](https://github.com/CoralTravelCenter/create-b2c-landing-vite).
|
|
53
|
+
|
|
54
|
+
## Конфигурация
|
|
55
|
+
|
|
56
|
+
В корне каждого проекта находится `landing.config.mjs`:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
export default {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
project: {
|
|
62
|
+
name: "uae-promo",
|
|
63
|
+
},
|
|
64
|
+
site: {
|
|
65
|
+
preset: "coral",
|
|
66
|
+
},
|
|
67
|
+
stack: {
|
|
68
|
+
script: "vue",
|
|
69
|
+
markup: "pug",
|
|
70
|
+
styles: "less",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Имя проекта должно соответствовать `^[a-z0-9][a-z0-9-]*$`.
|
|
76
|
+
|
|
77
|
+
Поддерживаются:
|
|
78
|
+
|
|
79
|
+
- JavaScript: `vanilla`, `vue`;
|
|
80
|
+
- разметка: `html`, `pug`;
|
|
81
|
+
- стили: `css`, `scss`, `less`;
|
|
82
|
+
- сайты: `coral`, `sunmar`.
|
|
83
|
+
|
|
84
|
+
Monkey URL, контейнер и CDN prefix не задаются вручную:
|
|
85
|
+
|
|
86
|
+
- URL: `https://<домен-пресета>/monkey/*`;
|
|
87
|
+
- контейнер: `#monkey-app`;
|
|
88
|
+
- CDN prefix: `landing-pages/<project.name>`.
|
|
89
|
+
|
|
90
|
+
Production CDN Sunmar пока не настроен. `dev` и `check` доступны, но `build`
|
|
91
|
+
завершается понятной ошибкой до добавления CDN в preset.
|
|
92
|
+
|
|
93
|
+
## Структура проекта
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
src/
|
|
97
|
+
order.json
|
|
98
|
+
markup/
|
|
99
|
+
welcome.html | welcome.pug
|
|
100
|
+
styles/
|
|
101
|
+
welcome.css | welcome.scss | welcome.less
|
|
102
|
+
scripts/
|
|
103
|
+
welcome.js
|
|
104
|
+
components/ # только для Vue
|
|
105
|
+
public/
|
|
106
|
+
pug.rc # только для Pug
|
|
107
|
+
landing.config.mjs
|
|
108
|
+
package.json
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
В проекте не нужны `vite.config.js`, исходники builder или локальные build-скрипты.
|
|
112
|
+
|
|
113
|
+
## Порядок блоков
|
|
114
|
+
|
|
115
|
+
`src/order.json` является единственным источником порядка:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"blocks": ["hero", "welcome"]
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Для каждого ключа builder ищет разметку, стиль и необязательный JS-файл с тем
|
|
124
|
+
же именем. Форматы определяются `landing.config.mjs`.
|
|
125
|
+
|
|
126
|
+
## Контракт JavaScript
|
|
127
|
+
|
|
128
|
+
Если у блока есть `src/scripts/<key>.js`, файл обязан экспортировать функцию
|
|
129
|
+
инициализации по умолчанию:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
export default function hero(container) {
|
|
133
|
+
const block = container.querySelector(".hero");
|
|
134
|
+
if (!block) return;
|
|
135
|
+
|
|
136
|
+
// Инициализация блока.
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Builder вставляет разметку, затем вызывает функцию. Этот контракт одинаков для
|
|
141
|
+
dev, HMR и CMS build. Блок без JS допустим.
|
|
142
|
+
|
|
143
|
+
## Ассеты
|
|
144
|
+
|
|
145
|
+
Файлы хранятся в `public/` и используются от корня:
|
|
146
|
+
|
|
147
|
+
```html
|
|
148
|
+
<img src="/hero.webp" alt="">
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```css
|
|
152
|
+
.hero {
|
|
153
|
+
background-image: url("/hero.webp");
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
В dev пути направляются на `http://127.0.0.1:5173`. При production build они
|
|
158
|
+
преобразуются в `<assetsBase>/landing-pages/<project.name>/...`.
|
|
159
|
+
|
|
160
|
+
## Совместимость
|
|
161
|
+
|
|
162
|
+
Builder проверяет `schemaVersion` до запуска команд. Несовместимая версия схемы
|
|
163
|
+
блокирует работу. Для аварийной сборки старого проекта можно использовать:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npx @coraltravelcenter/b2c-landing-builder@<version> build
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Разработка пакета
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npm ci
|
|
173
|
+
npm test
|
|
174
|
+
npm run check
|
|
175
|
+
npm run build
|
|
176
|
+
npm pack --dry-run
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Pull request проверяется на Node.js 20.19 и 22. Prerelease публикуется с npm
|
|
180
|
+
dist-tag `next`, стабильная версия — с `latest`.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coraltravelcenter/b2c-landing-builder",
|
|
3
|
+
"version": "2.0.0-next.0",
|
|
4
|
+
"description": "CLI and build toolkit for B2C landing projects",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"b2c-landing": "bin/b2c-landing.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/blocks/",
|
|
12
|
+
"src/cli/",
|
|
13
|
+
"src/config/",
|
|
14
|
+
"src/lib/",
|
|
15
|
+
"src/presets/",
|
|
16
|
+
"src/update/",
|
|
17
|
+
"src/vite/"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.19.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "node scripts/run-fixture.mjs dev",
|
|
24
|
+
"check": "node scripts/run-fixture.mjs check",
|
|
25
|
+
"build": "node scripts/run-fixture.mjs build",
|
|
26
|
+
"test": "node --test test/*.test.mjs",
|
|
27
|
+
"test:dev": "node scripts/smoke-dev.mjs",
|
|
28
|
+
"pack:check": "npm pack --dry-run"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
32
|
+
"less": "^4.6.7",
|
|
33
|
+
"pug": "^3.0.4",
|
|
34
|
+
"sass": "^1.101.0",
|
|
35
|
+
"typograf": "^7.7.0",
|
|
36
|
+
"vite": "^8.1.4",
|
|
37
|
+
"vite-plugin-monkey": "^8.0.6",
|
|
38
|
+
"vue": "^3.5.39"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/CoralTravelCenter/b2c-landing-vite.git"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT"
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {assertBlockKey, readOrder, writeOrder} from "./utils.mjs";
|
|
5
|
+
|
|
6
|
+
function markupTemplate(key, format) {
|
|
7
|
+
return format === "pug" ? `section.${key}\n` : `<section class="${key}">\n</section>\n`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function styleTemplate(key) {
|
|
11
|
+
return `.${key} {\n}\n`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function scriptTemplate(key) {
|
|
15
|
+
const functionName = key.replace(/-+/g, "_");
|
|
16
|
+
return `export default function ${functionName}(container) {\n const block = container.querySelector(".${key}");\n if (!block) return;\n}\n`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function addBlock(key, config, root = process.cwd()) {
|
|
20
|
+
assertBlockKey(key);
|
|
21
|
+
const {order, orderPath} = readOrder(root);
|
|
22
|
+
if (order.blocks.includes(key)) throw new Error(`Block already exists in src/order.json: ${key}`);
|
|
23
|
+
|
|
24
|
+
const files = [
|
|
25
|
+
[path.resolve(root, "src/markup", `${key}.${config.stack.markup}`), markupTemplate(key, config.stack.markup)],
|
|
26
|
+
[path.resolve(root, "src/styles", `${key}.${config.stack.styles}`), styleTemplate(key)],
|
|
27
|
+
[path.resolve(root, "src/scripts", `${key}.js`), scriptTemplate(key)],
|
|
28
|
+
];
|
|
29
|
+
const collision = files.find(([filePath]) => fs.existsSync(filePath));
|
|
30
|
+
if (collision) throw new Error(`Cannot add block; target already exists: ${path.relative(root, collision[0])}`);
|
|
31
|
+
|
|
32
|
+
for (const [filePath, source] of files) {
|
|
33
|
+
fs.mkdirSync(path.dirname(filePath), {recursive: true});
|
|
34
|
+
fs.writeFileSync(filePath, source);
|
|
35
|
+
console.log(`[block:add] created: ${path.relative(root, filePath)}`);
|
|
36
|
+
}
|
|
37
|
+
order.blocks.push(key);
|
|
38
|
+
writeOrder(orderPath, order);
|
|
39
|
+
console.log(`[block:add] order.json updated (+${key})`);
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {assertBlockKey, readOrder, writeOrder} from "./utils.mjs";
|
|
5
|
+
|
|
6
|
+
const FILES = {markup: ["html", "pug"], styles: ["css", "scss", "less"], scripts: ["js"]};
|
|
7
|
+
|
|
8
|
+
export function renameBlock(fromKey, toKey, root = process.cwd()) {
|
|
9
|
+
assertBlockKey(fromKey, "source block key");
|
|
10
|
+
assertBlockKey(toKey, "target block key");
|
|
11
|
+
if (fromKey === toKey) throw new Error("Source and target block keys are identical");
|
|
12
|
+
|
|
13
|
+
const {order, orderPath} = readOrder(root);
|
|
14
|
+
if (!order.blocks.includes(fromKey)) throw new Error(`Block is absent from src/order.json: ${fromKey}`);
|
|
15
|
+
if (order.blocks.includes(toKey)) throw new Error(`Target block already exists in src/order.json: ${toKey}`);
|
|
16
|
+
|
|
17
|
+
const moves = [];
|
|
18
|
+
for (const [directory, extensions] of Object.entries(FILES)) {
|
|
19
|
+
for (const extension of extensions) {
|
|
20
|
+
const source = path.resolve(root, "src", directory, `${fromKey}.${extension}`);
|
|
21
|
+
if (!fs.existsSync(source)) continue;
|
|
22
|
+
moves.push({source, target: path.resolve(root, "src", directory, `${toKey}.${extension}`)});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (!moves.length) throw new Error(`No files found for block: ${fromKey}`);
|
|
26
|
+
const collision = moves.find(({target}) => fs.existsSync(target));
|
|
27
|
+
if (collision) throw new Error(`Cannot rename block; target already exists: ${path.relative(root, collision.target)}`);
|
|
28
|
+
|
|
29
|
+
for (const {source, target} of moves) {
|
|
30
|
+
fs.renameSync(source, target);
|
|
31
|
+
console.log(`[block:rename] renamed: ${path.relative(root, source)} → ${path.relative(root, target)}`);
|
|
32
|
+
}
|
|
33
|
+
order.blocks = order.blocks.map((key) => key === fromKey ? toKey : key);
|
|
34
|
+
writeOrder(orderPath, order);
|
|
35
|
+
console.log(`[block:rename] order.json updated (${fromKey} → ${toKey})`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const BLOCK_KEY_RE = /^[a-z0-9][a-z0-9-_]*$/i;
|
|
5
|
+
|
|
6
|
+
export function assertBlockKey(key, label = "block key") {
|
|
7
|
+
if (!BLOCK_KEY_RE.test(key || "")) {
|
|
8
|
+
throw new Error(`Invalid ${label}: ${JSON.stringify(key)}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readOrder(root) {
|
|
13
|
+
const orderPath = path.resolve(root, "src/order.json");
|
|
14
|
+
let order;
|
|
15
|
+
try {
|
|
16
|
+
order = JSON.parse(fs.readFileSync(orderPath, "utf8"));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`Cannot read src/order.json: ${error.message}`, {cause: error});
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(order.blocks)) throw new Error("src/order.json must contain a blocks array");
|
|
21
|
+
return {order, orderPath};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeOrder(orderPath, order) {
|
|
25
|
+
fs.writeFileSync(orderPath, `${JSON.stringify(order, null, 2)}\n`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {loadConfig} from "../config/load-config.mjs";
|
|
2
|
+
import {resolvePreset} from "../config/resolve-preset.mjs";
|
|
3
|
+
import {validateProject} from "../config/validate-project.mjs";
|
|
4
|
+
|
|
5
|
+
const usage = `Usage: b2c-landing <command>
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
dev Start the Vite development server
|
|
9
|
+
build Build CMS-ready HTML files
|
|
10
|
+
check Validate project configuration
|
|
11
|
+
block:add <key> Create a block
|
|
12
|
+
block:rename <old> <new> Rename a block
|
|
13
|
+
update Show the manual update command`;
|
|
14
|
+
|
|
15
|
+
async function check() {
|
|
16
|
+
const config = await loadConfig();
|
|
17
|
+
const preset = resolvePreset(config.site.preset);
|
|
18
|
+
const project = validateProject(config);
|
|
19
|
+
console.log(`[check] project: ${config.project.name}`);
|
|
20
|
+
console.log(`[check] schemaVersion: ${config.schemaVersion}`);
|
|
21
|
+
console.log(`[check] site: ${preset.id} (${preset.domain})`);
|
|
22
|
+
if (preset.assetsBase) {
|
|
23
|
+
console.log(`[check] assets: ${preset.assetsBase}/landing-pages/${config.project.name}`);
|
|
24
|
+
} else {
|
|
25
|
+
console.log(`[check] assets: not configured for ${preset.id} (production build unavailable)`);
|
|
26
|
+
}
|
|
27
|
+
console.log(`[check] blocks: ${project.blocks.length}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runCli(args) {
|
|
31
|
+
const [command, ...rest] = args;
|
|
32
|
+
|
|
33
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
34
|
+
console.log(usage);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (command === "check") return check();
|
|
39
|
+
if (command === "dev") {
|
|
40
|
+
await loadConfig();
|
|
41
|
+
const {startDevServer} = await import("../vite/config.mjs");
|
|
42
|
+
return startDevServer();
|
|
43
|
+
}
|
|
44
|
+
if (command === "build") {
|
|
45
|
+
const config = await loadConfig();
|
|
46
|
+
const preset = resolvePreset(config.site.preset, {requireAssets: true});
|
|
47
|
+
const {buildCms} = await import("../lib/build-cms.mjs");
|
|
48
|
+
await buildCms({config});
|
|
49
|
+
process.env.B2C_ASSETS_BASE = preset.assetsBase;
|
|
50
|
+
process.env.B2C_ASSETS_PREFIX = `landing-pages/${config.project.name}`;
|
|
51
|
+
await import(`../lib/rewriteAssetsBuild.mjs?t=${Date.now()}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (command === "block:add" || command === "block:rename") {
|
|
55
|
+
const config = await loadConfig();
|
|
56
|
+
if (command === "block:add") {
|
|
57
|
+
if (rest.length !== 1) throw new Error("Usage: b2c-landing block:add <key>");
|
|
58
|
+
const {addBlock} = await import("../blocks/add.mjs");
|
|
59
|
+
return addBlock(rest[0], config);
|
|
60
|
+
}
|
|
61
|
+
if (rest.length !== 2) throw new Error("Usage: b2c-landing block:rename <old> <new>");
|
|
62
|
+
const {renameBlock} = await import("../blocks/rename.mjs");
|
|
63
|
+
return renameBlock(rest[0], rest[1]);
|
|
64
|
+
}
|
|
65
|
+
if (command === "update") {
|
|
66
|
+
const {printUpdateStatus} = await import("../update/check-update.mjs");
|
|
67
|
+
return printUpdateStatus();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error(`Unknown command ${JSON.stringify(command)}\n\n${usage}`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {pathToFileURL} from "node:url";
|
|
3
|
+
|
|
4
|
+
import {validateConfig} from "./validate-config.mjs";
|
|
5
|
+
|
|
6
|
+
export async function loadConfig(root = process.cwd()) {
|
|
7
|
+
const configPath = path.resolve(root, "landing.config.mjs");
|
|
8
|
+
let module;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
module = await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND" && error?.url?.startsWith(pathToFileURL(configPath).href)) {
|
|
14
|
+
throw new Error(`landing.config.mjs not found in ${root}`, {cause: error});
|
|
15
|
+
}
|
|
16
|
+
throw new Error(`Cannot load landing.config.mjs: ${error.message}`, {cause: error});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return validateConfig(module.default);
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import coral from "../presets/coral.mjs";
|
|
2
|
+
import sunmar from "../presets/sunmar.mjs";
|
|
3
|
+
|
|
4
|
+
const presets = {coral, sunmar};
|
|
5
|
+
|
|
6
|
+
export function resolvePreset(id, {requireAssets = false} = {}) {
|
|
7
|
+
const preset = presets[id];
|
|
8
|
+
if (!preset) throw new Error(`Unknown site preset: ${JSON.stringify(id)}`);
|
|
9
|
+
if (requireAssets && !preset.assetsBase) {
|
|
10
|
+
throw new Error(`Production CDN is not configured for site preset ${JSON.stringify(id)}`);
|
|
11
|
+
}
|
|
12
|
+
return preset;
|
|
13
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const PROJECT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
2
|
+
const SUPPORTED_SCHEMA_VERSIONS = new Set([1]);
|
|
3
|
+
const STACK_VALUES = {
|
|
4
|
+
script: new Set(["vanilla", "vue"]),
|
|
5
|
+
markup: new Set(["html", "pug"]),
|
|
6
|
+
styles: new Set(["css", "scss", "less"]),
|
|
7
|
+
};
|
|
8
|
+
const SITE_PRESETS = new Set(["coral", "sunmar"]);
|
|
9
|
+
|
|
10
|
+
export function validateConfig(config) {
|
|
11
|
+
if (!config || Array.isArray(config) || typeof config !== "object") {
|
|
12
|
+
throw new Error("landing.config.mjs must export an object by default");
|
|
13
|
+
}
|
|
14
|
+
if (!SUPPORTED_SCHEMA_VERSIONS.has(config.schemaVersion)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Unsupported schemaVersion ${JSON.stringify(config.schemaVersion)}. ` +
|
|
17
|
+
"This builder supports schemaVersion 1."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
if (!PROJECT_NAME_RE.test(config.project?.name || "")) {
|
|
21
|
+
throw new Error("project.name must match ^[a-z0-9][a-z0-9-]*$");
|
|
22
|
+
}
|
|
23
|
+
if (!SITE_PRESETS.has(config.site?.preset)) {
|
|
24
|
+
throw new Error(`Unknown site preset: ${JSON.stringify(config.site?.preset)}`);
|
|
25
|
+
}
|
|
26
|
+
for (const [key, allowed] of Object.entries(STACK_VALUES)) {
|
|
27
|
+
if (!allowed.has(config.stack?.[key])) {
|
|
28
|
+
throw new Error(`Unsupported stack.${key}: ${JSON.stringify(config.stack?.[key])}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const BLOCK_KEY_RE = /^[a-z0-9][a-z0-9-_]*$/i;
|
|
5
|
+
const DEFAULT_EXPORT_RE = /\bexport\s+default\s+(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>|[A-Za-z_$][\w$]*\s*;?)/;
|
|
6
|
+
|
|
7
|
+
function readJson(filePath, label) {
|
|
8
|
+
let source;
|
|
9
|
+
try {
|
|
10
|
+
source = fs.readFileSync(filePath, "utf8");
|
|
11
|
+
} catch (error) {
|
|
12
|
+
throw new Error(`${label} not found: ${filePath}`, {cause: error});
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(source);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new Error(`Invalid ${label}: ${error.message}`, {cause: error});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateProject(config, root = process.cwd()) {
|
|
22
|
+
const orderPath = path.resolve(root, "src/order.json");
|
|
23
|
+
const order = readJson(orderPath, "src/order.json");
|
|
24
|
+
if (!Array.isArray(order.blocks)) {
|
|
25
|
+
throw new Error("src/order.json must contain a blocks array");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const key of order.blocks) {
|
|
30
|
+
if (typeof key !== "string" || !BLOCK_KEY_RE.test(key)) {
|
|
31
|
+
throw new Error(`Invalid block key in src/order.json: ${JSON.stringify(key)}`);
|
|
32
|
+
}
|
|
33
|
+
if (seen.has(key)) throw new Error(`Duplicate block key in src/order.json: ${key}`);
|
|
34
|
+
seen.add(key);
|
|
35
|
+
|
|
36
|
+
const markupPath = path.resolve(root, "src/markup", `${key}.${config.stack.markup}`);
|
|
37
|
+
if (!fs.existsSync(markupPath)) {
|
|
38
|
+
throw new Error(`Missing configured markup for block ${key}: src/markup/${key}.${config.stack.markup}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const scriptPath = path.resolve(root, "src/scripts", `${key}.js`);
|
|
42
|
+
if (!fs.existsSync(scriptPath)) continue;
|
|
43
|
+
const source = fs.readFileSync(scriptPath, "utf8");
|
|
44
|
+
if (!DEFAULT_EXPORT_RE.test(source)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`src/scripts/${key}.js must export its initializer as default function`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {blocks: [...order.blocks]};
|
|
52
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** project root = process.cwd() */
|
|
5
|
+
export const ROOT = process.cwd();
|
|
6
|
+
|
|
7
|
+
/** resolve from ROOT */
|
|
8
|
+
export function r(...parts) {
|
|
9
|
+
return path.resolve(ROOT, ...parts);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readJsonSafe(filePath, fallback = null) {
|
|
13
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readBlocks(orderFilePath) {
|
|
22
|
+
const json = readJsonSafe(orderFilePath, null);
|
|
23
|
+
const blocks = json?.blocks;
|
|
24
|
+
return Array.isArray(blocks) ? blocks.filter(Boolean) : [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function existsFile(p) {
|
|
28
|
+
try {
|
|
29
|
+
return fs.existsSync(p) && fs.statSync(p).isFile();
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ensureDir(p) {
|
|
36
|
+
fs.mkdirSync(p, {recursive: true});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function cleanDir(p) {
|
|
40
|
+
if (!fs.existsSync(p)) return;
|
|
41
|
+
for (const name of fs.readdirSync(p)) {
|
|
42
|
+
fs.rmSync(path.join(p, name), {recursive: true, force: true});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** write only if content differs */
|
|
47
|
+
export function writeIfChanged(filePath, next, logLabel = "") {
|
|
48
|
+
const prev = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
49
|
+
if (prev !== next) {
|
|
50
|
+
fs.writeFileSync(filePath, next);
|
|
51
|
+
if (logLabel) console.log(logLabel);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeHtml(html) {
|
|
58
|
+
return String(html || "").replace(/\r\n/g, "\n").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function absEq(a, b) {
|
|
62
|
+
return path.resolve(a) === path.resolve(b);
|
|
63
|
+
}
|