@maizzle/framework 5.0.0-beta.2 → 5.0.0-beta.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maizzle/framework",
3
- "version": "5.0.0-beta.2",
3
+ "version": "5.0.0-beta.4",
4
4
  "description": "Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,9 +23,10 @@
23
23
  },
24
24
  "scripts": {
25
25
  "dev": "vitest",
26
- "lint": "biome lint ./src ./test",
26
+ "release": "npx np",
27
27
  "pretest": "npm run lint",
28
- "test": "vitest run --coverage"
28
+ "test": "vitest run --coverage",
29
+ "lint": "biome lint ./src ./test"
29
30
  },
30
31
  "repository": {
31
32
  "type": "git",
@@ -47,7 +48,7 @@
47
48
  "html-emails"
48
49
  ],
49
50
  "dependencies": {
50
- "@csstools/css-calc": "^1.2.2",
51
+ "@csstools/css-calc": "^1.2.4",
51
52
  "@maizzle/cli": "next",
52
53
  "cheerio": "^1.0.0-rc.12",
53
54
  "chokidar": "^3.6.0",
@@ -63,11 +64,11 @@
63
64
  "istextorbinary": "^9.5.0",
64
65
  "juice": "^10.0.0",
65
66
  "lodash-es": "^4.17.21",
66
- "morphdom": "^2.7.2",
67
+ "morphdom": "^2.7.3",
67
68
  "ora": "^8.0.1",
68
69
  "pathe": "^1.1.2",
69
- "postcss": "^8.4.38",
70
- "postcss-custom-properties": "^13.3.10",
70
+ "postcss": "^8.4.39",
71
+ "postcss-custom-properties": "^13.3.12",
71
72
  "postcss-import": "^16.1.0",
72
73
  "postcss-safe-parser": "^7.0.0",
73
74
  "posthtml": "^0.16.6",
@@ -87,7 +88,7 @@
87
88
  "pretty": "^2.0.0",
88
89
  "string-remove-widows": "^4.0.22",
89
90
  "string-strip-html": "^13.4.8",
90
- "tailwindcss": "^3.4.4",
91
+ "tailwindcss": "^3.4.5",
91
92
  "ws": "^8.17.0"
92
93
  },
