create-berna-stencil 2.2.1 → 2.4.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
@@ -12,7 +12,7 @@ Building a website from scratch involves a lot of moving parts: templating engin
12
12
  - 📁 **Scalable structure** — a clean, opinionated project layout that grows with your needs
13
13
  - 🌍 **Open source** — free to use, free to modify, free to share
14
14
 
15
- ![Version](https://img.shields.io/badge/version-2.2.1-blue)
15
+ ![Version](https://img.shields.io/badge/version-2.4.0-blue)
16
16
  ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
17
17
  ![Eleventy](https://img.shields.io/badge/11ty-v3.1.2-black)
18
18
 
@@ -2,7 +2,16 @@ const readline = require('readline');
2
2
  const { addPage, removePage, renamePage } = require('./modules/updatePage');
3
3
  const { updateOutputPath, getCurrentOutputPath } = require('./modules/updateOutputPath');
4
4
 
5
- // --- Setup ---
5
+ const c = {
6
+ reset: "\x1b[0m",
7
+ bold: "\x1b[1m",
8
+ dim: "\x1b[2m",
9
+ red: "\x1b[31m",
10
+ green: "\x1b[32m",
11
+ yellow: "\x1b[33m",
12
+ magenta: "\x1b[35m",
13
+ cyan: "\x1b[36m"
14
+ };
6
15
 
7
16
  const readerInterface = readline.createInterface({
8
17
  input: process.stdin,
@@ -10,67 +19,73 @@ const readerInterface = readline.createInterface({
10
19
  terminal: true
11
20
  });
12
21
 
13
- const PROTECTED_PAGES = ['homepage', '404'];
14
-
15
- // --- Utility ---
22
+ const PROTECTED_PAGES = ['homepage', '404'];
23
+ const MAX_NAME_LENGTH = 50;
16
24
 
17
- // Converts any string to kebab-case
18
25
  function toKebabCase(str) {
19
26
  return str.trim().toLowerCase()
27
+ .replace(/[^a-z0-9\s_-]/g, '')
20
28
  .replace(/[\s_]+/g, '-')
21
- .replace(/-+/g, '-');
29
+ .replace(/-+/g, '-')
30
+ .replace(/^-+|-+$/g, '');
22
31
  }
23
32
 
24
- // Returns an error message if the name is invalid, null otherwise
25
33
  function validatePageName(name) {
26
- if (!name) return 'Invalid name.';
27
- if (/^\d/.test(name)) return 'Page name cannot start with a number.';
28
- if (PROTECTED_PAGES.includes(name)) return `"${name}" is a protected page name.`;
34
+ if (!name) return 'Invalid name.';
35
+ if (name.length > MAX_NAME_LENGTH) return `Name must be ${MAX_NAME_LENGTH} characters or fewer.`;
36
+ if (!/^[a-z0-9-]+$/.test(name)) return 'Page name can only contain lowercase letters, numbers, and hyphens.';
37
+ if (/^\d/.test(name)) return 'Page name cannot start with a number.';
38
+ if (PROTECTED_PAGES.includes(name)) return `"${name}" is a protected page name.`;
39
+ return null;
40
+ }
41
+
42
+ function validateOutputPath(input) {
43
+ if (!input.trim()) return 'Invalid path.';
44
+ if (input.includes('..')) return 'Path cannot contain "..".';
45
+ if (/[<>|?*"']/.test(input)) return 'Path contains invalid characters.';
29
46
  return null;
30
47
  }
31
48
 
32
- // Wraps readerInterface.question in a Promise for use with async/await
49
+ function sanitizeInput(str) {
50
+ return str.replace(/[\x00-\x1F\x7F]/g, '').trim();
51
+ }
52
+
33
53
  function ask(prompt) {
34
54
  return new Promise(resolve =>
35
- readerInterface.question(prompt, answer => resolve(answer))
55
+ readerInterface.question(prompt, answer => resolve(sanitizeInput(answer)))
36
56
  );
37
57
  }
38
58
 
39
- // Asks for a page name, converts it to kebab-case, validates it,
40
- // logs the error and returns null if invalid
41
59
  async function askPageName(prompt) {
42
60
  const raw = await ask(prompt);
43
61
  const name = toKebabCase(raw);
44
62
  const error = validatePageName(name);
45
63
  if (error) {
46
- console.log(`(!) ${error}`);
64
+ console.log(`\n${c.red}✖ ${error}${c.reset}`);
47
65
  return null;
48
66
  }
49
67
  return name;
50
68
  }
51
69
 
52
- // --- Handlers ---
53
-
54
70
  async function handleCreateRequest() {
55
- const name = await askPageName('\n> Enter the name of the new page: ');
71
+ const name = await askPageName(`\n${c.green}❯${c.reset} Enter the name of the new page: `);
56
72
  if (name) addPage(name, null);
57
73
  }
58
74
 
59
75
  async function handleRemoveRequest() {
60
- const name = await askPageName('\n> Enter the name of the page to remove: ');
76
+ const name = await askPageName(`\n${c.red}❯${c.reset} Enter the name of the page to remove: `);
61
77
  if (name) removePage(name);
62
78
  }
63
79
 
64
80
  async function handleRenameRequest() {
65
- const oldName = await askPageName('\n> Enter the name of the page to rename: ');
81
+ const oldName = await askPageName(`\n${c.yellow}❯${c.reset} Enter the name of the page to rename: `);
66
82
  if (!oldName) return;
67
83
 
68
- const newName = await askPageName('> Enter the new name: ');
84
+ const newName = await askPageName(`${c.yellow}❯${c.reset} Enter the new name: `);
69
85
  if (!newName) return;
70
86
 
71
- // Extra check: old and new name must differ
72
87
  if (oldName === newName) {
73
- console.log('(!) Old and new name are the same.');
88
+ console.log(`\n${c.yellow}⚠ Old and new name are the same.${c.reset}`);
74
89
  return;
75
90
  }
76
91
 
@@ -79,19 +94,17 @@ async function handleRenameRequest() {
79
94
 
80
95
  async function handleOutputPathRequest() {
81
96
  const current = getCurrentOutputPath();
82
- const label = current ? ` Current path: "${current}"\n` : '';
83
- const input = await ask(`${label} Enter the new output path: `);
97
+ const label = current ? `\n${c.dim}Current path: "${current}"${c.reset}\n` : '\n';
98
+ const input = await ask(`${label}${c.magenta}❯${c.reset} Enter the new output path: `);
84
99
 
85
- if (!input.trim()) {
86
- console.log('(!) Invalid path.');
100
+ const error = validateOutputPath(input);
101
+ if (error) {
102
+ console.log(`\n${c.red}✖ ${error}${c.reset}`);
87
103
  } else {
88
104
  updateOutputPath(input);
89
105
  }
90
106
  }
91
107
 
92
- // --- Menu ---
93
-
94
- // Maps each menu choice to its handler function
95
108
  const MENU_ACTIONS = {
96
109
  '1': handleCreateRequest,
97
110
  '2': handleRemoveRequest,
@@ -99,19 +112,17 @@ const MENU_ACTIONS = {
99
112
  '4': handleOutputPathRequest,
100
113
  };
101
114
 
102
- // Displays the menu, waits for input, executes the chosen action,
103
- // then calls itself again to keep the CLI alive (async recursion, no stack buildup)
104
115
  async function displayMainMenu() {
105
- console.log('\n========================');
106
- console.log(' Berna-Stencil CLI ');
107
- console.log('========================\n');
108
- console.log('1. Create page');
109
- console.log('2. Remove page');
110
- console.log('3. Rename page');
111
- console.log('4. Configure output path');
112
- console.log('\nCTRL/CMD + C to exit');
116
+ console.log(`\n${c.cyan}${c.bold}╭────────────────────────╮`);
117
+ console.log(`│ Berna-Stencil CLI │`);
118
+ console.log(`╰────────────────────────╯${c.reset}\n`);
119
+ console.log(` ${c.green}1.${c.reset} Create page`);
120
+ console.log(` ${c.red}2.${c.reset} Remove page`);
121
+ console.log(` ${c.yellow}3.${c.reset} Rename page`);
122
+ console.log(` ${c.magenta}4.${c.reset} Configure output path`);
123
+ console.log(`\n ${c.dim}CTRL/CMD + C to exit${c.reset}\n`);
113
124
 
114
- const choice = (await ask('\nChoose an option: ')).trim();
125
+ const choice = (await ask(`${c.cyan}❯${c.reset} Choose an option: `)).trim();
115
126
 
116
127
  if (choice === '0') {
117
128
  readerInterface.close();
@@ -120,13 +131,15 @@ async function displayMainMenu() {
120
131
 
121
132
  const action = MENU_ACTIONS[choice];
122
133
  if (action) {
123
- await action();
134
+ try {
135
+ await action();
136
+ } catch (err) {
137
+ console.log(`\n${c.red}✖ Unexpected error: ${err.message}${c.reset}`);
138
+ }
124
139
  } else {
125
- console.log('(!) Invalid option.');
140
+ console.log(`\n${c.red}✖ Invalid option.${c.reset}`);
126
141
  }
127
142
 
128
- // Recurse to redisplay the menu after each action.
129
- // Safe because each iteration fully resolves before the next one starts.
130
143
  displayMainMenu();
131
144
  }
132
145
 
@@ -1,27 +1,8 @@
1
1
  const fileSystem = require('fs');
2
+ const { toCamelCase, toNiceTitle } = require('./utils');
2
3
 
3
4
  const SITE_DATA_PATH = 'src/frontend/data/site.json';
4
5
 
5
- // --- Utility ---
6
-
7
- // Converts a kebab-case string to camelCase
8
- // slice(1) removes the delimiter character reliably, avoiding the regex flag bug
9
- // of a double replace('-', '') without the /g flag
10
- function toCamelCase(str) {
11
- return str.toLowerCase().replace(/[-_][a-z0-9]/g, (group) =>
12
- group.slice(1).toUpperCase()
13
- );
14
- }
15
-
16
- // Converts a kebab-case page name to a human-readable title
17
- // e.g. "about-us" → "About Us"
18
- function toNiceTitle(pageName) {
19
- return pageName
20
- .split('-')
21
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
22
- .join(' ');
23
- }
24
-
25
6
  // Returns the parsed site.json content, or null if the file doesn't exist
26
7
  function readSiteData() {
27
8
  if (!fileSystem.existsSync(SITE_DATA_PATH)) return null;
@@ -1,5 +1,6 @@
1
- const fs = require('fs');
1
+ const fs = require('fs');
2
2
  const path = require('path');
3
+ const { isTypeScriptProject } = require('./utils');
3
4
 
4
5
  const ELEVENTY_CONFIG = path.resolve(__dirname, '../../.eleventy.js');
5
6
  const PACKAGE_JSON = path.resolve(__dirname, '../../package.json');
@@ -14,10 +15,6 @@ function parseOutputDir(content) {
14
15
  return match ? match[1] : null;
15
16
  }
16
17
 
17
- function isTypeScriptProject() {
18
- return fs.existsSync(TSCONFIG);
19
- }
20
-
21
18
  // --- Updaters ---
22
19
 
23
20
  function updateEleventyConfig(newPath) {
@@ -4,18 +4,10 @@ const path = require('path');
4
4
  const { addSiteData, removeSiteData, renameSiteData } = require('./updateData');
5
5
  const { addLayout, removeLayout, renameLayout } = require('./updateIncludes');
6
6
  const { getCurrentOutputPath } = require('./updateOutputPath');
7
- const { toCamelCase } = require('./utils');
7
+ const { toCamelCase, isTypeScriptProject } = require('./utils');
8
8
 
9
9
  const TEMPLATES_DIR = path.join(__dirname, '..', 'res', 'templates');
10
- const TSCONFIG = path.resolve(__dirname, '../../tsconfig.json');
11
10
 
12
- // --- Helpers ---
13
-
14
- function isTypeScriptProject() {
15
- return fileSystem.existsSync(TSCONFIG);
16
- }
17
-
18
- // Returns the three file targets (scss, js/ts, njk) for a given page name
19
11
  function getPageTargets(pageName) {
20
12
  const camelName = toCamelCase(pageName);
21
13
  const usesTs = isTypeScriptProject();
@@ -39,8 +31,6 @@ function getPageTargets(pageName) {
39
31
  ];
40
32
  }
41
33
 
42
- // --- Public API ---
43
-
44
34
  function addPage(pageName) {
45
35
  const camelName = toCamelCase(pageName);
46
36
 
@@ -86,8 +76,16 @@ function renamePage(oldName, newName) {
86
76
  console.log(`[skip] not found: ${src}`);
87
77
  return;
88
78
  }
79
+
89
80
  fileSystem.renameSync(src, dest);
90
81
  console.log(`[renamed] ${src} → ${dest}`);
82
+
83
+ if (dest.endsWith('.njk')) {
84
+ const content = fileSystem.readFileSync(dest, 'utf8')
85
+ .replace(/^title:.*$/m, `title: "${newCamel}"`)
86
+ .replace(/^permalink:.*$/m, `permalink: "/${newName}/"`);
87
+ fileSystem.writeFileSync(dest, content);
88
+ }
91
89
  });
92
90
 
93
91
  renameLayout(oldName, newName);
@@ -95,10 +93,10 @@ function renamePage(oldName, newName) {
95
93
  }
96
94
 
97
95
  function removePage(pageName) {
98
- const camelName = toCamelCase(pageName);
99
- const usesTs = isTypeScriptProject();
100
- const ext = usesTs ? 'ts' : 'js';
101
- const jsFolder = usesTs ? 'src/frontend/ts/pages' : 'src/frontend/js/pages';
96
+ const camelName = toCamelCase(pageName);
97
+ const usesTs = isTypeScriptProject();
98
+ const ext = usesTs ? 'ts' : 'js';
99
+ const jsFolder = usesTs ? 'src/frontend/ts/pages' : 'src/frontend/js/pages';
102
100
  const OUTPUT_DIR = getCurrentOutputPath() || 'out';
103
101
 
104
102
  const filesToDelete = [
@@ -1,3 +1,8 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const TSCONFIG = path.resolve(__dirname, '../../tsconfig.json');
5
+
1
6
  // Shared utility functions used across modules
2
7
 
3
8
  // Converts a kebab-case or snake_case string to camelCase.
@@ -9,4 +14,17 @@ function toCamelCase(str) {
9
14
  );
10
15
  }
11
16
 
12
- module.exports = { toCamelCase };
17
+ // Converts a kebab-case page name to a human-readable title
18
+ // e.g. "about-us" → "About Us"
19
+ function toNiceTitle(pageName) {
20
+ return pageName
21
+ .split('-')
22
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
23
+ .join(' ');
24
+ }
25
+
26
+ function isTypeScriptProject() {
27
+ return fs.existsSync(TSCONFIG);
28
+ }
29
+
30
+ module.exports = { toCamelCase, toNiceTitle, isTypeScriptProject };
package/bin/create.js CHANGED
@@ -120,7 +120,7 @@ src/backend/config.php
120
120
 
121
121
  const PROJECT_PACKAGE = {
122
122
  name: path.basename(targetDir),
123
- version: '2.2.1',
123
+ version: '2.4.0',
124
124
  private: true,
125
125
  outputDir: 'out',
126
126
  "scripts": {
package/docs/Backend.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Backend
2
2
 
3
+ > **PHP requirement** — the backend runs only on servers with PHP support:
4
+ > - **Apache** (shared hosting, VPS) ✅
5
+ > - **Nginx** + PHP-FPM (VPS) ✅
6
+ > - **IIS** + FastCGI (Windows Server) ✅
7
+ > - **Static hosting** (Netlify, Vercel, GitHub Pages, Cloudflare Pages) ❌
8
+
3
9
  The backend is a PHP REST API located in `src/backend/`, copied to the output directory automatically at build time.
4
10
 
5
11
  ## Structure
@@ -30,9 +36,9 @@ return [
30
36
  'API_KEY' => 'default-key',
31
37
 
32
38
  // If you want restrict access to protected endpoints to specific clients, you can define custom keys for each endpoint
33
- // For subfolder endpoints, use the relative path ('subfolder/endpoint')
34
39
  'ENDPOINT_KEYS' => [
35
- 'example-protected' => 'custom-key',
40
+ // For subfolder endpoints, use the relative path ('subfolder/endpoint')
41
+ // 'subfolder/endpoint' => 'example-key',
36
42
  ],
37
43
 
38
44
  'DB_HOST' => '127.0.0.1',
package/docs/Deploy.md ADDED
@@ -0,0 +1,55 @@
1
+ # Server Configuration
2
+
3
+ Berna-Stencil includes ready-made server configuration for Apache and IIS. For Nginx, a reference config is provided in the project root.
4
+
5
+ ## Apache
6
+
7
+ `.htaccess` files at `src/frontend/` and `src/backend/` are automatically copied to the build output by Eleventy. No additional setup required.
8
+
9
+ Covers:
10
+ - Directory listing disabled
11
+ - 403 / 404 → `/404.html`
12
+ - Sensitive files blocked (`web.config`, dotfiles, etc.)
13
+ - `/api/*` → `backend/_core/index.php`
14
+ - HTTPS redirect
15
+
16
+ ## IIS
17
+
18
+ `web.config` files at `src/frontend/` and `src/backend/` are automatically copied to the build output by Eleventy. No additional setup required.
19
+
20
+ Covers the same rules as the Apache configuration above.
21
+
22
+ ## Nginx
23
+
24
+ Nginx does not support per-directory configuration files. The `nginx.conf` in the project root is a reference config that must be manually placed on the server.
25
+
26
+ ### Setup
27
+
28
+ 1. Copy `nginx.conf` to the server:
29
+ ```bash
30
+ scp nginx.conf user@server:/etc/nginx/sites-available/your-site
31
+ ```
32
+
33
+ 2. Edit `server_name` and `root` to match your environment:
34
+ ```nginx
35
+ server_name example.com;
36
+ root /var/www/html/out;
37
+ ```
38
+
39
+ 3. Add your SSL certificate paths.
40
+
41
+ 4. Enable and reload:
42
+ ```bash
43
+ ln -s /etc/nginx/sites-available/your-site /etc/nginx/sites-enabled/
44
+ nginx -t && systemctl reload nginx
45
+ ```
46
+
47
+ ### PHP-FPM socket
48
+
49
+ The default socket path in `nginx.conf` targets RHEL / Fedora systems. Adjust for your distro:
50
+
51
+ | Distro | Path |
52
+ |---|---|
53
+ | RHEL / Fedora | `unix:/run/php-fpm/php-fpm.sock` |
54
+ | Debian / Ubuntu | `unix:/run/php/php-fpm.sock` |
55
+ | TCP fallback | `127.0.0.1:9000` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-berna-stencil",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "Eleventy boilerplate with per-page SCSS/JS pipeline, esbuild bundling, multi-framework CSS support and a built-in page management CLI",
5
5
  "keywords": [],
6
6
  "author": "Michele Garofalo",
@@ -48,13 +48,13 @@
48
48
  "esbuild": "^0.27.3",
49
49
  "sass": "^1.77.0"
50
50
  },
51
- "scripts": {
52
- "build:css": "sass src/frontend/scss:out/css --no-source-map --style=compressed --quiet --load-path=node_modules",
53
- "build:js": "node _tools/buildJs.js",
51
+ "scripts": {
52
+ "build:css": "sass src/frontend/scss:out/css --no-source-map --style=compressed --quiet",
53
+ "build:js": "esbuild \"src/frontend/ts/pages/*.ts\" --bundle --outdir=out/js/pages --minify",
54
54
  "build:11ty": "eleventy",
55
55
  "build": "npm run clean && npm run build:css && npm run build:js && npm run build:11ty",
56
- "serve:css": "sass --watch src/frontend/scss:out/css --no-source-map --quiet --load-path=node_modules",
57
- "serve:js": "node _tools/buildJs.js --watch",
56
+ "serve:css": "sass --watch src/frontend/scss:out/css --no-source-map --quiet",
57
+ "serve:js": "esbuild \"src/frontend/ts/pages/*.ts\" --bundle --outdir=out/js/pages --watch",
58
58
  "serve:11ty": "eleventy --serve --quiet",
59
59
  "clean": "node _tools/cleanOutput.js",
60
60
  "serve": "npm run clean && concurrently \"npm run serve:11ty\" \"npm run serve:css\" \"npm run serve:js\"",
@@ -6,9 +6,9 @@ return [
6
6
  'API_KEY' => 'DEFAULT_KEY',
7
7
 
8
8
  // If you want restrict access to protected endpoints to specific clients, you can define custom keys for each endpoint
9
- // For subfolder endpoints, use the relative path ('subfolder/endpoint')
10
9
  'ENDPOINT_KEYS' => [
11
- 'subfolder/example-protected' => 'example-key',
10
+ // For subfolder endpoints, use the relative path ('subfolder/endpoint')
11
+ // 'subfolder/endpoint' => 'example-key',
12
12
  ],
13
13
 
14
14
  // Database configuration
@@ -35,6 +35,10 @@
35
35
  <input type="radio" name="guide-filter" value="backend" />
36
36
  <span class="filter-pill">Backend</span>
37
37
  </label>
38
+ <label>
39
+ <input type="radio" name="guide-filter" value="deploy" />
40
+ <span class="filter-pill">Deploy</span>
41
+ </label>
38
42
  </div>
39
43
 
40
44
  <div class="tabs-container">
@@ -90,6 +94,9 @@
90
94
  <div id="content-backend" class="tab-content markdown-body">
91
95
  {% mdFile "docs/backend.md" %}
92
96
  </div>
97
+ <div id="content-deploy" class="tab-content markdown-body">
98
+ {% mdFile "docs/deploy.md" %}
99
+ </div>
93
100
 
94
101
  </div>
95
102
  </div>
@@ -1,12 +1,12 @@
1
- // ===========================
1
+ //===========================
2
2
  // TYPESCRIPT MODULES IMPORTS
3
- // ===========================
3
+ //===========================
4
4
 
5
5
  // import { initExampleModule } from '../modules/exampleModule';
6
6
 
7
- // ==========================
7
+ //==========================
8
8
  // PAGE CUSTOM TYPESCRIPT
9
- // ==========================
9
+ //==========================
10
10
 
11
11
  document.addEventListener("DOMContentLoaded", (): void => {
12
12
  // initExampleModule();
@@ -1,12 +1,12 @@
1
- // ===========================
1
+ //===========================
2
2
  // TYPESCRIPT MODULES IMPORTS
3
- // ===========================
3
+ //===========================
4
4
 
5
5
  // import { initExampleModule } from '../modules/exampleModule';
6
6
 
7
- // ==========================
7
+ //==========================
8
8
  // PAGE CUSTOM TYPESCRIPT
9
- // ==========================
9
+ //==========================
10
10
 
11
11
  document.addEventListener("DOMContentLoaded", (): void => {
12
12
  // initExampleModule();
package/tsconfig.json CHANGED
@@ -19,6 +19,7 @@
19
19
  "isolatedModules": true,
20
20
  "noUncheckedSideEffectImports": true,
21
21
  "moduleDetection": "force",
22
- "skipLibCheck": true
22
+ "skipLibCheck": true,
23
+ "experimentalDecorators": true
23
24
  }
24
25
  }