nibula 2.0.2 → 2.2.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.
@@ -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 };
@@ -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" },
@@ -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() {
@@ -4,7 +4,12 @@ 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
+ {#
12
+ <article class="markdown-body">
13
+ {% renderFile "your-file.md" | markdownPath, { site: site } %}
14
+ </article>
15
+ #}