@coraltravelcenter/b2c-landing-builder 2.13.0 → 2.14.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 CHANGED
@@ -112,6 +112,9 @@ export default {
112
112
  markup: "pug",
113
113
  styles: "less",
114
114
  },
115
+ blocks: {
116
+ static: ["html-only"],
117
+ },
115
118
  };
116
119
  ```
117
120
 
@@ -134,6 +137,14 @@ builder распознать legacy-шаблон и предложить миг
134
137
  Builder загружает Vue-плагин из проекта, поэтому Vue-зависимости не
135
138
  устанавливаются вместе с глобальным builder.
136
139
 
140
+ `blocks.static` явно перечисляет блоки без JavaScript initializer. Команда
141
+ `b2c-landing-vite check --strict` требует initializer или такую декларацию.
142
+ HTML-разметка поддерживает вложенные `<include src="./partial.html"></include>`.
143
+
144
+ При первом запуске dev-сервер сохраняет выбранный свободный порт в
145
+ `.b2c/dev-port`; следующие запуски используют его строго. Новый порт можно
146
+ закрепить командой `B2C_PORT=5175 npm run dev`.
147
+
137
148
  Перед подготовкой deploy-артефактов builder рекурсивно проверяет `public/`.
138
149
  Изображения JPG, JPEG и PNG конвертируются в WebP с качеством 80, а ссылки в
139
150
  CMS-файлах автоматически переключаются на новые имена. Исходные файлы в
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@coraltravelcenter/b2c-landing-builder",
9
- "version": "2.13.0",
9
+ "version": "2.14.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@babel/parser": "7.28.6",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "CLI and build toolkit for B2C landing projects",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.mjs CHANGED
@@ -15,10 +15,10 @@ Commands:
15
15
  block:rename <old> <new> Rename a block
16
16
  update Show the manual update command`;
17
17
 
