@changke/staticnext-build 0.13.0 → 0.27.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/AGENTS.md ADDED
@@ -0,0 +1,195 @@
1
+ # AGENTS.md
2
+
3
+ Guidelines for AI agents working in the `@changke/staticnext-build` repository.
4
+
5
+ ## Project Overview
6
+
7
+ Node.js CLI build tool (`sn-build`) that orchestrates static asset builds: cleaning,
8
+ copying, Markdown-to-HTML, HTML prototyping (Nunjucks or jstmpl), JS/TS bundling,
9
+ and CSS bundling via esbuild. Pure JavaScript, ESM-only, no TypeScript compiler,
10
+ no React/Next.js framework.
11
+
12
+ ## Build / Lint / Test Commands
13
+
14
+ ```bash
15
+ # Lint (ESLint v9 flat config with @stylistic)
16
+ npm run lint
17
+
18
+ # Run all tests (Node.js built-in test runner, requires Node >= 22.14.0)
19
+ npm test
20
+
21
+ # Run a single test file
22
+ node --test test/util.test.mjs
23
+
24
+ # Run tests matching a name pattern
25
+ node --test --test-name-pattern="getTargetPathString" test/util.test.mjs
26
+
27
+ # Lint + test (runs automatically before npm publish)
28
+ npm run prepublishOnly
29
+ ```
30
+
31
+ There is no build/compile step -- the source is plain `.mjs` files executed directly.
32
+
33
+ ## Module System
34
+
35
+ - **ESM only.** Package has `"type": "module"`. All files use `.mjs` extension.
36
+ - No CommonJS (`require`, `module.exports`) anywhere.
37
+
38
+ ## Code Style
39
+
40
+ Formatting is enforced entirely by ESLint with `@stylistic/eslint-plugin` -- there
41
+ is no Prettier. Run `npm run lint` to check.
42
+
43
+ ### Formatting Rules
44
+
45
+ | Rule | Setting |
46
+ |------|---------|
47
+ | Indentation | 2 spaces |
48
+ | Quotes | Single quotes |
49
+ | Semicolons | Always required |
50
+ | Trailing commas | Never (`comma-dangle: never`) |
51
+ | Object braces | No spaces (`{foo}` not `{ foo }`) |
52
+ | Block spacing | No spaces (`{return x}` not `{ return x }`) |
53
+ | Brace style | 1TBS (opening brace on same line) |
54
+ | Arrow parens | As-needed (omit for single param) |
55
+ | Named function parens | No space before: `function foo()` |
56
+ | Async arrow | Space required: `async () =>` |
57
+ | Line endings | LF only |
58
+ | Trailing whitespace | Trimmed (except in `.md` files) |
59
+ | Final newline | Always insert |
60
+
61
+ ### Import Conventions
62
+
63
+ ```js
64
+ // Node built-ins: always use `node:` prefix
65
+ import {readFile} from 'node:fs/promises';
66
+ import path from 'node:path';
67
+ import {env} from 'node:process';
68
+
69
+ // Third-party: bare specifiers
70
+ import {build} from 'esbuild';
71
+ import {marked} from 'marked';
72
+
73
+ // Local: relative paths with explicit .mjs extension
74
+ import {consoleLogColored} from '../utils.mjs';
75
+ import Config from './config.mjs';
76
+ ```
77
+
78
+ - Prefer named imports over namespace imports.
79
+ - Default imports are used for classes/singletons (e.g., `Config`, `path`).
80
+ - Group imports: Node built-ins first, then third-party, then local (separated by blank lines).
81
+
82
+ ### Export Conventions
83
+
84
+ Modules typically provide both named and default exports:
85
+
86
+ ```js
87
+ const clean = async dirs => { /* ... */ };
88
+ export {clean};
89
+ export default clean;
90
+ ```
91
+
92
+ ### Naming Conventions
93
+
94
+ | Entity | Convention | Examples |
95
+ |--------|-----------|----------|
96
+ | Variables, functions, params | camelCase | `getTargetPathString`, `srcPaths` |
97
+ | Classes | PascalCase | `Config` |
98
+ | Constants | camelCase (not SCREAMING_SNAKE) | `taskName`, `defaultConfig` |
99
+ | Files | kebab-case with `.mjs` | `set-env-dev.mjs`, `is-prod.mjs` |
100
+ | Test files | `*.test.mjs` | `util.test.mjs`, `config.test.mjs` |
101
+
102
+ ### Types
103
+
104
+ No TypeScript compiler is used. Types are defined via **JSDoc** in `lib/typedefs.mjs`:
105
+
106
+ ```js
107
+ /**
108
+ * @typedef {Object} PathPart
109
+ * @property {string} assets Path for general static assets
110
+ * @property {string=} css Path for CSS files
111
+ */
112
+ ```
113
+
114
+ Use `@param`, `@returns`, `@typedef`, and `@type` annotations for documenting
115
+ function signatures and inline type hints:
116
+
117
+ ```js
118
+ /** @type string[] */
119
+ const negativePatterns = [];
120
+ ```
121
+
122
+ ### Error Handling
123
+
124
+ - Use `try/catch` in async functions.
125
+ - Log errors with `console.error()`.
126
+ - Top-level entry point (`build.mjs`) catches with `.catch()` and calls `process.exit(1)`.
127
+ - Inner functions log errors and allow graceful continuation where appropriate.
128
+
129
+ ### Async Patterns
130
+
131
+ - `async/await` is the primary pattern.
132
+ - `Promise.all()` for parallel operations (e.g., running styles + scripts + markdown concurrently).
133
+ - `.then()` chaining for sequential task pipelines:
134
+ ```js
135
+ setEnvDev().then(tasks.clean).then(tasks.copy).then(paraDev);
136
+ ```
137
+ - Arrow functions are the dominant function style.
138
+
139
+ ## Testing
140
+
141
+ Tests use the **Node.js built-in test runner** (`node:test` module), not Jest or Vitest.
142
+
143
+ ### Test Structure
144
+
145
+ ```js
146
+ import {suite, test} from 'node:test';
147
+ import assert from 'node:assert/strict';
148
+
149
+ void suite('SuiteName', () => {
150
+ test('descriptive test name', () => {
151
+ assert.strictEqual(actual, expected);
152
+ });
153
+
154
+ test('async test', async () => {
155
+ const result = await someAsyncFunc();
156
+ assert.strictEqual(result, expected);
157
+ });
158
+ });
159
+ ```
160
+
161
+ Key patterns:
162
+ - Suites are wrapped with `void suite(...)` (the `void` suppresses the return value).
163
+ - Use `node:assert/strict` -- always strict assertions.
164
+ - Tests clean up after themselves (create/remove temp directories in `before`/`after`).
165
+ - Test fixtures live in `test/fixtures/`.
166
+
167
+ ## Project Structure
168
+
169
+ ```
170
+ build.mjs CLI entry point (bin: sn-build)
171
+ sn-config.mjs Default project configuration
172
+ lib/
173
+ config.mjs Config loading, merging, path helpers
174
+ is-prod.mjs NODE_ENV check helper
175
+ typedefs.mjs JSDoc type definitions
176
+ utils.mjs File I/O, glob helpers, console coloring
177
+ vars.mjs Default configuration values
178
+ tasks/
179
+ clean.mjs Remove target directories
180
+ copy.mjs Copy static assets via glob
181
+ markdown.mjs Convert .md -> .html (marked + nunjucks/jstmpl)
182
+ prototype.mjs Generate static HTML pages from templates
183
+ scripts.mjs Bundle JS/TS via esbuild
184
+ set-env-dev.mjs Set NODE_ENV=development
185
+ set-env-prod.mjs Set NODE_ENV=production
186
+ styles.mjs Bundle CSS via esbuild
187
+ test/
188
+ *.test.mjs Test files
189
+ fixtures/ Test fixture data
190
+ ```
191
+
192
+ ## Changelog & Versioning
193
+
194
+ The project follows [Semantic Versioning](https://semver.org/) and maintains a
195
+ `CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.27.0
8
+ 2026-02-12
9
+ ### Changed
10
+ - Updated dependencies
11
+ - Various code changes after AI (Opus 4.6) code review
12
+
13
+ ## 0.26.0
14
+ 2025-12-21
15
+ ### Removed
16
+ - `del`, `cpy` and `globby` to prefer node's native features (`rm`, `cp` and `glob`)
17
+ - `mocha` to prefer node's built-in test runner
18
+ ### Added
19
+ - New experimental templating system `JSTmpl` as default
20
+ - Parameter `--njk` to specify Nunjucks as template engine
21
+ - Parameter `--debug` to show verbose information for "copy" and "markdown" tasks
22
+ ### Changed
23
+ - Console outputs now with colors
24
+ - Updated various dependencies
25
+
7
26
  ## 0.13.0
8
27
  2025-09-20
9
28
  ### Changed
package/build.mjs CHANGED
@@ -1,52 +1,76 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  import {argv} from 'node:process';
3
+ import path from 'node:path';
4
4
 
5
- import Config from './lib/config.mjs';
5
+ import './lib/typedefs.mjs';
6
+ import config from './lib/config.mjs';
6
7
 
7
8
  import clean from './lib/tasks/clean.mjs';
8
9
  import copy from './lib/tasks/copy.mjs';
9
- import markdown from './lib/tasks/markdown.mjs';
10
- import prototype from './lib/tasks/prototype.mjs';
10
+ import {markdown, markdown2} from './lib/tasks/markdown.mjs';
11
+ import {prototype, prototype2} from './lib/tasks/prototype.mjs';
11
12
  import scripts from './lib/tasks/scripts.mjs';
12
13
  import setEnvDev from './lib/tasks/set-env-dev.mjs';
13
14
  import setEnvProd from './lib/tasks/set-env-prod.mjs';
14
15
  import styles from './lib/tasks/styles.mjs';
16
+ import {consoleLogColored} from './lib/utils.mjs';
15
17
 
16
18
  const taskName = argv[2]?.toLowerCase() || 'dev';
19
+ const useNjk = argv.includes('--njk');
20
+ const debug = argv.includes('--debug');
21
+
22
+ if (debug) {
23
+ process.env.SN_BUILD_MODE = 'debug';
24
+ }
17
25
 
18
26
  // load config data
19
- const conf = await Config.loadConfig();
27
+ const conf = await config.loadConfig();
20
28
 
21
29
  const taskEnded = taskName => {
22
30
  console.timeEnd(taskName);
23
31
  };
24
32
 
25
- const srcPaths = Config.getSourcePaths(conf.sourcePath, conf.sourceRoot);
26
- const tgtPaths = Config.getTargetPaths(conf.targetPath, conf.targetRoot, conf.targetAssetsRoot);
33
+ const srcPaths = config.getSourcePaths(conf.sourcePath, path.normalize(conf.sourceRoot));
34
+ const tgtPaths = config.getTargetPaths(conf.targetPath, path.normalize(conf.targetRoot), conf.targetAssetsRoot);
27
35
 
28
36
  // Set parameters of each task
29
37
  const tasks = {
30
- clean: () => clean([`${conf.targetRoot}/*`]),
38
+ clean: () => clean([conf.targetRoot]),
31
39
  copy: () => copy([
32
- {sources: [`${conf.sourceRoot}/*`, `!${conf.sourceRoot}/*.d.ts`], target: conf.targetRoot},
33
- {sources: `${srcPaths.assets}/**/*`, target: tgtPaths.assets},
34
- {sources: `${srcPaths.modules}/**/assets/**/*`, target: tgtPaths.moduleAssets},
35
- {sources: `${srcPaths.markdown}/**/assets/**/*`, target: tgtPaths.markdown}
36
- ].concat(Config.getCopyPairs(conf.copyPairs, conf.sourceRoot, tgtPaths.assets))),
40
+ // Root2root: src/main/webapp/index.html -> tgt/static/index.html
41
+ {sources: [`${conf.sourceRoot}/*.*`, `!${conf.sourceRoot}/*.d.ts`], target: conf.targetRoot, opts: {flat: true}},
42
+ // Assets: src/main/webapp/assets/dummy.txt -> tgt/static/assets/_sn_/dummy.txt
43
+ {sources: `${srcPaths.assets}/**/*.*`, target: tgtPaths.assets, opts: {levelRemoval: 4}},
44
+ // Module assets:
45
+ // src/main/webapp/modules/mod-weather/assets/weather/sample.json -> tgt/static/assets/_sn_/modules/mod-weather/assets/weather/sample.json
46
+ {sources: `${srcPaths.modules}/**/assets/**/*.*`, target: tgtPaths.moduleAssets, opts: {levelRemoval: 4}},
47
+ // Markdown file assets
48
+ // src/main/webapp/md/google-pixel-9/assets/cover.webp -> public/posts/google-pixel-9/assets/cover.webp
49
+ {sources: `${srcPaths.markdown}/**/assets/**/*.*`, target: tgtPaths.markdown, opts: {levelRemoval: 4}}
50
+ ].concat(config.getCopyPairs(conf.copyPairs, conf.sourceRoot, tgtPaths.assets))),
37
51
  markdown: () => markdown(
38
52
  srcPaths.markdown,
39
53
  [srcPaths.prototype, srcPaths.modules],
40
54
  conf.njkGlobals,
41
55
  tgtPaths.markdown
42
56
  ),
57
+ markdown2: () => markdown2(
58
+ srcPaths.markdown,
59
+ conf.njkGlobals,
60
+ tgtPaths.markdown
61
+ ),
43
62
  prototype: () => prototype(
44
63
  srcPaths.prototype,
45
64
  [srcPaths.modules],
46
65
  conf.njkGlobals,
47
66
  tgtPaths.prototype
48
67
  ),
49
- scripts: () => scripts(Config.getScriptEntries(conf.moduleEntries, conf.mainEntryFile, srcPaths), tgtPaths.assets),
68
+ prototype2: () => prototype2(
69
+ srcPaths.prototype,
70
+ conf.njkGlobals,
71
+ tgtPaths.prototype
72
+ ),
73
+ scripts: () => scripts(config.getScriptEntries(conf.moduleEntries, conf.mainEntryFile, srcPaths), tgtPaths.assets),
50
74
  styles: () => styles([
51
75
  `${srcPaths.css}/brands/*.css`,
52
76
  `${srcPaths.css}/index.css`,
@@ -57,7 +81,7 @@ const tasks = {
57
81
 
58
82
  const paraDev = () => {
59
83
  return Promise.all([
60
- tasks.markdown().then(tasks.prototype),
84
+ useNjk ? tasks.markdown().then(tasks.prototype) : tasks.markdown2().then(tasks.prototype2),
61
85
  tasks.styles(),
62
86
  tasks.scripts()
63
87
  ]);
@@ -65,7 +89,7 @@ const paraDev = () => {
65
89
 
66
90
  const paraProd = () => {
67
91
  return Promise.all([
68
- tasks.markdown(),
92
+ useNjk ? tasks.markdown() : tasks.markdown2(),
69
93
  tasks.styles(),
70
94
  tasks.scripts()
71
95
  ]);
@@ -83,7 +107,9 @@ const taskMap = {
83
107
  'clean': tasks.clean,
84
108
  'copy': tasks.copy,
85
109
  'markdown': tasks.markdown,
110
+ 'markdown2': tasks.markdown2,
86
111
  'prototype': tasks.prototype,
112
+ 'prototype2': tasks.prototype2,
87
113
  'scripts': tasks.scripts,
88
114
  'setenvdev': setEnvDev,
89
115
  'setenvprod': setEnvProd,
@@ -94,10 +120,16 @@ const taskMap = {
94
120
 
95
121
  const task = taskMap[taskName];
96
122
 
97
- console.log('+++ SN Build +++');
123
+ if (typeof task !== 'function') {
124
+ console.warn(`Task "${taskName}" not found or is not a function!`);
125
+ process.exit(1);
126
+ }
127
+
128
+ consoleLogColored('+++ SN Build +++', ['bold', 'blue']);
98
129
  console.time(taskName);
99
130
  console.log(`Task "${taskName}" started...`);
100
131
  task().then(() => {
132
+ console.log('Task ended.');
101
133
  taskEnded(taskName);
102
134
  }).catch(err => {
103
135
  console.error(err);
package/lib/config.mjs CHANGED
@@ -14,7 +14,7 @@ class Config {
14
14
  const cf = configFile || `${cwd()}${this.customConfigFile}`;
15
15
  const mod = await import(cf);
16
16
  const customConf = mod.default;
17
- return this.deepMergeConfObj(defaultConf, customConf);
17
+ return this.mergeConfObj(defaultConf, customConf);
18
18
  } catch (e) {
19
19
  // file not exist
20
20
  console.error('Error loading file: ', e);
@@ -22,9 +22,9 @@ class Config {
22
22
  }
23
23
  }
24
24
 
25
- // keep nested obj values while merging
25
+ // keep nested obj values while merging (only merges one level deep)
26
26
  // e.g. {foo:{a:1,b:2}} merged with {foo:{b:3,c:4}} will be {foo:{a:1,b:3,c:4}}
27
- deepMergeConfObj(defaultConf, customConf) {
27
+ mergeConfObj(defaultConf, customConf) {
28
28
  const conf = Object.assign({}, defaultConf);
29
29
  for (const [k, v] of Object.entries(customConf)) {
30
30
  if (this.objsToMerge.includes(k)) {
@@ -89,12 +89,13 @@ class Config {
89
89
  */
90
90
  getCopyPairs = (copyPairs, sourceBase, targetBase, appendSourcePath = false) => {
91
91
  if (copyPairs && Array.isArray(copyPairs) && copyPairs.length > 0) {
92
- return copyPairs.map(pr => {
92
+ return copyPairs.map(pair => {
93
+ const pc = {...pair};
93
94
  if (appendSourcePath) {
94
- pr.sources = `${appendSourcePath}${pr.sources}`;
95
+ pc.sources = `${sourceBase}${pc.sources}`;
95
96
  }
96
- pr.target = `${targetBase}${pr.target}`;
97
- return pr;
97
+ pc.target = `${targetBase}${pc.target}`;
98
+ return pc;
98
99
  });
99
100
  } else {
100
101
  return [];
package/lib/is-prod.mjs CHANGED
@@ -1,3 +1,5 @@
1
- export default function() {
2
- return (process.env.NODE_ENV === 'production');
3
- }
1
+ import {env} from 'node:process';
2
+
3
+ const isProd = () => env.NODE_ENV === 'production';
4
+
5
+ export default isProd;
@@ -1,14 +1,19 @@
1
- import {deleteAsync} from 'del';
1
+ import {rm} from 'node:fs/promises';
2
+ import {consoleLogColored} from '../utils.mjs';
2
3
 
3
4
  /**
4
5
  * Clean the target directory
5
6
  *
7
+ * @example
8
+ * await clean('target');
9
+ *
6
10
  * @param {(string|string[])} targetPath
7
- * @returns {Promise<string[]>}
11
+ * @returns {Promise<Awaited<void>[]>}
8
12
  */
9
13
  const clean = targetPath => {
10
- console.log('=> clean');
11
- return deleteAsync(targetPath);
14
+ consoleLogColored('=> clean');
15
+ let pa = (Array.isArray(targetPath)) ? targetPath : [targetPath];
16
+ return Promise.all(pa.map(path => rm(path, {force: true, recursive: true})));
12
17
  };
13
18
 
14
19
  export {clean};
@@ -1,15 +1,61 @@
1
- import cpy from 'cpy';
1
+ import {cp} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {glob2Array, consoleLogColored, isDebugMode} from '../utils.mjs';
2
4
 
3
5
  /**
4
6
  * Copy (different) assets
5
7
  *
6
8
  * @param {STPair[]} sourceTargetPairs
7
- * @return {Promise<Awaited<unknown>[]>}
9
+ * @return {Promise<Awaited<Awaited<void>[]>[]>}
8
10
  */
9
11
  const copy = sourceTargetPairs => {
10
- console.log('=> copy');
11
- return Promise.all(sourceTargetPairs.map(pair => cpy(pair.sources, pair.target, pair.opts)));
12
+ consoleLogColored('=> copy');
13
+ const debug = isDebugMode();
14
+ return Promise.all(sourceTargetPairs.map(pair => globCopy(pair.sources, pair.target, pair.opts, debug)));
12
15
  };
13
16
 
14
- export {copy};
17
+ /**
18
+ * Copy files specified as glob pattern(s)
19
+ *
20
+ * @param {string|string[]} globPatterns
21
+ * @param {string} targetRoot
22
+ * @param {CopyTaskOption} opts
23
+ * @param {boolean} verbose
24
+ * @returns {Promise<Awaited<void>[]>}
25
+ */
26
+ const globCopy = async (globPatterns, targetRoot, opts, verbose = false) => {
27
+ /** @type string[] */
28
+ const sources = await glob2Array(globPatterns);
29
+
30
+ return Promise.all(sources.map(async src => {
31
+ const targetPath = opts.flat
32
+ ? path.join(targetRoot, path.basename(src))
33
+ : path.join(targetRoot, genTargetPath(src, opts.levelRemoval));
34
+ if (verbose) {
35
+ consoleLogColored(`copying ${src} -> ${targetPath}`, 'dim');
36
+ }
37
+ return cp(src, targetPath, {recursive: true});
38
+ }));
39
+ };
40
+
41
+ /**
42
+ * Generate the target path (destination) of the file to be copied
43
+ *
44
+ * @example
45
+ * genTargetPath('src/foo/bar/a.txt', 2)
46
+ * // returns 'bar/a.txt', 2 levels of path (src/foo) are removed
47
+ *
48
+ * @param {string} sourcePath
49
+ * @param {number=} levelRemoval How many levels of path to be removed
50
+ * @returns {string}
51
+ */
52
+ const genTargetPath = (sourcePath, levelRemoval = 0) => {
53
+ const pathParts = sourcePath.split(path.sep);
54
+ const pathPartsCount = pathParts.length - 1;
55
+ const removal = levelRemoval ? Math.min(levelRemoval, pathPartsCount) : pathPartsCount;
56
+ const newPathParts = pathParts.slice(removal);
57
+ return newPathParts.join(path.sep);
58
+ };
59
+
60
+ export {copy, genTargetPath};
15
61
  export default copy;
@@ -1,25 +1,22 @@
1
- import {globby} from 'globby';
2
1
  import {readFile} from 'node:fs/promises';
3
2
  import {marked} from 'marked';
4
3
  import {markedHighlight} from 'marked-highlight';
5
4
  import {markedXhtml} from 'marked-xhtml';
6
5
  import hljs from 'highlight.js';
7
6
  import njk from 'nunjucks';
7
+ import {renderPageToString} from '@changke/jstmpl';
8
+ import {createAndWriteToFile, getTargetPathString, glob2Array, consoleLogColored, isDebugMode} from '../utils.mjs';
8
9
 
9
- import {createAndWriteToFile, getTargetPathString} from '../utils.mjs';
10
-
11
- marked.use({
12
- headerIds: false,
13
- mangle: false
14
- },
15
- markedXhtml(),
16
- markedHighlight({
17
- langPrefix: 'hljs language-',
18
- highlight: (code, lang) => {
19
- const language = hljs.getLanguage(lang) ? lang : 'plaintext';
20
- return hljs.highlight(code, {language}).value;
21
- }
22
- }));
10
+ marked.use(
11
+ markedXhtml(),
12
+ markedHighlight({
13
+ langPrefix: 'hljs language-',
14
+ highlight: (code, lang) => {
15
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
16
+ return hljs.highlight(code, {language}).value;
17
+ }
18
+ })
19
+ );
23
20
 
24
21
  const {Environment, FileSystemLoader} = njk;
25
22
 
@@ -39,6 +36,8 @@ const getPageTitle = content => {
39
36
  return t;
40
37
  };
41
38
 
39
+ const mdArticleTemplateFile = 'md.tmpl.mjs'; // TODO: configurable?
40
+
42
41
  /**
43
42
  * Generate docs using markdown and Nunjucks
44
43
  *
@@ -49,8 +48,8 @@ const getPageTitle = content => {
49
48
  * @return {Promise<string[]>}
50
49
  */
51
50
  const markdown = (mdSourcePathRoot, njkPaths, njkGlobals, mdTargetPath) => {
52
- console.log('=> markdown');
53
- return globby(`${mdSourcePathRoot}/**/*.md`).then(docs => {
51
+ consoleLogColored('=> markdown (njk)');
52
+ return glob2Array(`${mdSourcePathRoot}/**/*.md`).then(docs => {
54
53
  const njkEnv = new Environment(new FileSystemLoader(njkPaths, {}));
55
54
  for (const [k, v] of Object.entries(njkGlobals)) {
56
55
  njkEnv.addGlobal(k, v);
@@ -85,5 +84,42 @@ const markdown = (mdSourcePathRoot, njkPaths, njkGlobals, mdTargetPath) => {
85
84
  });
86
85
  };
87
86
 
88
- export {markdown};
87
+ const markdown2 = (mdSourcePathRoot, data, mdTargetPath) => {
88
+ const debug = isDebugMode();
89
+ consoleLogColored('=> markdown2 (jstmpl)');
90
+ return glob2Array(`${mdSourcePathRoot}/**/*.md`).then(docs => {
91
+ return Promise.all(docs.map(async doc => {
92
+ const mdContent = await readFile(doc, {encoding: 'utf8'});
93
+ const mdHtml = marked.parse(mdContent);
94
+ const pageTitle = getPageTitle(mdContent);
95
+ const res = await renderPageToString(`${mdSourcePathRoot}/${mdArticleTemplateFile}`, {
96
+ ...data,
97
+ title: pageTitle,
98
+ content: mdHtml
99
+ });
100
+ const target = getTargetPathString(
101
+ doc,
102
+ mdSourcePathRoot,
103
+ mdTargetPath,
104
+ 'md',
105
+ 'html'
106
+ );
107
+ if (debug) {
108
+ consoleLogColored(`md: ${doc} -> ${target}`, 'dim');
109
+ }
110
+ await createAndWriteToFile(target, res);
111
+ // create a content-only file for RSS generating
112
+ const feedTarget = getTargetPathString(
113
+ doc,
114
+ mdSourcePathRoot,
115
+ mdTargetPath,
116
+ 'md',
117
+ 'txt'
118
+ );
119
+ await createAndWriteToFile(feedTarget, mdHtml);
120
+ }));
121
+ });
122
+ };
123
+
124
+ export {markdown, markdown2};
89
125
  export default markdown;
@@ -1,8 +1,7 @@
1
1
  import njk from 'nunjucks';
2
- import {globby} from 'globby';
3
2
  import {readFile} from 'node:fs/promises';
4
-
5
- import {getTargetPathString, createAndWriteToFile} from '../utils.mjs';
3
+ import {renderPageToString} from '@changke/jstmpl';
4
+ import {getTargetPathString, createAndWriteToFile, glob2Array, consoleLogColored} from '../utils.mjs';
6
5
 
7
6
  const {Environment, FileSystemLoader} = njk;
8
7
 
@@ -16,8 +15,8 @@ const {Environment, FileSystemLoader} = njk;
16
15
  * @return {Promise<string[]>}
17
16
  */
18
17
  const prototype = (prototypeSourcePathRoot, njkPaths, njkGlobals, prototypeTargetPath) => {
19
- console.log('=> prototype');
20
- return globby(`${prototypeSourcePathRoot}/pages/**/*.njk`).then(pages => {
18
+ consoleLogColored('=> prototype (njk)');
19
+ return glob2Array(`${prototypeSourcePathRoot}/pages/**/*.njk`).then(pages => {
21
20
  const njkEnv = new Environment(new FileSystemLoader([prototypeSourcePathRoot].concat(njkPaths), {}));
22
21
  for (const [k, v] of Object.entries(njkGlobals)) {
23
22
  njkEnv.addGlobal(k, v);
@@ -37,5 +36,30 @@ const prototype = (prototypeSourcePathRoot, njkPaths, njkGlobals, prototypeTarge
37
36
  });
38
37
  };
39
38
 
40
- export {prototype};
39
+ /**
40
+ * Generate static pages with JSTmpl
41
+ * @param {string} sourceRoot - Glob pattern for source files e.g. 'src/pages/*.mjs'
42
+ * @param {Object.<string, string>} data
43
+ * @param {string} targetPath
44
+ * @returns {Promise<void>}
45
+ */
46
+ const prototype2 = (sourceRoot, data, targetPath) => {
47
+ consoleLogColored('=> prototype (jstmpl)');
48
+ // src/main/webapp/prototype/pages/index.mjs -> ${targetPath.prototype}/index.html
49
+ return glob2Array(`${sourceRoot}/pages/**/*.mjs`).then(pages => {
50
+ return Promise.all(pages.map(async page => {
51
+ const res = await renderPageToString(page, data);
52
+ const targetFile = getTargetPathString(
53
+ page,
54
+ `${sourceRoot}/pages`,
55
+ targetPath,
56
+ 'mjs',
57
+ page.endsWith('rss/index.mjs') ? 'xml' : 'html'
58
+ );
59
+ await createAndWriteToFile(targetFile, res);
60
+ }));
61
+ });
62
+ };
63
+
64
+ export {prototype, prototype2};
41
65
  export default prototype;
@@ -1,6 +1,6 @@
1
1
  import {build} from 'esbuild';
2
-
3
2
  import isEnvProd from '../is-prod.mjs';
3
+ import {consoleLogColored} from '../utils.mjs';
4
4
 
5
5
  /**
6
6
  * Transpile / bundle JS files using esbuild
@@ -10,12 +10,12 @@ import isEnvProd from '../is-prod.mjs';
10
10
  * @return {Promise}
11
11
  */
12
12
  const esm = (entries, targetPath) => {
13
- console.log('=> scripts');
13
+ consoleLogColored('=> scripts');
14
14
  return build({
15
15
  entryPoints: entries,
16
16
  bundle: true,
17
17
  format: 'esm',
18
- target: 'es2023',
18
+ target: 'es2024',
19
19
  splitting: true,
20
20
  outdir: targetPath,
21
21
  minify: isEnvProd(), // only minify in prod build
@@ -1,8 +1,5 @@
1
- const setEnvDev = () => {
2
- return new Promise(resolve => {
3
- process.env.NODE_ENV = 'development';
4
- resolve();
5
- });
1
+ const setEnvDev = async () => {
2
+ process.env.NODE_ENV = 'development';
6
3
  };
7
4
 
8
5
  export default setEnvDev;
@@ -1,8 +1,5 @@
1
- const setEnvProd = () => {
2
- return new Promise(resolve => {
3
- process.env.NODE_ENV = 'production';
4
- resolve();
5
- });
1
+ const setEnvProd = async () => {
2
+ process.env.NODE_ENV = 'production';
6
3
  };
7
4
 
8
5
  export default setEnvProd;
@@ -1,6 +1,5 @@
1
1
  import {build} from 'esbuild';
2
- import {globby} from 'globby';
3
-
2
+ import {glob2Array, consoleLogColored} from '../utils.mjs';
4
3
  import isEnvProd from '../is-prod.mjs';
5
4
 
6
5
  /**
@@ -12,16 +11,16 @@ import isEnvProd from '../is-prod.mjs';
12
11
  * @return {Promise}
13
12
  */
14
13
  const styles = (sources, targetPath, external = []) => {
15
- console.log('=> styles');
16
- return globby(sources).then(entries => {
14
+ consoleLogColored('=> styles');
15
+ return glob2Array(sources).then(entries => {
17
16
  return build({
18
17
  entryPoints: entries,
19
18
  bundle: true,
20
- target: 'es2021',
19
+ target: 'es2024',
21
20
  outdir: targetPath,
22
21
  sourcemap: !isEnvProd(),
23
22
  minify: isEnvProd(),
24
- external: external
23
+ external
25
24
  });
26
25
  });
27
26
  };
package/lib/typedefs.mjs CHANGED
@@ -14,13 +14,21 @@
14
14
  * @property {string=} markdown Path for markdown files
15
15
  */
16
16
 
17
+ /**
18
+ * Options for "copy" task
19
+ *
20
+ * @typedef {Object} CopyTaskOption
21
+ * @property {number=} levelRemoval
22
+ * @property {boolean=} flat
23
+ */
24
+
17
25
  /**
18
26
  * Source/Target pairs for "copy" task
19
27
  *
20
28
  * @typedef {Object} STPair
21
29
  * @property {(string|string[])} sources
22
30
  * @property {string} target
23
- * @property {Object=} opts
31
+ * @property {CopyTaskOption=} opts
24
32
  */
25
33
 
26
34
  /**
package/lib/utils.mjs CHANGED
@@ -1,5 +1,7 @@
1
- import {access, mkdir, writeFile} from 'node:fs/promises';
2
- import * as path from 'node:path';
1
+ import {access, mkdir, writeFile, glob, rm, stat} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {styleText} from 'node:util';
4
+ import {env} from 'node:process';
3
5
 
4
6
  const getTargetPathString = (sourcePath, sourceRoot, targetRoot, srcExt, tgtExt) => {
5
7
  const baseName = path.basename(sourcePath, `.${srcExt}`);
@@ -17,7 +19,98 @@ const createAndWriteToFile = async (filePath, fileContent) => {
17
19
  await writeFile(filePath, fileContent, {flag: 'w+'});
18
20
  };
19
21
 
22
+ /**
23
+ * Extracts negative patterns into another array to feed Node's glob function
24
+ * @param {(string|string[])} globPatterns
25
+ * @returns {Object.<string, string[]>}
26
+ */
27
+ const processGlobPatterns = globPatterns => {
28
+ // extract negative patterns
29
+ let patterns = globPatterns;
30
+ if (!Array.isArray(globPatterns)) {
31
+ patterns = [globPatterns];
32
+ }
33
+ /** @type string[] */
34
+ const negativePatterns = [];
35
+ /** @type string[] */
36
+ const normalPatterns = patterns.filter(p => {
37
+ if (p.startsWith('!')) {
38
+ negativePatterns.push(p.substring(1));
39
+ return false;
40
+ } else {
41
+ return true;
42
+ }
43
+ });
44
+ return {normalPatterns, negativePatterns};
45
+ };
46
+
47
+ /**
48
+ * Convert `glob()` result to an array
49
+ * @param {(string|string[])} globPatterns
50
+ * @param {boolean} fileOnly
51
+ * @returns {Promise<string[]>}
52
+ */
53
+ const glob2Array = async (globPatterns, fileOnly = false) => {
54
+ const p = processGlobPatterns(globPatterns);
55
+ const m = glob(p.normalPatterns, {exclude: p.negativePatterns});
56
+ if (fileOnly) {
57
+ const files = [];
58
+ return Array.fromAsync(m).then(async entries => {
59
+ for (const entry of entries) {
60
+ const st = await stat(entry);
61
+ if (st.isFile()) {
62
+ files.push(entry);
63
+ }
64
+ }
65
+ return files;
66
+ });
67
+ } else {
68
+ return Array.fromAsync(m);
69
+ }
70
+ };
71
+
72
+ const createDirectory = async (dir, log = false) => {
73
+ try {
74
+ const dirUrl = new URL(dir, import.meta.url);
75
+ const createdDir = await mkdir(dirUrl, {recursive: true});
76
+ if (log) {
77
+ console.log(`Directory created: ${createdDir}`);
78
+ }
79
+ return createdDir;
80
+ } catch (err) {
81
+ console.error(err.message);
82
+ }
83
+ };
84
+
85
+ const removeDirectory = async (dir, log = false) => {
86
+ try {
87
+ await rm(dir, {recursive: true, force: true});
88
+ if (log) {
89
+ console.log(`Directory removed: ${dir}`);
90
+ }
91
+ } catch (err) {
92
+ console.error(err.message);
93
+ }
94
+ };
95
+
96
+ /**
97
+ * Print colored console messages
98
+ * @param {string} msg
99
+ * @param {(string|string[])} color
100
+ */
101
+ const consoleLogColored = (msg, color = 'cyan') => {
102
+ console.log(styleText(color, msg));
103
+ };
104
+
105
+ const isDebugMode = () => (env.SN_BUILD_MODE === 'debug');
106
+
20
107
  export {
21
108
  getTargetPathString,
22
- createAndWriteToFile
109
+ createAndWriteToFile,
110
+ processGlobPatterns,
111
+ glob2Array,
112
+ createDirectory,
113
+ removeDirectory,
114
+ consoleLogColored,
115
+ isDebugMode
23
116
  };
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@changke/staticnext-build",
3
- "version": "0.13.0",
3
+ "version": "0.27.0",
4
4
  "description": "Build scripts extracted from StaticNext seed project",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "lint": "eslint . lib test",
8
- "test": "mocha",
8
+ "test": "node --test 'test/*.test.mjs'",
9
9
  "prepublishOnly": "npm run lint && npm run test"
10
10
  },
11
11
  "engines": {
12
- "node": ">=20"
12
+ "node": ">=22.14.0"
13
13
  },
14
14
  "bin": {
15
15
  "sn-build": "build.mjs"
@@ -19,21 +19,18 @@
19
19
  "license": "ISC",
20
20
  "type": "module",
21
21
  "devDependencies": {
22
- "@eslint/js": "^9.36.0",
23
- "@stylistic/eslint-plugin": "^5.4.0",
24
- "eslint": "^9.36.0",
25
- "globals": "^16.4.0",
26
- "mocha": "^11.7.2"
22
+ "@eslint/js": "^9.39.2",
23
+ "@stylistic/eslint-plugin": "^5.8.0",
24
+ "eslint": "^9.39.2",
25
+ "globals": "^16.5.0"
27
26
  },
28
27
  "dependencies": {
29
- "cpy": "^12.0.1",
30
- "del": "^8.0.1",
31
- "esbuild": "^0.25.10",
32
- "globby": "^14.1.0",
28
+ "@changke/jstmpl": "^0.0.1",
29
+ "esbuild": "^0.27.3",
33
30
  "highlight.js": "^11.11.1",
34
- "marked": "^16.3.0",
35
- "marked-highlight": "^2.2.2",
36
- "marked-xhtml": "^1.0.13",
31
+ "marked": "^17.0.2",
32
+ "marked-highlight": "^2.2.3",
33
+ "marked-xhtml": "^1.0.14",
37
34
  "nunjucks": "^3.2.4"
38
35
  }
39
36
  }
package/eslint.config.mjs DELETED
@@ -1,30 +0,0 @@
1
- import globals from 'globals';
2
- import js from '@eslint/js';
3
- import stylistic from '@stylistic/eslint-plugin';
4
-
5
- export default [
6
- js.configs.recommended,
7
- stylistic.configs.customize({
8
- blockSpacing: false,
9
- braceStyle: '1tbs',
10
- semi: true
11
- }),
12
- {
13
- files: ['**/*.mjs'],
14
- languageOptions: {
15
- globals: {
16
- ...globals.node,
17
- ...globals.mocha
18
- }
19
- },
20
- rules: {
21
- '@stylistic/arrow-parens': ['error', 'as-needed', {requireForBlockBody: false}],
22
- '@stylistic/comma-dangle': ['error', 'never'],
23
- '@stylistic/object-curly-spacing': ['error', 'never'],
24
- '@stylistic/quotes': ['warn', 'single'],
25
- '@stylistic/quote-props': 0,
26
- '@stylistic/semi': ['error', 'always'],
27
- '@stylistic/space-before-function-paren': ['error', {anonymous: 'ignore', asyncArrow: 'always', named: 'never'}]
28
- }
29
- }
30
- ];