nibula 2.0.2 → 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.
- package/.eleventy.js +68 -8
- package/CHANGELOG.md +79 -0
- package/bin/create.js +2 -25
- package/docs/Components.md +80 -42
- package/docs/Creating pages.md +3 -1
- package/docs/Deploy.md +185 -127
- package/docs/Head and SEO.md +98 -56
- package/docs/Project structure.md +3 -2
- package/package.json +3 -1
- package/src/frontend/layouts/base.njk +1 -1
- package/src/frontend/routes/404.njk +4 -3
- package/src/frontend/routes/index.njk +4 -3
- package/tools/assistant.js +21 -2
- package/tools/cli/prompt.js +99 -13
- package/tools/config/messages.json +1 -0
- package/tools/config/settings.json +10 -4
- package/tools/lib/files.js +5 -0
- package/tools/lib/layouts.js +15 -0
- package/tools/lib/pageActions.js +13 -5
- package/tools/lib/paths.js +1 -0
- package/tools/res/templates/template.njk +4 -3
|
@@ -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
|
|
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
|
}
|
|
@@ -4,7 +4,8 @@ permalink: "404.html"
|
|
|
4
4
|
layout: base.njk
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
{# To keep order, create a new component in components/ and include it here #}
|
|
8
|
+
{% include "not-found.njk" ignore missing %}
|
|
9
9
|
|
|
10
|
-
{
|
|
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
|
-
|
|
8
|
-
|
|
7
|
+
{# To keep order, create a new component in components/ and include it here #}
|
|
8
|
+
{% include "welcome.njk" ignore missing %}
|
|
9
9
|
|
|
10
|
-
{
|
|
10
|
+
{# Markdown files live in components/ too. Pass the data the file needs #}
|
|
11
|
+
{# {% renderFile "example.md" | markdownPath, { site: site } %} #}
|
package/tools/assistant.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
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() {
|
package/tools/cli/prompt.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
123
|
+
if (activeInterface) activeInterface.close();
|
|
38
124
|
}
|
|
39
125
|
|
|
40
|
-
module.exports = { ask, confirm, close };
|
|
126
|
+
module.exports = { ask, confirm, askChoice, close };
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"renameToPrompt": "New name: ",
|
|
69
69
|
"outputPathPrompt": "New output path: ",
|
|
70
70
|
"menuPrompt": "Choose an option: ",
|
|
71
|
+
"layoutPrompt": "Select a layout (arrow keys, Enter to confirm):",
|
|
71
72
|
"currentOutputPath": "Current path: \"{path}\"",
|
|
72
73
|
"confirmRemove": "This permanently deletes all files for \"{name}\".",
|
|
73
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" },
|
package/tools/lib/files.js
CHANGED
|
@@ -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 };
|
package/tools/lib/pageActions.js
CHANGED
|
@@ -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
|
|
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,
|
|
106
|
+
if (isRoute) applyFrontMatter(destination, values, RENAME_FRONT_MATTER_KEYS);
|
|
99
107
|
}
|
|
100
108
|
} catch (error) {
|
|
101
109
|
log('page.renameFailed', { error: error.message });
|
package/tools/lib/paths.js
CHANGED
|
@@ -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() {
|
|
@@ -4,7 +4,8 @@ permalink: ""
|
|
|
4
4
|
layout: base.njk
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
{# To keep order, create a new component in components/ and include it here #}
|
|
8
|
+
{% include "component.njk" ignore missing %}
|
|
9
9
|
|
|
10
|
-
{
|
|
10
|
+
{# Markdown files live in components/ too. Pass the data the file needs #}
|
|
11
|
+
{# {% renderFile "example.md" | markdownPath, { site: site } %} #}
|