18
- async function check() {
18
+ async function check({strict = false} = {}) {
19
19
  const config = await loadConfig();
20
20
  const preset = resolvePreset(config.site.preset);
21
- const project = validateProject(config);
21
+ const project = validateProject(config, process.cwd(), {strict});
22
22
  console.log(`[check] project: ${config.project.name}`);
23
23
  console.log(`[check] schemaVersion: ${config.schemaVersion}`);
24
24
  console.log(`[check] site: ${preset.id} (${preset.domain})`);
@@ -28,6 +28,12 @@ async function check() {
28
28
  console.log(`[check] assets: not configured for ${preset.id} (production build unavailable)`);
29
29
  }
30
30
  console.log(`[check] blocks: ${project.blocks.length}`);
31
+ for (const block of project.blockDetails) {
32
+ console.log(
33
+ `[check] block ${block.key}: markup=yes styles=yes initializer=${block.initializer ? "yes" : "no"}` +
34
+ `${block.static ? " (static)" : ""}`
35
+ );
36
+ }
31
37
  }
32
38
 
33
39
  export async function runCli(args) {
@@ -38,7 +44,12 @@ export async function runCli(args) {
38
44
  return;
39
45
  }
40
46
 
41
- if (command === "check") return check();
47
+ if (command === "check") {
48
+ if (rest.some((argument) => argument !== "--strict")) {
49
+ throw new Error("Usage: b2c-landing-vite check [--strict]");
50
+ }
51
+ return check({strict: rest.includes("--strict")});
52
+ }
42
53
  if (command === "dev") {
43
54
  const config = await loadConfig();
44
55
  validateProject(config);
@@ -42,6 +42,12 @@ export function validateConfig(config) {
42
42
  if (!SITE_PRESETS.has(config.site?.preset)) {
43
43
  throw new Error(`Unknown site preset: ${JSON.stringify(config.site?.preset)}`);
44
44
  }
45
+ if (config.blocks?.static !== undefined && !Array.isArray(config.blocks.static)) {
46
+ throw new Error("blocks.static must be an array of block keys");
47
+ }
48
+ if (config.blocks?.static?.some((key) => typeof key !== "string" || !key)) {
49
+ throw new Error("blocks.static must contain non-empty block keys");
50
+ }
45
51
  for (const [key, allowed] of Object.entries(STACK_VALUES)) {
46
52
  if (!allowed.has(config.stack?.[key])) {
47
53
  throw new Error(`Unsupported stack.${key}: ${JSON.stringify(config.stack?.[key])}`);
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import {createRequire} from "node:module";
3
3
  import path from "node:path";
4
4
 
5
+ import {processMarkup} from "../lib/processMarkup.mjs";
5
6
  import {resolveScriptStack} from "./script-stack.mjs";
6
7
  import {hasDefaultInitializer} from "./validate-script-contract.mjs";
7
8
 
@@ -11,6 +12,7 @@ const STACK_DEPENDENCIES = {
11
12
  markup: {pug: ["pug"]},
12
13
  styles: {less: ["less"], scss: ["sass"]},
13
14
  };
15
+ const SCRIPT_EXTENSIONS = ["js", "ts"];
14
16
 
15
17
  function validateLocalDependencies(config, root) {
16
18
  const projectRequire = createRequire(path.resolve(root, "package.json"));
@@ -46,7 +48,7 @@ function readJson(filePath, label) {
46
48
  }
47
49
  }
48
50
 
49
- export function validateProject(config, root = process.cwd()) {
51
+ export function validateProject(config, root = process.cwd(), {strict = false} = {}) {
50
52
  const scriptStack = resolveScriptStack(config.stack.script);
51
53
  const orderPath = path.resolve(root, "src/order.json");
52
54
  const order = readJson(orderPath, "src/order.json");
@@ -58,6 +60,8 @@ export function validateProject(config, root = process.cwd()) {
58
60
  }
59
61
 
60
62
  const seen = new Set();
63
+ const staticBlocks = new Set(config.blocks?.static || []);
64
+ const blockDetails = [];
61
65
  for (const key of order.blocks) {
62
66
  if (typeof key !== "string" || !BLOCK_KEY_RE.test(key)) {
63
67
  throw new Error(`Invalid block key in src/order.json: ${JSON.stringify(key)}`);
@@ -69,6 +73,9 @@ export function validateProject(config, root = process.cwd()) {
69
73
  if (!fs.existsSync(markupPath)) {
70
74
  throw new Error(`Missing configured markup for block ${key}: src/markup/${key}.${config.stack.markup}`);
71
75
  }
76
+ if (config.stack.markup === "html") {
77
+ processMarkup(fs.readFileSync(markupPath, "utf8"), {filePath: markupPath});
78
+ }
72
79
 
73
80
  const stylePath = path.resolve(root, "src/styles", `${key}.${config.stack.styles}`);
74
81
  if (!fs.existsSync(stylePath)) {
@@ -76,7 +83,31 @@ export function validateProject(config, root = process.cwd()) {
76
83
  }
77
84
 
78
85
  const scriptPath = path.resolve(root, "src/scripts", `${key}.${scriptStack.extension}`);
79
- if (!fs.existsSync(scriptPath)) continue;
86
+ const conflictingScriptPaths = SCRIPT_EXTENSIONS
87
+ .filter((extension) => extension !== scriptStack.extension)
88
+ .map((extension) => path.resolve(root, "src/scripts", `${key}.${extension}`))
89
+ .filter((candidate) => fs.existsSync(candidate));
90
+ if (conflictingScriptPaths.length) {
91
+ const conflictingEntries = conflictingScriptPaths
92
+ .map((candidate) => path.relative(root, candidate))
93
+ .join(", ");
94
+ throw new Error(
95
+ `Script entry for block ${key} conflicts with stack.script=${JSON.stringify(config.stack.script)}: ` +
96
+ `${conflictingEntries}. Change stack.script or rename/remove the conflicting entry.`
97
+ );
98
+ }
99
+ const hasInitializer = fs.existsSync(scriptPath);
100
+ if (hasInitializer && staticBlocks.has(key)) {
101
+ throw new Error(`Block ${key} is declared static but has initializer src/scripts/${key}.${scriptStack.extension}`);
102
+ }
103
+ if (!hasInitializer && strict && !staticBlocks.has(key)) {
104
+ throw new Error(
105
+ `Block ${key} has no initializer. Add src/scripts/${key}.${scriptStack.extension} ` +
106
+ `or declare it in blocks.static.`
107
+ );
108
+ }
109
+ blockDetails.push({key, markup: true, styles: true, initializer: hasInitializer, static: staticBlocks.has(key)});
110
+ if (!hasInitializer) continue;
80
111
  const source = fs.readFileSync(scriptPath, "utf8");
81
112
  if (!hasDefaultInitializer({source, filePath: scriptPath, extension: scriptStack.extension, root})) {
82
113
  throw new Error(
@@ -85,6 +116,10 @@ export function validateProject(config, root = process.cwd()) {
85
116
  }
86
117
  }
87
118
 
119
+ for (const key of staticBlocks) {
120
+ if (!seen.has(key)) throw new Error(`Unknown block in blocks.static: ${key}`);
121
+ }
122
+
88
123
  validateLocalDependencies(config, root);
89
- return {folder: order.folder, blocks: [...order.blocks]};
124
+ return {folder: order.folder, blocks: [...order.blocks], blockDetails};
90
125
  }
@@ -131,7 +131,7 @@ async function buildBlock(key, stack) {
131
131
  const markup = stack.markup === "pug"
132
132
  ? renderPugFile(markupPath)
133
133
  : normalizeHtml(fs.readFileSync(markupPath, "utf8"));
134
- const html = processMarkup(markup);
134
+ const html = processMarkup(markup, {filePath: markupPath});
135
135
 
136
136
  const configuredCssPath = path.join(STYLES_DIR, `${key}.${stack.styles}`);
137
137
  const cssPath = existsFile(configuredCssPath) ? configuredCssPath : null;
@@ -155,7 +155,10 @@ async function buildBlock(key, stack) {
155
155
 
156
156
  if (js) parts.push(`<script>\n${js}\n</script>`);
157
157
 
158
- return parts.join("\n\n") + "\n";
158
+ return {
159
+ content: parts.join("\n\n") + "\n",
160
+ composition: {markup: Boolean(html), styles: Boolean(mergedCss), initializer: Boolean(js)},
161
+ };
159
162
  }
160
163
 
161
164
  // ---------- run ----------
@@ -178,8 +181,13 @@ export async function buildCms({config}) {
178
181
  }
179
182
 
180
183
  const outFile = path.join(OUT_DIR, `${key}.html`);
181
- fs.writeFileSync(outFile, result);
184
+ fs.writeFileSync(outFile, result.content);
182
185
  console.log(`[CMS] wrote @CMS/${key}.html`);
186
+ console.log(
187
+ `[CMS] block ${key}: markup=${result.composition.markup ? "yes" : "no"} ` +
188
+ `styles=${result.composition.styles ? "yes" : "no"} ` +
189
+ `initializer=${result.composition.initializer ? "yes" : "no"}`
190
+ );
183
191
  }
184
192
 
185
193
  console.log("[CMS] done");
@@ -100,7 +100,10 @@ export function landingBlocksPlugin({root = process.cwd(), stack} = {}) {
100
100
  const source = stack.markup === "pug"
101
101
  ? renderPugFile(markupPath)
102
102
  : normalizeHtml(fs.readFileSync(markupPath, "utf8"));
103
- const html = processMarkup(source);
103
+ const html = processMarkup(source, {
104
+ filePath: markupPath,
105
+ onInclude: (includePath) => this.addWatchFile(includePath),
106
+ });
104
107
  entries.push(`{ key: ${JSON.stringify(key)}, html: ${JSON.stringify(html)}, init: ${init} }`);
105
108
  });
106
109
 
@@ -1,3 +1,6 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
1
4
  import Typograf from "typograf";
2
5
 
3
6
  const typograf = new Typograf({
@@ -5,10 +8,60 @@ const typograf = new Typograf({
5
8
  });
6
9
 
7
10
  const PROTECTED_TAGS = /<(script|style|pre|code|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi;
11
+ const HTML_INCLUDE = /<include\s+[^>]*\bsrc\s*=\s*(["'])([^"']+)\1[^>]*>(?:\s*<\/include\s*>)?/gi;
12
+
13
+ function protectContents(source) {
14
+ const contents = [];
15
+ const protectedSource = source.replace(PROTECTED_TAGS, (fragment) => {
16
+ const index = contents.push(fragment) - 1;
17
+ return `<b2c-markup-protected data-index="${index}"></b2c-markup-protected>`;
18
+ });
19
+ return {
20
+ source: protectedSource,
21
+ restore(value) {
22
+ return value.replace(
23
+ /<b2c-markup-protected data-index="(\d+)"><\/b2c-markup-protected>/g,
24
+ (_, index) => contents[Number(index)]
25
+ );
26
+ },
27
+ };
28
+ }
29
+
30
+ function expandHtmlIncludes(source, {filePath, onInclude, chain = []}) {
31
+ const absolutePath = path.resolve(filePath);
32
+ if (chain.includes(absolutePath)) {
33
+ const cycle = [...chain, absolutePath].map((item) => path.relative(process.cwd(), item)).join(" -> ");
34
+ throw new Error(`Recursive HTML include detected: ${cycle}`);
35
+ }
36
+
37
+ const protectedMarkup = protectContents(source);
38
+ const expanded = protectedMarkup.source.replace(HTML_INCLUDE, (_match, _quote, includeSource) => {
39
+ const includePath = path.resolve(path.dirname(absolutePath), includeSource);
40
+ let includeMarkup;
41
+ try {
42
+ includeMarkup = fs.readFileSync(includePath, "utf8");
43
+ } catch (error) {
44
+ if (error.code === "ENOENT") {
45
+ throw new Error(`HTML include not found: ${includePath} (included from ${absolutePath})`, {cause: error});
46
+ }
47
+ throw error;
48
+ }
49
+ onInclude?.(includePath);
50
+ return expandHtmlIncludes(includeMarkup, {
51
+ filePath: includePath,
52
+ onInclude,
53
+ chain: [...chain, absolutePath],
54
+ });
55
+ });
56
+ return protectedMarkup.restore(expanded);
57
+ }
8
58
 
9
- export function processMarkup(html) {
59
+ export function processMarkup(html, {filePath, onInclude} = {}) {
10
60
  const protectedContents = [];
11
- const source = String(html || "").replace(PROTECTED_TAGS, (fragment) => {
61
+ const markup = filePath
62
+ ? expandHtmlIncludes(String(html || ""), {filePath, onInclude})
63
+ : String(html || "");
64
+ const source = markup.replace(PROTECTED_TAGS, (fragment) => {
12
65
  const index = protectedContents.push(fragment) - 1;
13
66
  return `<b2c-typograf-protected data-index="${index}"></b2c-typograf-protected>`;
14
67
  });
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import {fileURLToPath} from "node:url";
3
4
 
4
5
  import {createServer} from "vite";
@@ -19,10 +20,27 @@ export function toViteFsUrl(filePath) {
19
20
  const clientEntryUrl = toViteFsUrl(clientEntry);
20
21
  let monkeyPlugin;
21
22
 
22
- export function resolveDevServerPort(env = process.env) {
23
+ const DEFAULT_DEV_PORT = 5173;
24
+
25
+ function devPortPath(root) {
26
+ return path.join(root, ".b2c", "dev-port");
27
+ }
28
+
29
+ export function resolveDevServerPort(env = process.env, root = process.cwd()) {
23
30
  const explicitPort = env.B2C_PORT;
24
31
  if (explicitPort === undefined || explicitPort === "") {
25
- return {port: 5173, strictPort: false};
32
+ let savedPort;
33
+ try {
34
+ savedPort = fs.readFileSync(devPortPath(root), "utf8").trim();
35
+ } catch (error) {
36
+ if (error.code !== "ENOENT") throw error;
37
+ }
38
+ if (!savedPort) return {port: DEFAULT_DEV_PORT, strictPort: false};
39
+ const port = Number(savedPort);
40
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
41
+ throw new Error(`Invalid saved dev port in ${devPortPath(root)}. Remove the file or set B2C_PORT.`);
42
+ }
43
+ return {port, strictPort: true};
26
44
  }
27
45
  const port = Number(explicitPort);
28
46
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
@@ -31,6 +49,12 @@ export function resolveDevServerPort(env = process.env) {
31
49
  return {port, strictPort: true};
32
50
  }
33
51
 
52
+ export function saveDevServerPort(root, port) {
53
+ const filePath = devPortPath(root);
54
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
55
+ fs.writeFileSync(filePath, `${port}\n`);
56
+ }
57
+
34
58
  export function createTargetUrl(preset) {
35
59
  return `https://${preset.domain}/monkey/`;
36
60
  }
@@ -71,7 +95,7 @@ export async function createViteConfig(root = process.cwd(), options = {}) {
71
95
  const preset = options.preset || resolvePreset(config.site.preset);
72
96
  const monkey = await loadMonkeyPlugin();
73
97
  const shouldOpen = process.env.B2C_NO_OPEN !== "1";
74
- const devPort = resolveDevServerPort();
98
+ const devPort = resolveDevServerPort(process.env, root);
75
99
  const vue = resolveScriptStack(config.stack.script).vue
76
100
  ? await loadProjectVuePlugin(root)
77
101
  : null;
@@ -111,8 +135,22 @@ export async function startDevServer(root = process.cwd()) {
111
135
  const config = await loadConfig(root);
112
136
  const preset = resolvePreset(config.site.preset);
113
137
  const shouldOpen = process.env.B2C_NO_OPEN !== "1";
114
- const server = await createServer(await createViteConfig(root, {config, preset}));
115
- await server.listen();
138
+ const viteConfig = await createViteConfig(root, {config, preset});
139
+ const server = await createServer(viteConfig);
140
+ try {
141
+ await server.listen();
142
+ } catch (error) {
143
+ if (viteConfig.server.strictPort && /port .*already in use/i.test(error.message)) {
144
+ throw new Error(
145
+ `${error.message}. Stop the process using this project's port or choose and save another one with ` +
146
+ `B2C_PORT=<free-port> npm run dev.`,
147
+ {cause: error}
148
+ );
149
+ }
150
+ throw error;
151
+ }
152
+ const address = server.httpServer?.address();
153
+ if (address && typeof address === "object") saveDevServerPort(root, address.port);
116
154
  server.printUrls();
117
155
  await presentTargetPage({preset, shouldOpen});
118
156
  return server;