diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 9e4f0cd0..6ca60d86 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: - node: [10.x, 12.x, 14.x] + node: [14.x] # Steps represent a sequence of tasks that will be executed as part of the job steps: diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml index 6c6f33a0..9fe9a819 100644 --- a/.github/workflows/mac.yml +++ b/.github/workflows/mac.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: - node: [10.x, 12.x, 14.x] + node: [14.x] # Steps represent a sequence of tasks that will be executed as part of the job steps: diff --git a/core/engine/index.js b/core/engine/index.js index 56018ecf..6dbbffa0 100644 --- a/core/engine/index.js +++ b/core/engine/index.js @@ -7,32 +7,73 @@ const {transform} = require('../transform'); const {getDirectoryFilesDetail} = require('../utils/getDirectoryFilesDetail'); const {File} = require('../File'); const validUrl = require('valid-url'); +const {isObject, isArray, isFunction} = require("util"); +const {mergeObjects} = require("../utils/merge-objects"); const defaultOptions = { staticData: {}, customTags: [], customAttributes: [], env: 'development', + sass: { + indentWidth: 2, + precision: 5, + indentType: 'space', + linefeed: 'lf', + sourceComments: false, + includePaths: [], + functions: {}, + }, + less: { + strictUnits: false, + insecure: false, + paths: [], + math: 1, + urlArgs: '', + modifyVars: null, + lint: false, + }, + stylus: { + paths: [], + }, + postCSS: { + plugins: [] + }, onPageRequest() { } } -const engine = (app, pagesDirectoryPath, opt = defaultOptions) => { +const engine = (app, pagesDirectoryPath, opt = {}) => { if (!app) { throw new Error('engine first argument must be provided and be a valid express app.') } - opt = {...defaultOptions, ...opt} + let hbConfig = {}; - if (typeof opt.staticData !== 'object') { - throw new Error('HTML+ static data must be an javascript object') + try { + hbConfig = require(path.join(process.cwd(), 'hp.config.js')); + } catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') { + e.message = `hp.config.js file loading failed: ${e.message}` + throw new Error(e) + } + } + + opt = {...defaultOptions, ...mergeObjects(hbConfig, opt)}; + + if (!isObject(opt.staticData)) { + throw new Error('HTML+ static data option must be a javascript object') + } + + if (!isArray(opt.customTags)) { + throw new Error('HTML+ custom tags option must be an array of valid tags.') } - if (!Array.isArray(opt.customTags)) { - throw new Error('HTML+ custom tags must be an array of valid tags.') + if (!isArray(opt.customAttributes)) { + throw new Error('HTML+ custom attributes option must be an array of valid attributes.') } - if (typeof opt.onPageRequest !== 'function') { + if (!isFunction(opt.onPageRequest)) { throw new Error('"onPageRequest" option must be a function') } @@ -54,7 +95,7 @@ const engine = (app, pagesDirectoryPath, opt = defaultOptions) => { filePath = filePath.replace(pagesDirectoryPath, ''); const template = `${filePath.replace('.html', '')}`.slice(1); let tempPath = ''; - + if (filePath.endsWith('index.html')) { tempPath = filePath.replace('/index.html', ''); pagesRoutes[tempPath || '/'] = template; @@ -73,11 +114,11 @@ const engine = (app, pagesDirectoryPath, opt = defaultOptions) => { .then(() => { app.engine('html', (filePath, {settings, _locals, cache, ...context}, callback) => { const fileName = path.basename(filePath); - + if (fileName.startsWith('_')) { callback(new Error(`Cannot render partial(${fileName}) file as page. Partial files can only be included.`)); } - + fs.readFile(filePath, (err, content) => { if (err) return callback(err); const file = new File(filePath, settings.views); @@ -92,25 +133,25 @@ const engine = (app, pagesDirectoryPath, opt = defaultOptions) => { partialFiles: partials, onBeforeRender: (node, nodeFile) => { let attrName = ''; - + if (node.tagName === 'link') { attrName = 'href'; } else if (node.tagName === 'script') { attrName = 'src'; } - + const srcPath = node.attributes[attrName]; - + if (srcPath && !validUrl.isUri(srcPath)) { const resourceFullPath = path.resolve(nodeFile.fileDirectoryPath, srcPath); - + if (resourceFullPath.startsWith(pagesDirectoryPath)) { node.setAttribute(attrName, resourceFullPath.replace(pagesDirectoryPath, '')) } } } }) - + callback(null, result); } catch (e) { console.error(e.message); @@ -121,17 +162,17 @@ const engine = (app, pagesDirectoryPath, opt = defaultOptions) => { } }) }); - + app.set('views', pagesDirectoryPath); app.set('view engine', 'html'); - + app.use(pageAndResourcesMiddleware( pagesRoutes, pagesDirectoryPath, opt )); app.use(express.static(pagesDirectoryPath)) - + console.log('HTML+ engine is ready'); }) }; diff --git a/core/engine/page-and-resources-middleware.js b/core/engine/page-and-resources-middleware.js index 9b7615e7..4d8629db 100644 --- a/core/engine/page-and-resources-middleware.js +++ b/core/engine/page-and-resources-middleware.js @@ -17,7 +17,7 @@ const sourcesExtensions = new Set([ ]); const cache = {}; -function pageAndResourcesMiddleware(pagesRoutes, pagesDirectoryPath, {env, onPageRequest}) { +function pageAndResourcesMiddleware(pagesRoutes, pagesDirectoryPath, {env, onPageRequest, sass, less}) { return async (req, res, next) => { if (req.method === 'GET') { const ext = path.extname(req.path); @@ -45,11 +45,11 @@ function pageAndResourcesMiddleware(pagesRoutes, pagesDirectoryPath, {env, onPag switch (ext) { case '.scss': case '.sass': - content = await transformResource.sass({file}); + content = await transformResource.sass({file, ...sass}); content = (await transformResource.css(content, {file, env})).content; break; case '.less': - content = await transformResource.less({file}); + content = await transformResource.less({file, ...less}); content = (await transformResource.css(content, {file, env})).content; break; case '.styl': diff --git a/core/transformers/less.transformer.js b/core/transformers/less.transformer.js index d63a8334..a0e95c9f 100644 --- a/core/transformers/less.transformer.js +++ b/core/transformers/less.transformer.js @@ -5,7 +5,14 @@ const render = promisify(less.render); const defaultOptions = { env: 'development', - file: null + file: null, + strictUnits: false, + insecure: false, + paths: [], + math: 1, + urlArgs: '', + modifyVars: null, + lint: false, } async function lessTransformer(content, opt = defaultOptions) { @@ -22,10 +29,9 @@ async function lessTransformer(content, opt = defaultOptions) { content = content ?? ''; const options = { - ...defaultOptions, - env: opt.env, + ...opt, filename: opt.file?.fileAbsolutePath, - // ...(opt.env === 'development' && {sourceMap: {}}) + ...(opt.env === 'production' && {sourceMap: {}}) } return render(content, options).then(res => { diff --git a/core/transformers/sass.transformer.js b/core/transformers/sass.transformer.js index 8a317ccc..b3a470d8 100644 --- a/core/transformers/sass.transformer.js +++ b/core/transformers/sass.transformer.js @@ -6,6 +6,13 @@ const render = promisify(nodeSass.render); const defaultOptions = { env: 'development', file: null, + indentWidth: 2, + precision: 5, + indentType: 'space', + linefeed: 'lf', + sourceComments: false, + functions: {}, + includePaths: [], } async function sassTransformer(content, opt = defaultOptions) { diff --git a/core/transformers/sass.transformer.spec.js b/core/transformers/sass.transformer.spec.js index cb39bb1c..3166f522 100644 --- a/core/transformers/sass.transformer.spec.js +++ b/core/transformers/sass.transformer.spec.js @@ -87,4 +87,61 @@ describe('sassTransformer', () => { return expect(sassTransformer({})).rejects.toThrowError('If no string content is provided, the "file" option must be provided.') }); + describe('should work with other options', () => { + it('indentWidth', () => { + return sassTransformer(` + body { + background: #edd; + } + `, { + indentWidth: 8 + }).then(res => { + expect(res).toEqual('body {\n' + + ' background: #edd; }\n'); + }) + }); + + it('precision', () => { + return sassTransformer(` + $w: (10 / 3); + body { + width: #{$w}px; + } + `, { + precision: 5 + }).then(res => { + expect(res).toEqual('body {\n' + + ' width: 3.33333px; }\n'); + }) + }); + + it('indentType', () => { + return sassTransformer(` + body { + width: 300px; + } + `, { + indentWidth: 1, + indentType: 'tab' + }).then(res => { + expect(res).toEqual('body {\n' + + '\twidth: 300px; }\n'); + }) + }); + + it('sourceComments', () => { + return sassTransformer(` + body { + width: 300px; + } + `, { + sourceComments: true + }).then(res => { + expect(res).toEqual('/* line 2, stdin */\n' + + 'body {\n' + + ' width: 300px; }\n'); + }) + }); + }); + }); \ No newline at end of file diff --git a/core/transformers/stylus.transformer.js b/core/transformers/stylus.transformer.js index 3a4ce983..16eb4bf9 100644 --- a/core/transformers/stylus.transformer.js +++ b/core/transformers/stylus.transformer.js @@ -1,18 +1,16 @@ const stylus = require('stylus'); -const {promisify} = require('util'); - -const render = promisify(stylus.render); const defaultOptions = { env: 'development', file: null, + paths: [] } async function stylusTransformer(content = null, opt = defaultOptions) { if (content && typeof content === 'object') { opt = content; content = null; - + if (!opt.file) { throw new Error('If no string content is provided, the "file" option must be provided.') } @@ -21,13 +19,23 @@ async function stylusTransformer(content = null, opt = defaultOptions) { opt = {...defaultOptions, ...opt}; content = content ?? ''; - return await render(content, { - filename: opt.file?.fileAbsolutePath, - // ...(opt.env === 'development' && {sourceMap: 'inline'}), - }) - .then(css => { - return css; + return await (new Promise((res, rej) => { + + const styl = stylus(content, { + ...opt, + filename: opt.file?.fileAbsolutePath, + ...(opt.env === 'production' && {sourceMap: 'inline'}), }); + + + styl.render((err, css) => { + if (err) { + return rej(err); + } + + res(css) + }) + })) } module.exports.stylusTransformer = stylusTransformer; diff --git a/core/utils/merge-objects.js b/core/utils/merge-objects.js new file mode 100644 index 00000000..5fcd3255 --- /dev/null +++ b/core/utils/merge-objects.js @@ -0,0 +1,17 @@ +const {isObject, isArray} = require("util"); + +function mergeObjects(a, b) { + if(!isObject(a) || !isObject(b)) return b ?? {}; + + const obj = isArray(a) ? [...a] : a; + + for(const key in b) { + if(b.hasOwnProperty(key)) { + obj[key] = mergeObjects(obj[key], b[key]); + } + } + + return obj; +} + +module.exports.mergeObjects = mergeObjects; diff --git a/core/utils/merge-objects.spec.js b/core/utils/merge-objects.spec.js new file mode 100644 index 00000000..b514fd29 --- /dev/null +++ b/core/utils/merge-objects.spec.js @@ -0,0 +1,32 @@ +const {mergeObjects} = require('./merge-objects'); + +describe('mergeObjects', () => { + it('should return second object if one is null or not an object', () => { + expect(mergeObjects(null, {b: 10})).toEqual({b: 10}) + expect(mergeObjects(12, {b: 10})).toEqual({b: 10}) + expect(mergeObjects('12', {b: 10})).toEqual({b: 10}) + expect(mergeObjects(() => {}, {b: 10})).toEqual({b: 10}) + + expect(mergeObjects({a: 10}, null)).toEqual({}) + }); + + it('should merge arrays', () => { + expect(mergeObjects([1, 2, 4], [1, 9, 0])).toEqual([1, 9, 0]) + expect(mergeObjects([6, 2, 4], [9, 0])).toEqual([9, 0, 4]) + expect(mergeObjects([6, [2, 4]], [9, [0, 9]])).toEqual([9, [0, 9]]) + expect(mergeObjects([6, [2, [3, 1]]], [9, [0, 9]])).toEqual( [9, [0, 9]]) + expect(mergeObjects([6, [2, 6]], [9, [0, [3, 1]]])).toEqual([9, [0, [3, 1]]]) + expect(mergeObjects([6, [2, 6]], [9])).toEqual([9, [2, 6]]) + expect(mergeObjects([6, [2, 6]], [9, [1]])).toEqual([9, [1, 6]]) + }); + + it('should merge object literals', () => { + expect(mergeObjects({a: 12}, {b: 34})).toEqual({a: 12, b: 34}) + expect(mergeObjects({a: 12}, {a: 34})).toEqual({a: 34}) + expect(mergeObjects({a: [12, 67, 10]}, {a: [34, 90]})).toEqual({a: [34, 90, 10]}) + expect(mergeObjects({a: 10}, {a: [34, 90]})).toEqual({a: [34, 90]}) + expect(mergeObjects({a: [34, 90]}, {a: 10})).toEqual({a: 10}) + expect(mergeObjects({a: {b: 100}}, {a: {c: 200, b: 13}})).toEqual({a: {b: 13, c: 200}}) + expect(mergeObjects({a: {c: 200, b: 13}}, {a: {b: 100}})).toEqual({a: {b: 100, c: 200}}) + }); +}); \ No newline at end of file diff --git a/hp.config.js b/hp.config.js new file mode 100644 index 00000000..4d76ce1c --- /dev/null +++ b/hp.config.js @@ -0,0 +1,27 @@ +const homePage = require('./website/data/home.page'); +const documentationPage = require('./website/data/documentation.page'); +const learnPage = require('./website/data/learn.page'); +const site = require('./website/data/site.json'); +const packageJSON = require('./package.json'); +const {CodeSnippet} = require('./website/tags/code-snippet'); + +const env = process.env.NODE_ENV || 'development'; + +module.exports = { + env, + staticData: { + pages: { + documentation: documentationPage, + home: homePage, + learn: learnPage, + }, + site: { + ...site, + version: packageJSON.version, + license: packageJSON.license + } + }, + customTags: [ + CodeSnippet, + ] +} \ No newline at end of file diff --git a/package.json b/package.json index fec6d946..4c88f751 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@beforesemicolon/html-plus", - "version": "0.4.6", + "version": "0.5.0", "description": "HTML Template Engine/Language", "main": "dist/index.js", "files": [ diff --git a/tsconfig.json b/tsconfig.json index 0d015b55..09529c52 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -73,6 +73,7 @@ "exclude": [ "./website", "./docs", + "./hp.config.js", "**/*.spec.js" ] } diff --git a/website/pages/_partials/_layout.html b/website/pages/_partials/_layout.html index 59f434ab..0a0bf113 100644 --- a/website/pages/_partials/_layout.html +++ b/website/pages/_partials/_layout.html @@ -51,5 +51,13 @@ + + \ No newline at end of file diff --git a/website/pages/_partials/docs/_engine-function.html b/website/pages/_partials/docs/_engine-function.html index 914236f8..189c1672 100644 --- a/website/pages/_partials/docs/_engine-function.html +++ b/website/pages/_partials/docs/_engine-function.html @@ -50,11 +50,15 @@

