nibula 2.0.1 → 2.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.
@@ -17,7 +17,7 @@ your-project/
17
17
  └── frontend/
18
18
  ├── assets/ # Images, fonts, brand files
19
19
  │ └── brand/ # Favicons, logo etc
20
- ├── components/ # Reusable .njk fragments
20
+ ├── components/ # Reusable .njk fragments and .md content
21
21
  │ └── global/ # Header and footer, included in every page
22
22
  ├── data/ # site.json and pages.json
23
23
  ├── hosting/ # .htaccess and web.config
@@ -35,6 +35,7 @@ your-project/
35
35
  | Add a page | `nib cli` → creates the files in `routes/`, `scss/pages/`, `js/pages/` |
36
36
  | Write the content of a page | `routes/` |
37
37
  | Build a reusable block of markup | `components/` |
38
+ | Write long prose in Markdown | `components/` — see docs/Components.md |
38
39
  | Change the header or footer | `components/global/` |
39
40
  | Style one page | `scss/pages/` |
40
41
  | Style every page | `scss/_global.scss` |
@@ -57,7 +58,7 @@ your-project/
57
58
 
58
59
  Subfolders are allowed wherever content is yours to organize:
59
60
 
60
- - `components/` — nest as deep as you like; includes take the path relative to `components/`
61
+ - `components/` — nest as deep as you like; includes take the path relative to `components/`, and so does the `markdownPath` filter
61
62
  - `scss/modules/` and `js/modules/` — same idea, remember to update the relative import paths
62
63
  - `layouts/` — extra layouts alongside `base.njk`; routes pick one by filename
63
64
  - `backend/api/public/` and `protected/` — subfolders become part of the URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nibula",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A beginner-friendly static site generator built on Eleventy that stays close to vanilla HTML, CSS and JS — with a page-management CLI, an optional PHP backend, and SEO files generated for you.",
5
5
  "keywords": [
6
6
  "11ty",
@@ -63,6 +63,8 @@
63
63
  "esbuild": "^0.27.3",
64
64
  "foundation-sites": "^6.9.0",
65
65
  "glob": "^13.0.6",
66
+ "markdown-it-anchor": "^9.2.1",
67
+ "markdown-it-attrs": "^5.0.1",
66
68
  "sass": "^1.77.0",
67
69
  "uikit": "^3.25.13"
68
70
  }
@@ -1,7 +1,7 @@
1
1
  <header>
2
2
  <nav>
3
3
  <a class="logo" href="/">
4
- <img src="../../assets/brand/logo.svg" alt="{{ site.title }}" height="40">
4
+ <img src="{{ site.logo }}" alt="{{ site.title }}" height="40">
5
5
  </a>
6
6
  </nav>
7
7
  </header>
@@ -1,11 +1,7 @@
1
1
  // import { initExampleModule } from "./modules/exampleModule.js";
2
2
 
3
- function initGlobal() {
3
+ document.addEventListener('DOMContentLoaded', () => {
4
4
  // initExampleModule();
5
- }
5
+ });
6
6
 