93
94
  "devDependencies": {
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  readFile,
3
3
  writeFile,
4
- copyFile,
5
4
  lstat,
6
5
  mkdir,
7
6
  rm
@@ -12,22 +11,34 @@ import { defu as merge } from 'defu'
12
11
 
13
12
  import get from 'lodash/get.js'
14
13
  import isEmpty from 'lodash-es/isEmpty.js'
15
- import { isBinary } from 'istextorbinary'
16
14
 
17
15
  import ora from 'ora'
18
16
  import pico from 'picocolors'
19
17
  import cliTable from 'cli-table3'
20
18
 
21
19
  import { render } from '../generators/render.js'
22
- import { formatTime } from '../utils/string.js'
23
- import { getColorizedFileSize } from '../utils/node.js'
24
- import { readFileConfig } from '../utils/getConfigByFilePath.js'
20
+
21
+ import {
22
+ formatTime,
23
+ getRootDirectories,
24
+ getFileExtensionsFromPattern,
25
+ } from '../utils/string.js'
26
+
27
+ import {
28
+ getColorizedFileSize,
29
+ copyDirectory,
30
+ } from '../utils/node.js'
31
+
25
32
  import {
26
33
  generatePlaintext,
27
34
  handlePlaintextTags,
28
35
  writePlaintextFile
29
36
  } from '../generators/plaintext.js'
30
37
 
38
+ import { readFileConfig } from '../utils/getConfigByFilePath.js'
39
+
40
+ import { transformers } from '../transformers/index.js'
41
+
31
42
  /**
32
43
  * Compile templates and output to the build directory.
33
44
  * Returns a promise containing an object with files output and the config object.
@@ -72,9 +83,19 @@ export default async (config = {}) => {
72
83
  head: ['File name', 'File size', 'Build time'].map(item => pico.bold(item)),
73
84
  })
74
85
 
75
- // Determine paths of templates to build
76
- const userFilePaths = get(config, 'build.content', 'src/templates/**/*.html')
77
- const templateFolders = Array.isArray(userFilePaths) ? userFilePaths : [userFilePaths]
86
+ /**
87
+ * Determine paths to handle
88
+ *
89
+ * 1. Resolve globs in `build.content` to folders that should be copied over to `build.output.path`
90
+ * 2. Check that templates to be built, actually exist
91
+ */
92
+ const contentPaths = get(config, 'build.content', 'src/templates/**/*.html')
93
+
94
+ // 1. Resolve globs in `build.content` to folders that should be copied over to `build.output.path`
95
+ const rootDirs = await getRootDirectories(contentPaths)
96
+
97
+ // 2. Check that templates to be built, actually exist
98
+ const templateFolders = Array.isArray(contentPaths) ? contentPaths : [contentPaths]
78
99
  const templatePaths = await fg.glob([...new Set(templateFolders)])
79
100
 
80
101
  // If there are no templates to build, throw error
@@ -82,37 +103,44 @@ export default async (config = {}) => {
82
103
  throw new Error(`No templates found in ${pico.inverse(templateFolders)}`)
83
104
  }
84
105
 
85
- const baseDirs = templateFolders.filter(p => !p.startsWith('!')).map(p => {
86
- const parts = p.split('/')
87
- // remove the glob part (e.g., **/*.html):
88
- return parts.filter(part => !part.includes('*')).join('/')
89
- })
90
-
91
106
  /**
92
- * Check for binary files
107
+ * Copy source directories to destination
93
108
  *
94
- * We store paths to binary files in a separate array, because we don't want
95
- * to render them. These files will be treated as static files and will
96
- * be copied directly to the output directory, just like the
97
- * `build.static` folders.
109
+ * Copies each `build.content` path to the `build.output.path` directory.
98
110
  */
99
- const binaryPaths = await fg.glob([...new Set(baseDirs.map(base => `${base}/**/*.*`))])
100
- .then(paths => paths.filter(file => isBinary(file)))
111
+ for await (const rootDir of rootDirs) {
112
+ await copyDirectory(rootDir, buildOutputPath)
113
+ }
101
114
 
102
115
  /**
103
- * Render templates
116
+ * Get a list of files to render, from the output directory
104
117
  *
105
- * Render each template and write the output to the output directory,
106
- * preserving the relative path.
118
+ * Uses all file extensions from non-negated glob paths in `build.content`
119
+ * to determine which files to render from the output directory.
107
120
  */
108
- for await (const templatePath of templatePaths) {
109
- const templateBuildStartTime = Date.now()
121
+ const outputExtensions = new Set()
110
122
 
111
- // Determine the base directory the template belongs to
112
- const baseDir = baseDirs.find(base => templatePath.startsWith(base))
123
+ for (const pattern of contentPaths) {
124
+ outputExtensions.add(...getFileExtensionsFromPattern(pattern))
125
+ }
113
126
 
114
- // Compute the relative path
115
- const relativePath = path.relative(baseDir, templatePath)
127
+ /**
128
+ * Create a list of templates to compile
129
+ */
130
+ const extensions = outputExtensions.size > 1 ? `{${[...outputExtensions].join(',')}}` : 'html'
131
+
132
+ const templatesToCompile = await fg.glob(
133
+ path.join(
134
+ buildOutputPath,
135
+ `**/*.${extensions}`
136
+ )
137
+ )
138
+
139
+ /**
140
+ * Render templates
141
+ */
142
+ for await (const templatePath of templatesToCompile) {
143
+ const templateBuildStartTime = Date.now()
116
144
 
117
145
  /**
118
146
  * Add the current template path to the config
@@ -122,8 +150,6 @@ export default async (config = {}) => {
122
150
  */
123
151
  config.build.current = {
124
152
  path: path.parse(templatePath),
125
- baseDir,
126
- relativePath,
127
153
  }
128
154
 
129
155
  const html = await readFile(templatePath, 'utf8')
@@ -158,8 +184,9 @@ export default async (config = {}) => {
158
184
  * We do this before generating plaintext, so that
159
185
  * any paths will already have been created.
160
186
  */
161
- const outputPathFromConfig = get(rendered.config, 'permalink', path.join(buildOutputPath, relativePath))
187
+ const outputPathFromConfig = get(rendered.config, 'permalink', templatePath)
162
188
  const parsedOutputPath = path.parse(outputPathFromConfig)
189
+ // This keeps original file extension if no output extension is set
163
190
  const extension = get(rendered.config, 'build.output.extension', parsedOutputPath.ext.slice(1))
164
191
  const outputPath = `${parsedOutputPath.dir}/${parsedOutputPath.name}.${extension}`
165
192
 
@@ -174,6 +201,14 @@ export default async (config = {}) => {
174
201
  */
175
202
  await writeFile(outputPath, rendered.html)
176
203
 
204
+ /**
205
+ * Remove original file if its path is different
206
+ * from the final destination path.
207
+ */
208
+ if (outputPath !== templatePath) {
209
+ await rm(templatePath)
210
+ }
211
+
177
212
  /**
178
213
  * Add file to CLI table for build summary logging
179
214
  */
@@ -189,48 +224,30 @@ export default async (config = {}) => {
189
224
  /**
190
225
  * Copy static files
191
226
  *
192
- * Copy binary files that are alongside templates as well as
193
- * files from `build.static`, to the output directory.
194
- *
195
- * TODO: support an array of objects with source and destination, i.e. static: [{ source: 'src/assets', destination: 'assets' }, ...]
227
+ * TODO: support an array of objects with source and destination,
228
+ * i.e. static: [{ source: 'src/assets', destination: 'assets' }, ...]
196
229
  */
197
-
198
- // Copy binary files that are alongside templates
199
- for await (const binaryPath of binaryPaths) {
200
- const relativePath = path.relative(get(config, 'build.current.baseDir'), binaryPath)
201
- const outputPath = path.join(get(config, 'build.output.path'), get(config, 'build.static.destination'), relativePath)
202
-
203
- await mkdir(path.dirname(outputPath), { recursive: true })
204
- await copyFile(binaryPath, outputPath)
205
- }
206
-
207
- // Copy files from `build.static`
208
230
  const staticSourcePaths = await fg.glob([...new Set(get(config, 'build.static.source', []))])
209
- .then(paths => paths.filter(file => isBinary(file)))
210
-
211
- if (!isEmpty(staticSourcePaths)) {
212
- for await (const staticPath of staticSourcePaths) {
213
- const relativePath = path.relative(get(config, 'build.current.baseDir'), staticPath)
214
- const outputPath = path.join(get(config, 'build.output.path'), get(config, 'build.static.destination'), relativePath)
215
231
 
216
- await mkdir(path.dirname(outputPath), { recursive: true })
217
- await copyFile(staticPath, outputPath)
218
- }
232
+ for await (const rootDir of getRootDirectories(staticSourcePaths)) {
233
+ await copyDirectory(rootDir, path.join(buildOutputPath, get(config, 'build.static.destination')))
219
234
  }
220
235
 
221
- const compiledFiles = await fg.glob(path.join(config.build.output.path, '**/*'))
236
+ const compiledFiles = await fg.glob(path.join(buildOutputPath, '**/*'))
222
237
 
223
238
  /**
224
239
  * Run `afterBuild` event
225
240
  */
226
241
  if (typeof config.afterBuild === 'function') {
227
- await config.afterBuild({ files: compiledFiles, config, render })
242
+ await config.afterBuild({
243
+ config,
244
+ files: compiledFiles,
245
+ transform: transformers,
246
+ })
228
247
  }
229
248
 
230
249
  /**
231
250
  * Log a build summary if enabled in the config
232
- *
233
- * Need to first clear the spinner
234
251
  */
235
252
 
236
253
  spinner.clear()
@@ -134,7 +134,7 @@ export async function generatePlaintext(html = '', config = {}) {
134
134
  ).result
135
135
  }
136
136
 
137
- export async function writePlaintextFile(plaintext = '', templateConfig = {}) {
137
+ export async function writePlaintextFile(plaintext = '', config = {}) {
138
138
  if (!plaintext) {
139
139
  throw new Error('Missing plaintext content.')
140
140
  }
@@ -149,8 +149,8 @@ export async function writePlaintextFile(plaintext = '', templateConfig = {}) {
149
149
  * Fall back to template's build output path and extension, for example:
150
150
  * `config.build.output.path`
151
151
  */
152
- const plaintextConfig = get(templateConfig, 'plaintext')
153
- let plaintextOutputPath = get(plaintextConfig, 'output.path', get(templateConfig, 'build.output.path'))
152
+ const plaintextConfig = get(config, 'plaintext')
153
+ let plaintextOutputPath = get(plaintextConfig, 'output.path', get(config, 'build.output.path'))
154
154
  const plaintextExtension = get(plaintextConfig, 'output.extension', 'txt')
155
155
 
156
156
  /**
@@ -158,15 +158,12 @@ export async function writePlaintextFile(plaintext = '', templateConfig = {}) {
158
158
  */
159
159
  if (plaintextConfig === true) {
160
160
  // If the template has a `permalink` key set in the FM
161
- if (typeof templateConfig.permalink === 'string') {
161
+ if (typeof config.permalink === 'string') {
162
162
  // Output plaintext at the `permalink` path
163
- plaintextOutputPath = templateConfig.permalink
163
+ plaintextOutputPath = config.permalink
164
164
  } else {
165
165
  // Output plaintext at the same directory as the HTML file
166
- plaintextOutputPath = path.join(
167
- get(templateConfig, 'build.output.path'),
168
- get(templateConfig, 'build.current.relativePath')
169
- )
166
+ plaintextOutputPath = get(config, 'build.output.path')
170
167
  }
171
168
  }
172
169
 
@@ -186,8 +183,8 @@ export async function writePlaintextFile(plaintext = '', templateConfig = {}) {
186
183
  */
187
184
  if (path.extname(plaintextOutputPath)) {
188
185
  // Ensure the target directory exists
189
- await lstat(path.dirname(plaintextOutputPath)).catch(async () => {
190
- await mkdir(path.dirname(plaintextOutputPath), { recursive: true })
186
+ await lstat(plaintextOutputPath).catch(async () => {
187
+ await mkdir(plaintextOutputPath, { recursive: true })
191
188
  })
192
189
 
193
190
  // Ensure correct extension is used
@@ -196,17 +193,19 @@ export async function writePlaintextFile(plaintext = '', templateConfig = {}) {
196
193
  path.basename(plaintextOutputPath, path.extname(plaintextOutputPath)) + '.' + plaintextExtension
197
194
  )
198
195
 
196
+ console.log('plaintextOutputPath', plaintextOutputPath);
197
+
199
198
  return writeFile(plaintextOutputPath, plaintext)
200
199
  }
201
200
 
202
201
  /**
203
202
  * If `plaintextOutputPath` is a directory path, output file there, using the template's name
204
203
  */
205
- const templateFileName = get(templateConfig, 'build.current.path.name')
204
+ const templateFileName = get(config, 'build.current.path.name')
206
205
 
207
206
  plaintextOutputPath = path.join(
208
- plaintextOutputPath,
209
- get(templateConfig, 'build.current.path.dir'),
207
+ path.dirname(plaintextOutputPath),
208
+ get(config, 'build.current.path.dir'),
210
209
  templateFileName + '.' + plaintextExtension
211
210
  )
212
211
 
@@ -5,7 +5,7 @@ import { defu as merge } from 'defu'
5
5
  import expressions from 'posthtml-expressions'
6
6
  import { parseFrontMatter } from '../utils/node.js'
7
7
  import { process as compilePostHTML } from '../posthtml/index.js'
8
- import { run as useTransformers } from '../transformers/index.js'
8
+ import { run as useTransformers, transformers } from '../transformers/index.js'
9
9
 
10
10
  export async function render(html = '', config = {}) {
11
11
  if (typeof html !== 'string') {
@@ -65,7 +65,8 @@ export async function render(html = '', config = {}) {
65
65
  content = await templateConfig.beforeRender(({
66
66
  html: content,
67
67
  config: templateConfig,
68
- render
68
+ posthtml: compilePostHTML,
69
+ transform: transformers,
69
70
  })) ?? content
70
71
  }
71
72
 
@@ -85,7 +86,8 @@ export async function render(html = '', config = {}) {
85
86
  compiled.html = await templateConfig.afterRender(({
86
87
  html: compiled.html,
87
88
  config: templateConfig,
88
- render
89
+ posthtml: compilePostHTML,
90
+ transform: transformers,
89
91
  })) ?? compiled.html
90
92
  }
91
93
 
@@ -117,7 +119,8 @@ export async function render(html = '', config = {}) {
117
119
  compiled.html = await templateConfig.afterTransformers(({
118
120
  html: compiled.html,
119
121
  config: templateConfig,
120
- render
122
+ posthtml: compilePostHTML,
123
+ transform: transformers,
121
124
  })) ?? compiled.html
122
125
  }
123
126
 
@@ -56,9 +56,9 @@ export async function run(html = '', config = {}) {
56
56
  * Rewrite Tailwind CSS class names to email-safe alternatives,
57
57
  * unless explicitly disabled
58
58
  */
59
- if (get(config, 'css.safeClassNames') !== false) {
59
+ if (get(config, 'css.safe') !== false) {
60
60
  posthtmlPlugins.push(
61
- safeClassNames(get(config, 'css.safeClassNames', {}))
61
+ safeClassNames(get(config, 'css.safe', {}))
62
62
  )
63
63
  }
64
64
 
@@ -267,3 +267,23 @@ export async function run(html = '', config = {}) {
267
267
  html: result.html,
268
268
  }))
269
269
  }
270
+
271
+ export const transformers = {
272
+ comb,
273
+ sixHex,
274
+ minify,
275
+ baseUrl,
276
+ inlineCSS,
277
+ prettify,
278
+ filters,
279
+ markdown,
280
+ posthtmlMso,
281
+ shorthandCss,
282
+ preventWidows,
283
+ addAttributes,
284
+ urlParameters,
285
+ safeClassNames,
286
+ replaceStrings,
287
+ attributeToStyle,
288
+ removeAttributes,
289
+ }
@@ -116,7 +116,7 @@ export async function inline(html = '', options = {}) {
116
116
  rule.walkDecls(decl => {
117
117
  // Resolve calc() values to static values
118
118
  if (options.resolveCalc) {
119
- decl.value = decl.value.includes('calc(') ? calc(decl.value) : decl.value
119
+ decl.value = decl.value.includes('calc(') ? calc(decl.value, {precision: 2}) : decl.value
120
120
  }
121
121
 
122
122
  declarations.add(decl)
@@ -182,7 +182,7 @@ export async function inline(html = '', options = {}) {
182
182
  let [property, value] = i.split(':').map(i => i.trim())
183
183
 
184
184
  if (value && options.resolveCalc) {
185
- value = value.includes('calc') ? calc(value) : value
185
+ value = value.includes('calc') ? calc(value, {precision: 2}) : value
186
186
  }
187
187
 
188
188
  if (value && options.preferUnitlessValues) {
package/src/utils/node.js CHANGED
@@ -1,7 +1,13 @@
1
+ import path from 'pathe'
1
2
  import os from 'node:os'
2
3
  import gm from 'gray-matter'
3
4
  import pico from 'picocolors'
4
5
  import { humanFileSize } from './string.js'
6
+ import {
7
+ copyFile,
8
+ mkdir,
9
+ readdir
10
+ } from 'node:fs/promises'
5
11
 
6
12
  // Return a local IP address
7
13
  export function getLocalIP() {
@@ -66,3 +72,20 @@ export function parseFrontMatter(html) {
66
72
  const { content, data, matter, stringify } = gm(html, {})
67
73
  return { content, data, matter, stringify }
68
74
  }
75
+
76
+ export async function copyDirectory(src, dest) {
77
+ await mkdir(dest, { recursive: true })
78
+
79
+ const entries = await readdir(src, { withFileTypes: true })
80
+
81
+ for (const entry of entries) {
82
+ const srcPath = path.join(src, entry.name)
83
+ const destPath = path.join(dest, entry.name)
84
+
85
+ if (entry.isDirectory()) {
86
+ await copyDirectory(srcPath, destPath) // Recursively copy subdirectories
87
+ } else {
88
+ await copyFile(srcPath, destPath) // Copy files
89
+ }
90
+ }
91
+ }
@@ -115,3 +115,49 @@ export function humanFileSize(bytes, si=false, dp=2) {
115
115
 
116
116
  return bytes.toFixed(dp) + ' ' + units[u]
117
117
  }
118
+
119
+ /**
120
+ * Get the root directories from a list of globs.
121
+ * @param {*} globs
122
+ * @returns
123
+ */
124
+ export function getRootDirectories(globs) {
125
+ globs = Array.isArray(globs) ? globs : [globs]
126
+
127
+ const positiveGlobs = new Set(globs.filter(g => !g.startsWith('!')))
128
+
129
+ const rootDirs = new Set()
130
+
131
+ for (const pattern of positiveGlobs) {
132
+ rootDirs.add(pattern.split('/').slice(0, pattern.split('/').indexOf('**')).join('/'))
133
+ }
134
+
135
+ return Array.from(rootDirs)
136
+ }
137
+
138
+ /**
139
+ * Get the file extensions from a glob pattern.
140
+ * @param {*} pattern
141
+ * @returns
142
+ */
143
+ export function getFileExtensionsFromPattern(pattern) {
144
+ const starExtPattern = /\.([^\*\{\}]+)$/ // Matches .ext but not .* or .{ext}
145
+ const bracePattern = /\.{([^}]+)}$/ // Matches .{ext} or .{ext,ext}
146
+ const wildcardPattern = /\.\*$/ // Matches .*
147
+
148
+ if (wildcardPattern.test(pattern)) {
149
+ return ['html'] // We default to 'html' if the pattern is a wildcard
150
+ }
151
+
152
+ const braceMatch = pattern.match(bracePattern);
153
+ if (braceMatch) {
154
+ return braceMatch[1].split(',') // Split and return extensions inside braces
155
+ }
156
+
157
+ const starExtMatch = pattern.match(starExtPattern)
158
+ if (starExtMatch) {
159
+ return [starExtMatch[1]] // Return single extension
160
+ }
161
+
162
+ return ['html'] // No recognizable extension pattern, default to 'html'
163
+ }
package/types/build.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import ComponentsConfig from './components';
2
2
  import type { SpinnerName } from 'cli-spinners';
3
+ import type ExpressionsConfig from './expressions';
3
4
 
4
5
  export default interface BuildConfig {
5
6
  /**
@@ -8,7 +9,7 @@ export default interface BuildConfig {
8
9
  components?: ComponentsConfig;
9
10
 
10
11
  /**
11
- * Directory where Maizzle should look for Templates to compile.
12
+ * Paths where Maizzle should look for Templates to compile.
12
13
  *
13
14
  * @default ['src/templates/**\/*.html']
14
15
  *
@@ -16,12 +17,17 @@ export default interface BuildConfig {
16
17
  * ```
17
18
  * export default {
18
19
  * build: {
19
- * files: ['src/templates/**\/*.html']
20
+ * content: ['src/templates/**\/*.html']
20
21
  * }
21
22
  * }
22
23
  * ```
23
24
  */
24
- files?: string | string[];
25
+ content?: string | string[];
26
+
27
+ /**
28
+ Configure expressions.
29
+ */
30
+ expressions?: ExpressionsConfig;
25
31
 
26
32
  /**
27
33
  * Define the output path for compiled Templates, and what file extension they should use.
package/types/config.d.ts CHANGED
@@ -136,7 +136,7 @@ export default interface Config {
136
136
  * ```
137
137
  * export default {
138
138
  * css: {
139
- * safeClassNames: {
139
+ * safe: {
140
140
  * ':': '__',
141
141
  * '!': 'i-',
142
142
  * }
@@ -144,7 +144,7 @@ export default interface Config {
144
144
  * }
145
145
  * ```
146
146
  */
147
- safeClassNames?: boolean | Record<string, string>;
147
+ safe?: boolean | Record<string, string>;
148
148
 
149
149
  /**
150
150
  * Ensure that all your HEX colors inside `bgcolor` and `color` attributes are defined with six digits.