Arguments

{ concept: 'options', type: 'type: object, optional', - description: 'Object with different options to be consumed by the engine.' + description: 'Object with different options to be consumed by the engine. If not provided, the engine will try to find for the hp.config.js file on the project root directory.' }, ] }">

Options

+

Options can be provided directly to the engine function as third argument or you can simply create a hp.config.js +at the root directory and export(using module.exports = {...}) an object with the following properties.

+

If you provide options both, by providing the hp.config.js file and engine options, these are deeply merged + and the engine options will override the hp.config.js file where they match in properties.

Return

-

The engine returns undefined.

+

The engine returns a Promise.

Usage Examples

+

How hp.config.js file may look like. Right now you must always export with module.exports.

+ +// hp.config.js +const homePage = require('./website/data/home.page'); +const aboutPage = require('./website/data/about.page'); +const {CodeSnippet} = require('./website/tags/code-snippet'); +const tailwind = require('tailwindcss'); + +const env = process.env.NODE_ENV || 'development'; + +module.exports = { + env, + staticData: { + pages: { + home: homePage, + about: aboutPage, + }, + }, + customTags: [ + CodeSnippet, + ], + postCSS: [ + plugins: [tailwind] + ] +} + +

Providing static data to the engine.

engine(app, path.resolve(__dirname, './pages'), { staticData: { @@ -97,6 +148,7 @@

Usage Examples

} });
+