7
- // Global logic here
8
-
9
- // Do not touch
10
- // This instruction starts global.js itself
11
- document.addEventListener('DOMContentLoaded', initGlobal);
7
+ // Global logic here
@@ -1,4 +1,4 @@
1
- <!-- This is the base of every page that you will create -->
1
+ {# This is the base of every page that you will create #}
2
2
 
3
3
  <!DOCTYPE html>
4
4
  <html lang="{{ site.lang or 'en' }}" data-theme="{{ site.theme or 'light' }}" data-bs-theme="{{ site.theme or 'light' }}">
@@ -4,7 +4,8 @@ permalink: "404.html"
4
4
  layout: base.njk
5
5
  ---
6
6
 
7
- <!-- !IMPORTANT -->
8
- <!-- To keep order, the best practice is to create a new component.njk file in components/ and include it here-->
7
+ {# To keep order, create a new component in components/ and include it here #}
8
+ {% include "not-found.njk" ignore missing %}
9
9
 
10
- {% include "not-found.njk" ignore missing %}
10
+ {# Markdown files live in components/ too. Pass the data the file needs #}
11
+ {# {% renderFile "example.md" | markdownPath, { site: site } %} #}
@@ -4,7 +4,8 @@ permalink: "/"
4
4
  layout: base.njk
5
5
  ---
6
6
 
7
- <!-- !IMPORTANT -->
8
- <!-- To keep order, the best practice is to create a new component.njk file in components/ and include it here-->
7
+ {# To keep order, create a new component in components/ and include it here #}
8
+ {% include "welcome.njk" ignore missing %}
9
9
 
10
- {% include "welcome.njk" ignore missing %}
10
+ {# Markdown files live in components/ too. Pass the data the file needs #}
11
+ {# {% renderFile "example.md" | markdownPath, { site: site } %} #}
@@ -1,11 +1,7 @@
1
1
  // import { initExampleModule } from "./modules/exampleModule"
2
2
 
3
- function initGlobal() {
3
+ document.addEventListener('DOMContentLoaded', () => {
4
4
  // initExampleModule();
5
- }
5
+ });
6
6
 
7
- // Global logic here
8
-
9
- // Do not touch
10
- // This instruction starts global.ts itself
11
- document.addEventListener('DOMContentLoaded', initGlobal);
7
+ // Global logic here
@@ -2,7 +2,7 @@ import '../global';
2
2
 
3
3
  // import { initExampleModule } from '../modules/exampleModule';
4
4
 
5
- document.addEventListener("DOMContentLoaded", (): void => {
5
+ document.addEventListener("DOMContentLoaded", () => {
6
6
  // initExampleModule();
7
7
  });
8
8
 
@@ -2,7 +2,7 @@ import '../global';
2
2
 
3
3
  // import { initExampleModule } from '../modules/exampleModule';
4
4
 
5
- document.addEventListener("DOMContentLoaded", (): void => {
5
+ document.addEventListener("DOMContentLoaded", () => {
6
6
  // initExampleModule();
7
7
  });
8
8
 
@@ -5,11 +5,13 @@ const { validatePageName, validateOutputPath, checkRequiredFiles } = require('./
5
5
  const { addPage, removePage, renamePage } = require('./lib/pageActions');
6
6
  const { pageExists } = require('./lib/pageArtifacts');
7
7
  const { updateOutputPath, getCurrentOutputPath } = require('./lib/outputPath');
8
- const { ask, confirm, close } = require('./cli/prompt');
8
+ const { availableLayouts } = require('./lib/layouts');
9
+ const { ask, confirm, askChoice, close } = require('./cli/prompt');
9
10
  const ui = require('./cli/ui');
10
11
 
11
12
  const NEW_LINE = '\n';
12
13
  const NO_PREFIX = '';
14
+ const NO_LAYOUT = '';
13
15
 
14
16
  async function askPageName(prompt) {
15
17
  const raw = await ask(prompt);
@@ -24,9 +26,26 @@ async function askPageName(prompt) {
24
26
  return name;
25
27
  }
26
28
 
29
+ async function askLayout() {
30
+ const layouts = availableLayouts();
31
+ if (layouts.length === 0) return NO_LAYOUT;
32
+ if (layouts.length === 1) return layouts[0];
33
+
34
+ const choices = layouts.map((name) => ({ label: name, value: name }));
35
+ return askChoice(message('cli.layoutPrompt'), choices);
36
+ }
37
+
27
38
  async function handleCreate() {
28
39
  const name = await askPageName(ui.promptLine(message('cli.createPrompt')));
29
- if (name) addPage(name);
40
+ if (!name) return;
41
+
42
+ const layoutName = await askLayout();
43
+ if (layoutName === null) {
44
+ ui.notice(message('cli.cancelled'));
45
+ return;
46
+ }
47
+
48
+ addPage(name, layoutName);
30
49
  }
31
50
 
32
51
  async function handleRemove() {
@@ -1,30 +1,58 @@
1
1
  const readline = require('readline');
2
2
  const settings = require('../config/settings.json');
3
- const { color } = require('../lib/colors');
3
+ const { color, paint } = require('../lib/colors');
4
4
 
5
5
  const CONTROL_CHARACTERS_PATTERN = /[\x00-\x1F\x7F]/g;
6
6
  const AFFIRMATIVE_PATTERN = /^y(es)?$/i;
7
7
  const CONFIRM_HINT = settings.cli.confirmHint;
8
8
  const EMPTY = '';
9
+ const NEW_LINE = '\n';
9
10
 
10
- const readlineInterface = readline.createInterface({
11
- input: process.stdin,
12
- output: process.stdout,
13
- terminal: true,
14
- });
11
+ const CURSOR_UP_PREFIX = '\x1B[';
12
+ const CURSOR_UP_SUFFIX = 'A';
13
+ const CLEAR_LINE = '\x1B[K';
14
+ const CHOICE_INDENT = ' ';
15
+
16
+ const SELECTED_SYMBOL = settings.cli.symbols.selected;
17
+ const UNSELECTED_SYMBOL = settings.cli.symbols.unselected;
18
+ const SELECTED_COLOR = settings.cli.colors.selected;
19
+
20
+ const EXIT_KEYS = ['c', 'd'];
21
+ const UP_KEY = 'up';
22
+ const DOWN_KEY = 'down';
23
+ const CONFIRM_KEYS = ['return', 'enter'];
24
+
25
+ let activeInterface = null;
15
26
 
16
27
  function sanitizeInput(value) {
17
28
  return (value ?? EMPTY).replace(CONTROL_CHARACTERS_PATTERN, EMPTY).trim();
18
29
  }
19
30
 
31
+ function cursorUp(lines) {
32
+ return `${CURSOR_UP_PREFIX}${lines}${CURSOR_UP_SUFFIX}`;
33
+ }
34
+
20
35
  function ask(prompt) {
21
36
  return new Promise((resolve) => {
22
- const onClose = () => resolve(null);
23
- readlineInterface.once('close', onClose);
24
- readlineInterface.question(prompt, (answer) => {
25
- readlineInterface.off('close', onClose);
26
- resolve(sanitizeInput(answer));
37
+ const readlineInterface = readline.createInterface({
38
+ input: process.stdin,
39
+ output: process.stdout,
40
+ terminal: true,
27
41
  });
42
+
43
+ activeInterface = readlineInterface;
44
+ let settled = false;
45
+
46
+ const finish = (value) => {
47
+ if (settled) return;
48
+ settled = true;
49
+ activeInterface = null;
50
+ readlineInterface.close();
51
+ resolve(value);
52
+ };
53
+
54
+ readlineInterface.on('close', () => finish(null));
55
+ readlineInterface.question(prompt, (answer) => finish(sanitizeInput(answer)));
28
56
  });
29
57
  }
30
58
 
@@ -33,8 +61,66 @@ async function confirm(prompt) {
33
61
  return AFFIRMATIVE_PATTERN.test((answer ?? EMPTY).trim());
34
62
  }
35
63
 
64
+ function askChoice(prompt, choices) {
65
+ return new Promise((resolve) => {
66
+ let selectedIndex = 0;
67
+ let settled = false;
68
+
69
+ process.stdout.write(`${prompt}${NEW_LINE}${NEW_LINE}`);
70
+
71
+ const render = (firstRender) => {
72
+ if (!firstRender) process.stdout.write(cursorUp(choices.length));
73
+
74
+ const output = choices.map((choice, index) => {
75
+ const selected = index === selectedIndex;
76
+ const symbol = selected ? SELECTED_SYMBOL : UNSELECTED_SYMBOL;
77
+ const label = `${CHOICE_INDENT}${symbol} ${choice.label}`;
78
+ const line = selected ? paint(SELECTED_COLOR, label) : label;
79
+ return `${line}${CLEAR_LINE}${NEW_LINE}`;
80
+ }).join(EMPTY);
81
+
82
+ process.stdout.write(output);
83
+ };
84
+
85
+ const finish = (value) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ process.stdin.removeListener('keypress', onKeyPress);
89
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
90
+ process.stdin.pause();
91
+ resolve(value);
92
+ };
93
+
94
+ function onKeyPress(character, key) {
95
+ if (key.ctrl && EXIT_KEYS.includes(key.name)) {
96
+ finish(null);
97
+ return;
98
+ }
99
+ if (key.name === UP_KEY) {
100
+ selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : choices.length - 1;
101
+ render(false);
102
+ return;
103
+ }
104
+ if (key.name === DOWN_KEY) {
105
+ selectedIndex = selectedIndex < choices.length - 1 ? selectedIndex + 1 : 0;
106
+ render(false);
107
+ return;
108
+ }
109
+ if (CONFIRM_KEYS.includes(key.name)) {
110
+ finish(choices[selectedIndex].value);
111
+ }
112
+ }
113
+
114
+ readline.emitKeypressEvents(process.stdin);
115
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
116
+ process.stdin.resume();
117
+ process.stdin.on('keypress', onKeyPress);
118
+ render(true);
119
+ });
120
+ }
121
+
36
122
  function close() {
37
- readlineInterface.close();
123
+ if (activeInterface) activeInterface.close();
38
124
  }
39
125
 
40
- module.exports = { ask, confirm, close };
126
+ module.exports = { ask, confirm, askChoice, close };
@@ -4,6 +4,16 @@
4
4
  "alreadyInside": "You are already inside a Nibula project.",
5
5
  "alreadyInsideHint": "Run \"nib new\" from outside a project."
6
6
  },
7
+ "update": {
8
+ "current": "Current version: {current}",
9
+ "available": "Latest version: {latest}",
10
+ "unknownLatest": "Could not reach the npm registry to check the latest version.",
11
+ "scopeNotice": "The update applies to newly created projects only. Existing ones keep running their own copy of Nibula.",
12
+ "confirm": "Update now? [Y/n] ",
13
+ "cancelled": "Update cancelled.",
14
+ "nonInteractive": "Run the command in a terminal to confirm the update.",
15
+ "upToDate": "Already on the latest version ({current})."
16
+ },
7
17
  "validation": {
8
18
  "invalidName": "Invalid name.",
9
19
  "invalidNameCharacters": "Page name can only contain lowercase letters, numbers, and hyphens.",
@@ -58,6 +68,7 @@
58
68
  "renameToPrompt": "New name: ",
59
69
  "outputPathPrompt": "New output path: ",
60
70
  "menuPrompt": "Choose an option: ",
71
+ "layoutPrompt": "Select a layout (arrow keys, Enter to confirm):",
61
72
  "currentOutputPath": "Current path: \"{path}\"",
62
73
  "confirmRemove": "This permanently deletes all files for \"{name}\".",
63
74
  "cancelled": "Cancelled.",
@@ -13,7 +13,8 @@
13
13
  "stylePages": "src/frontend/scss/pages",
14
14
  "pagesData": "src/frontend/data/pages.json",
15
15
  "siteData": "src/frontend/data/site.json",
16
- "templates": "tools/res/templates"
16
+ "templates": "tools/res/templates",
17
+ "layouts": "src/frontend/layouts"
17
18
  },
18
19
  "languages": {
19
20
  "javascript": {
@@ -69,6 +70,7 @@
69
70
  "routeExtension": "njk",
70
71
  "styleTemplate": "template.scss",
71
72
  "routeTemplate": "template.njk",
73
+ "layoutExtension": "njk",
72
74
  "outputFiles": [
73
75
  "js/pages/{camelName}.js",
74
76
  "css/pages/{camelName}.css",
@@ -95,7 +97,8 @@
95
97
  },
96
98
  "frontMatter": {
97
99
  "title": "title: \"{camelName}\"",
98
- "permalink": "permalink: \"/{pageName}/\""
100
+ "permalink": "permalink: \"/{pageName}/\"",
101
+ "layout": "layout: {layoutName}"
99
102
  },
100
103
  "cli": {
101
104
  "title": "Nibula CLI",
@@ -105,7 +108,9 @@
105
108
  "prompt": "\u276f",
106
109
  "error": "\u2716",
107
110
  "warning": "\u26a0",
108
- "bullet": "-"
111
+ "bullet": "-",
112
+ "selected": "\u25c9",
113
+ "unselected": "*"
109
114
  },
110
115
  "colors": {
111
116
  "box": "cyan",
@@ -113,7 +118,8 @@
113
118
  "prompt": "cyan",
114
119
  "error": "red",
115
120
  "warning": "yellow",
116
- "notice": "dim"
121
+ "notice": "dim",
122
+ "selected": "cyan"
117
123
  },
118
124
  "menu": [
119
125
  { "key": "1", "action": "createPage", "label": "Create page" },
@@ -46,6 +46,10 @@ function removeFile(target) {
46
46
  return true;
47
47
  }
48
48
 
49
+ function readDirectory(directoryPath) {
50
+ return fs.readdirSync(directoryPath);
51
+ }
52
+
49
53
  function removeDirectory(target) {
50
54
  if (!exists(target)) return false;
51
55
  fs.rmSync(target, { recursive: true, force: true });
@@ -71,6 +75,7 @@ module.exports = {
71
75
  copyFile,
72
76
  moveFile,
73
77
  removeFile,
78
+ readDirectory,
74
79
  removeDirectory,
75
80
  removeDirectoryIfEmpty,
76
81
  };
@@ -0,0 +1,15 @@
1
+ const settings = require('../config/settings.json');
2
+ const { PATHS } = require('./paths');
3
+ const { exists, readDirectory } = require('./files');
4
+
5
+ const LAYOUT_SUFFIX = `.${settings.page.layoutExtension}`;
6
+
7
+ function availableLayouts() {
8
+ if (!exists(PATHS.layouts)) return [];
9
+
10
+ return readDirectory(PATHS.layouts)
11
+ .filter((name) => name.endsWith(LAYOUT_SUFFIX))
12
+ .sort();
13
+ }
14
+
15
+ module.exports = { availableLayouts };
@@ -11,13 +11,18 @@ const { readPagesData, addPageData, removePageData, renamePageData } = require('
11
11
  const FRONT_MATTER_PATTERNS = Object.freeze({
12
12
  title: /^title:.*$/m,
13
13
  permalink: /^permalink:.*$/m,
14
+ layout: /^layout:.*$/m,
14
15
  });
15
16
 
17
+ const ROUTE_FRONT_MATTER_KEYS = ['title', 'permalink'];
18
+ const RENAME_FRONT_MATTER_KEYS = ['permalink'];
19
+ const LAYOUT_KEY = 'layout';
20
+
16
21
  function templatePath(name) {
17
22
  return path.join(PATHS.templates, name);
18
23
  }
19
24
 
20
- function applyFrontMatter(filePath, values, keys = Object.keys(FRONT_MATTER_PATTERNS)) {
25
+ function applyFrontMatter(filePath, values, keys) {
21
26
  let content = readText(filePath);
22
27
 
23
28
  for (const key of keys) {
@@ -27,14 +32,17 @@ function applyFrontMatter(filePath, values, keys = Object.keys(FRONT_MATTER_PATT
27
32
  writeText(filePath, content);
28
33
  }
29
34
 
30
- function addPage(pageName) {
35
+ function addPage(pageName, layoutName) {
31
36
  if (pageExists(pageName)) {
32
37
  log('page.alreadyExists', { name: pageName });
33
38
  return;
34
39
  }
35
40
 
36
41
  const { camelName, source } = getPageArtifacts(pageName);
37
- const values = { camelName, pageName };
42
+ const values = { camelName, pageName, layoutName };
43
+ const frontMatterKeys = layoutName
44
+ ? [...ROUTE_FRONT_MATTER_KEYS, LAYOUT_KEY]
45
+ : ROUTE_FRONT_MATTER_KEYS;
38
46
 
39
47
  const creations = [
40
48
  { destination: source.style, template: settings.page.styleTemplate, isRoute: false },
@@ -52,7 +60,7 @@ function addPage(pageName) {
52
60
  }
53
61
 
54
62
  copyFile(templateFile, destination);
55
- if (isRoute) applyFrontMatter(destination, values);
63
+ if (isRoute) applyFrontMatter(destination, values, frontMatterKeys);
56
64
 
57
65
  log('page.fileCreated', { path: destination });
58
66
  }
@@ -95,7 +103,7 @@ function renamePage(oldName, newName) {
95
103
  moveFile(source, destination);
96
104
  log('page.fileRenamed', { source, destination });
97
105
 
98
- if (isRoute) applyFrontMatter(destination, values, ['permalink']);
106
+ if (isRoute) applyFrontMatter(destination, values, RENAME_FRONT_MATTER_KEYS);
99
107
  }
100
108
  } catch (error) {
101
109
  log('page.renameFailed', { error: error.message });
@@ -47,6 +47,7 @@ const PATHS = Object.freeze({
47
47
  get siteData() { return resolveFromProject(settings.paths.siteData); },
48
48
  get pagesData() { return resolveFromProject(settings.paths.pagesData); },
49
49
  get templates() { return resolveFromPackage(settings.paths.templates); },
50
+ get layouts() { return resolveFromProject(settings.paths.layouts); },
50
51
  });
51
52
 
52
53
  function getNotInsideProjectMessage() {
@@ -1,4 +1,5 @@
1
1
  import '../global.js';
2
+
2
3
  // import { initExampleModule } from '../modules/exampleModule';
3
4
 
4
5
  document.addEventListener("DOMContentLoaded", () => {
@@ -4,7 +4,8 @@ permalink: ""
4
4
  layout: base.njk
5
5
  ---
6
6
 
7
- <!-- !IMPORTANT -->
8
- <!-- To keep order, the best practice is to create a new component.njk file in components/ and include it here-->
7
+ {# To keep order, create a new component in components/ and include it here #}
8
+ {% include "component.njk" ignore missing %}
9
9
 
10
- {% include "component.njk" ignore missing %}
10
+ {# Markdown files live in components/ too. Pass the data the file needs #}
11
+ {# {% renderFile "example.md" | markdownPath, { site: site } %} #}
@@ -2,7 +2,7 @@ import '../global';
2
2
 
3
3
  // import { initExampleModule } from '../modules/exampleModule';
4
4
 
5
- document.addEventListener("DOMContentLoaded", (): void => {
5
+ document.addEventListener("DOMContentLoaded", () => {
6
6
  // initExampleModule();
7
7
  });
8
8