Providing a page request handler which inject context data to the templates.

engine(app, path.resolve(__dirname, './pages'), { onPageRequest: (req) => { diff --git a/website/pages/_partials/docs/_getting-started.html b/website/pages/_partials/docs/_getting-started.html index 2a55db97..7b58e652 100644 --- a/website/pages/_partials/docs/_getting-started.html +++ b/website/pages/_partials/docs/_getting-started.html @@ -63,4 +63,32 @@

Server Setup

HTML+ engine will setup your Routes for your pages and all handlers for CSS(including preprocessors), Javascript and Typescript files.

+

Config file

+

The engine function takes an option object as third argument +which you can also provide by creating a hp.config.js file on the root of your project with same properties.

+ +// hp.config.js +const homePage = require('./website/data/home.page'); +const aboutPage = require('./website/data/about.page'); +const {CodeSnippet} = require('./website/tags/code-snippet'); +const tailwind = require('tailwindcss'); + +const env = process.env.NODE_ENV || 'development'; + +module.exports = { + env, + staticData: { + pages: { + home: homePage, + about: aboutPage, + }, + }, + customTags: [ + CodeSnippet, + ], + postCSS: [ + plugins: [tailwind] + ] +}; + \ No newline at end of file diff --git a/website/server-test.js b/website/server-test.js index fd952f00..bfa0a5fc 100644 --- a/website/server-test.js +++ b/website/server-test.js @@ -2,16 +2,10 @@ const express = require('express'); const http = require('http'); const path = require('path'); const {engine} = require('../core/engine'); -const homePage = require('./data/home.page'); const documentationPage = require('./data/documentation.page'); -const learnPage = require('./data/learn.page'); -const {CodeSnippet} = require('./tags/code-snippet'); -const site = require('./data/site.json'); -const packageJSON = require('./../package.json'); const {collectPaths} = require("./data/collect-paths"); const app = express(); -const env = process.env.NODE_ENV || 'development'; const paths = collectPaths(documentationPage.menu.list); (async () => { @@ -34,25 +28,6 @@ const paths = collectPaths(documentationPage.menu.list); }) await engine(app, path.resolve(__dirname, './pages'), { - env, - staticData: { - pages: { - documentation: documentationPage, - home: homePage, - learn: learnPage, - }, - site: { - ...site, - version: packageJSON.version, - license: packageJSON.license - }, - params: {}, - query: {}, - path: '/' - }, - customTags: [ - CodeSnippet, - ], onPageRequest: (req) => { const fullPath = req.path.replace(/(\/|\.html)$/, ''); return {