├── .editorconfig ├── .gitattributes ├── .github ├── baker.jpg ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── bake.js ├── example-simple ├── _data │ └── titles.json ├── _layouts │ └── template.html ├── baker.config.js ├── index.html ├── other.html ├── scripts │ ├── Paragraph.svelte │ ├── app.js │ ├── sectors.csv │ └── utils.ts └── styles │ └── main.scss ├── example ├── .env ├── _data │ └── meta.json ├── _layouts │ ├── base.njk │ ├── extra.njk │ ├── header.njk │ ├── object.json.njk │ ├── object.njk │ ├── robots.txt.njk │ └── sitemap.xml.njk ├── assets │ ├── demo.json │ ├── fonts │ │ ├── benton-gothic-regular.woff │ │ └── benton-gothic-regular.woff2 │ └── images │ │ ├── corgi-copy.JPG │ │ └── corgi.jpg ├── baker.config.js ├── embeds │ ├── embed_example.html │ └── embed_example_2.html ├── index.njk ├── scripts │ ├── Inner.svelte │ ├── Other.svelte │ ├── app.js │ ├── app.svelte │ ├── cdcr.csv │ ├── cdcr.json │ ├── client.ts │ ├── global.d.ts │ ├── nested │ │ └── internal.js │ └── woo │ │ └── app.js ├── styles │ ├── _type.scss │ ├── _variables.scss │ └── main.scss ├── three │ └── four.njk ├── tsconfig.json └── two.njk ├── lib ├── blocks │ ├── inject.js │ ├── script.js │ └── static.js ├── engines │ ├── assets.js │ ├── base.js │ ├── nunjucks.js │ ├── rollup.js │ └── sass.js ├── env.js ├── filters │ ├── date.js │ ├── json-script.js │ └── log.js ├── index.js ├── paths.js ├── polyfills │ └── dynamic-import.js ├── rollup-plugins │ ├── css-plugin.js │ ├── data-plugin.js │ ├── dataset-plugin.js │ └── prepend-entry.js ├── utils.js └── vendor │ └── dlv.js ├── package-lock.json ├── package.json ├── patches └── mini-sync+0.3.0.patch └── svelte.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.py] 12 | indent_size = 4 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/baker.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadesk/baker/59c185c8de06c89a4ab979789c2fb2eae44a653e/.github/baker.jpg -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '41 16 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test builds 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node: ['14', '16', '18', '20', '21'] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: ${{ matrix.node }} 17 | cache: npm 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm run build:simple 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env.test 67 | 68 | # parcel-bundler cache (https://parceljs.org/) 69 | .cache 70 | 71 | # next.js build output 72 | .next 73 | 74 | # nuxt.js build output 75 | .nuxt 76 | 77 | # vuepress build output 78 | .vuepress/dist 79 | 80 | # Serverless directories 81 | .serverless/ 82 | 83 | # FuseBox cache 84 | .fusebox/ 85 | 86 | # DynamoDB Local files 87 | .dynamodb/ 88 | 89 | # example directories to ignore 90 | example/_dist 91 | example/_screenshot 92 | example/_fallbacks 93 | example/.DS_Store 94 | example-simple/_dist 95 | example-simple/.DS_Store 96 | 97 | # IntelliJ 98 | .idea 99 | 100 | .DS_Store 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.47.7] - 2024-02-13 9 | 10 | ### Added 11 | - Strict timestamp check to fallback image uploads and updated fallback upload step in deploy workflow 12 | 13 | ## [0.47.6] - 2025-01-15 14 | 15 | ### Changed 16 | - Adjusted viewport settings to dynamically fix content height and add 20px bottom padding for fallback images 17 | 18 | ## [0.47.5] - 2024-12-05 19 | - Patched mini-sync to run on 127.0.0.1 and added option `port` option to baker config 20 | 21 | ## [0.47.4] - 2024-11-07 22 | - Updated manifest to correctly source assets in link tags, removing incorrect file references 23 | 24 | ## [0.47.3] - 2024-11-05 25 | - Fix fallback images generation 26 | 27 | ## [0.47.2] - 2024-10-31 28 | - Set `deviceScaleFactor` to 2 for improved screenshot resolution. 29 | 30 | ## [0.47.1] - 2024-10-11 31 | - Added a fixed width (`FIXED_FALLBACK_SCREENSHOT_WIDTH`) to generate mobile-friendly fallback screenshot images 32 | 33 | ## [0.47.0] - 2024-10-09 34 | - Added `screenshot` task to baker for fallback image generation 35 | 36 | ## [0.46.1] - 2024-10-02 37 | - Removed `webHookUrl` from baker.config 38 | 39 | ## [0.46.0] - 2024-08-30 40 | - `prepareCrosswalk` deprecated from baker config in favor of built-in crosswalk data handling. 41 | - Remove `staticAbsoluteBlock`. All static assets use absolute paths now 42 | - Audio file (`.mp3`) is now recognized by `AssetsEngine` and will be included in any hashing. 43 | - Extended Image files to include (`.webp`, `.avif`). 44 | - Added `webHookUrl` option in baker.config 45 | 46 | ## [0.45.0] - 2024-02-03 47 | - Added watcher for changes to `createPages` templates 48 | - Updated dependencies 49 | 50 | ## [0.44.1] - 2023-04-07 51 | - Updated dependencies 52 | 53 | ## [0.44.0] - 2022-12-10 54 | - Added support for rending API endpoints in JSON, sitemaps.xml and robots.txt 55 | - Added `createPages` to the test routine 56 | - Updated dependencies 57 | 58 | ## [0.41.0] - 2022-11-09 59 | 60 | ### Changed 61 | - Updated dependencies 62 | 63 | ## [0.40.0] - 2021-12-04 64 | 65 | ### Changed 66 | - Updated dependencies 67 | 68 | ## [0.39.0] - 2021-11-23 69 | 70 | ### Changed 71 | - Refactored the package to comply with [ES module](https://nodejs.org/api/packages.html#determining-module-system) standards 72 | - Updated dependencies 73 | 74 | ## [0.38.0] - 2021-11-16 75 | 76 | ### Added 77 | - New `nunjucksVariables` config option to set global variables for all templates. 78 | 79 | ### Changed 80 | - Updated dependencies 81 | 82 | ## [0.37.0] - 2021-11-10 83 | 84 | ### Added 85 | - New `NOW` global variable with the current timestamp returned by `new Date()` 86 | 87 | ### Changed 88 | - Updated dependencies 89 | 90 | ## [0.36.0] - 2021-10-30 91 | 92 | ### Added 93 | - Added new `minifyOptions` option to allow users to override the default configuration 94 | 95 | ### Changed 96 | - Updated dependencies 97 | 98 | ## [0.34.0] - 2021-06-23 99 | 100 | ### Changed 101 | 102 | - The Nunjucks engine now uses `FileSystemLoader` for `node_modules` imports (very similar to how Sass engine works via `includePaths`), instead of deferring to `NodeResolveLoader`. That loader is not nearly as useful when `npm` installed packages use the new [`"export": { ... }` format](https://nodejs.org/api/packages.html#packages_exports). Unless packages explicitly declare the non-JS exports you're unable to find them. I don't think we were using that anyway, so it's NBD. 103 | 104 | ## [0.33.0] - 2021-05-05 105 | 106 | ### Added 107 | 108 | - It is now possible to pass compiler options to Svelte via `svelteCompilerOptions` in `baker.config.js`. Good for the rare case of when you need to render them as hydratable. 109 | 110 | ## [0.32.1] - 2021-04-27 111 | 112 | ### Fixed 113 | 114 | - Our custom Rollup `datasetPlugin` and `dataPlugin` have been moved to before `@rollup/plugin-node-resolve` in the Rollup plugin list. In some cases `nodeResolve` would misinterpret the `*:` prefix and blow up the path before these plugins got a chance to do it first. 115 | 116 | ## [0.32.0] - 2021-04-14 117 | 118 | ### Changed 119 | - The `{% static %}` tag will now pass through full URLs as-is when used as the parameter. This lets developers not have to worry about whether a path is project relative or not in loops, and allows templates that work with files to easily account for local and remote files. 120 | 121 | ## [0.31.2] - 2021-02-22 122 | 123 | ### Added 124 | - Add `preventAssignment: true` to `@rollup/plugin-replace` options. 125 | 126 | ### Fixed 127 | - Make sure `process.exit(1)` is called when builds fail. 128 | ## [0.31.1] - 2021-01-31 129 | 130 | ### Fixed 131 | 132 | - Pathing for Svelte CSS also needs to account for path prefixes or else it will be resolved incorrectly in HTML that's not at the root. 133 | 134 | ## [0.31.0] - 2021-01-22 135 | 136 | ### Added 137 | 138 | - Thanks to `@web/rollup-plugin-import-meta-assets` it's now possible to import paths to files within JavaScript and have that be enough to ensure that the file is added to the build. This is _yet another_ method for loading data in baker projects, and likely the best one yet. 139 | 140 | ```js 141 | // Rollup will see this and understand it should add this file to your build 142 | const url = new URL('./data/cities.json', import.meta.url); 143 | 144 | // load it and go! 145 | const data = await d3.json(url); 146 | ``` 147 | 148 | ### Changed 149 | 150 | - The Nunjucks environment is now allowed to cache templates in production mode. Probably won't change much speed wise, but ever so slightly more efficient. 151 | 152 | ## [0.30.0] - 2021-01-18 153 | 154 | ### Added 155 | 156 | A new experimental custom Rollup plugin has been added that provides an optimized method for importing data files in JavaScript. If a JSON, CSV, or TSV file is imported using the prefix `dataset:*` it will be added to the bundle either directly as an Object or Array literal (if under 10K in size) or rendered as a string within a `JSON.parse` call. 157 | 158 | It has been documented that [parsing a string within `JSON.parse` is much, much faster](https://v8.dev/blog/cost-of-javascript-2019#json) on average than directly passing in JavaScript, and typically this is the very first step when data is being loaded manually (with `d3-fetch`'s `json` or `csv` functions, etc.). This makes it possible to import (or even better — dynamically import) data without having to deploy it as a file or inject it into HTML to be parsed. 159 | 160 | An example of how to use it: 161 | 162 | ```js 163 | import data from 'dataset:./assets/data.json'; 164 | // or dynamically 165 | const data = await import('dataset:./assets/data.json'); 166 | ``` 167 | 168 | ## [0.29.0] - 2021-01-13 169 | 170 | ### Added 171 | 172 | - CSS within Svelte components is now **supported**. This means any CSS that's written within Svelte components will be included in the `{% script %}` entrypoint bundle that is added to a page. 173 | - Additional variables are now available on the `page` template context object (previously `current_page`) - in addition to `page.absoluteUrl`, `page.url` represents the project-relative URL. `page.inputPath` represents the project-relative path to the original input template, and `page.outputPath` represents the project-relative output path. 174 | 175 | ### Changed 176 | 177 | - The file watcher logic is now a little smarter and keeps track of dependencies directly in the engines (except for Rollup, which manages this itself). This is a small step toward having a much richer dependency graph for builds. 178 | - The `current_page` template context object has been renamed to `page`. `current_page` however will continue to exist until `1.0`. 179 | 180 | ## [0.28.0] - 2020-12-30 181 | 182 | ### Added 183 | 184 | - It's now possible to supply custom tags (`{% custom variable1, variable2 %}`) to Nunjucks via the `baker.config.js` file. It is very similar to how you add custom filters. 185 | 186 | How to add one: 187 | 188 | ```js 189 | // baker.config.js 190 | module.exports = { 191 | // ... 192 | nunjucksTags: { 193 | author(firstName, lastName) { 194 | return `

By ${firstName} ${lastName}

`; 195 | }, 196 | }, 197 | }; 198 | ``` 199 | 200 | How to use one: 201 | 202 | ```html 203 | {% author "Arthur", "Barker" %} 204 | ``` 205 | 206 | > Heads up! Nunjucks **requires** a comma between arguments. 207 | 208 | And the output: 209 | 210 | ```html 211 |

By Arthur Barker

212 | ``` 213 | 214 | ### Changed 215 | 216 | - Async nunjucks tags now _must_ return a Promise. This abstracts away some of Nunjucks' warts and the expectation of a callback to enable async tags. 217 | - Because the built-in `inject` tag was async it now returns a Promise to match the new interface. 218 | 219 | ## [0.27.1] - 2020-12-14 220 | 221 | ### Added 222 | 223 | - Because users of `baker.config.js` no longer have access to the Baker instance, the function that resolves static files is now also available on the Nunjucks instance at `getStaticPath`. 224 | 225 | ## [0.27.0] - 2020-12-14 226 | 227 | ### Changed 228 | 229 | - A behind-the-scenes change, but the custom Rollup plugin for injecting imports into entrypoints has been replaced. This rids Baker of a bug that surfaces when attempting to use dynamic imports. 230 | 231 | ## [0.26.0] - 2020-10-05 232 | 233 | ### Added 234 | 235 | - Added TypeScript support to Svelte files via `svelte-preprocess`. 236 | - Added an exported `svelte.config.js` file so templates and other tools that need to mirror Baker's `svelte-preprocess` options can do so easily. 237 | 238 | ### Changed 239 | 240 | - Moved to `premove` from `rimraf` for directory-emptying tasks. 241 | 242 | ## [0.25.0] - 2020-08-14 243 | 244 | ### Added 245 | 246 | - The `jsonScript` filter is now built-in to Baker's Nunjucks' environment. [Much like the Django version](https://docs.djangoproject.com/en/dev/ref/templates/builtins/#json-script), this filter safely outputs an object as JSON, wrapped in a 260 | ``` 261 | 262 | - Attempting to use a JavaScript entrypoint that does not exist because compiling failed or because it was not configured as an entrypoint will now throw a more explicit (and helpful) error. 263 | 264 | - It's now possible to use ESM imports/exports when writing the `baker.config.js` file. Hopefully this will make context switching less annoying - before it was the only user-facing JavaScript file that required CommonJS syntax. 265 | 266 | ### Changed 267 | 268 | - The `static`, `staticabsolute` and `inject` blocks will now always throw an error if a valid file cannot be found. Previously it would silently (and intentionally) fail so a missing file wasn't the end of the world while in development. Maybe this will be too drastic of a change but we'll have to see. Too often folks have a silent failure in development and don't realize it until their build fails in production. 269 | 270 | - Nunjucks blocks and filters have been reorganized within Baker. Nothing user-facing should be altered by this. 271 | 272 | ## [0.24.1] - 2020-08-11 273 | 274 | ### Fixed 275 | 276 | - Bumped `mini-sync` to 0.3.0 to catch a downstream change to do what we already thought was happening - all files served by the dev server should be receiving explicit `no-cache` Cache Control headers. 277 | 278 | ## [0.24.0] - 2020-08-03 279 | 280 | ### Changed 281 | 282 | - Custom Nunjucks filters now have a reference to the current instance of the Nunjucks engine available at `this`. Most filters will never need this, but we have a few cases where filters were hacking access in and we don't wanna break everything. 283 | 284 | ### Fixed 285 | 286 | - The Nunjucks custom `log` filter now returns the input value so Nunjucks will not complain about a null or undefined output. 287 | 288 | ## [0.23.0] - 2020-08-03 289 | 290 | ### Added 291 | 292 | - It's now possible to use a configuration file to pass options to Baker when it is ran using the CLI. This is the **preferred** method for using Baker. 293 | 294 | By default the CLI tool looks for a file called `baker.config.js` in the `input` directory when the `--config` (or `-c`) paramter is passed bare. You can also pass a path to a configuration file if you've given it another name or put it in another directory. If you _do not_ pass `--config` it will use the defaults instead and any other parameters you pass. 295 | 296 | ```sh 297 | # will look for "baker.config.js" in the current directory 298 | baker bake --config 299 | 300 | # will load the config in "my-config.js" in the current directory 301 | baker bake --config my-config.js 302 | 303 | # will not use any config *at all* even if it exists, and use all default options other than "input" 304 | baker bake --input my-project-directory 305 | ``` 306 | 307 | The configuration file should export an `object` off of `module.exports`. All options are optional, and Baker will still use the smart defaults the previous iteration of the CLI used. Only set things you want to explicitly change/add! 308 | 309 | ```js 310 | // baker.config.js 311 | module.exports = { 312 | // a custom domain for all resolved URLs 313 | domain: 'our-news-domain', 314 | 315 | // we want to use the static root feature, so we supply the path 316 | staticRoot: '/static/', 317 | 318 | // use createPages to generate pages on the fly 319 | createPages(createPage, data) { 320 | for (const title of data.titles) { 321 | createPage('template.html', `${title}.html`, { 322 | context: { title }, 323 | }); 324 | } 325 | }, 326 | 327 | // pass an object of filters to add to Nunjucks 328 | nunjucksFilters: { 329 | square(n) { 330 | n = +n; 331 | 332 | return n * n; 333 | }, 334 | }, 335 | }; 336 | ``` 337 | 338 | - It's now possible to add new Nunjucks filters using the `nunjucksFilters` configuration method. While it was always technically possible to add new filters before by reaching into Baker's instance of Nunjucks, this is a more user-friendly option that is available via the new config file method. 339 | 340 | `nunjucksFilters` should be an object, where each key is the name of the filter, and the key's value is the function to call when the filter is used. 341 | 342 | ```js 343 | // baker.config.js 344 | module.exports = { 345 | nunjucksFilters: { 346 | square(value) { 347 | const n = +value; 348 | 349 | return n * n; 350 | }, 351 | }, 352 | }; 353 | ``` 354 | 355 | ```html 356 | {% set value = 5 %} 357 | ``` 358 | 359 | ```html 360 | {{ value|square }} // 25 361 | ``` 362 | 363 | - New `log` filter in Nunjucks templates that allows you to log any variable or value to the terminal's console. 364 | 365 | ```html 366 | {{ value|log }} // this variable should log in your terminal 367 | ``` 368 | 369 | ### Changed 370 | 371 | - Templates are now rendered sequentially instead of in parallel in an attempt to encourage some consistency in the order of errors being thrown. It's not uncommon to have an error present in multiple pages but because each one races each other to render it's not always the same page that throws. It's maddening. This _may_ be slightly slower in bigger projects but we shall see. 372 | 373 | ## [0.22.0] - 2020-07-20 374 | 375 | ### Added 376 | 377 | - It's now possible to dynamically generate HTML outputs by passing an optional `createPages` function when you create a `Baker` instance. 378 | 379 | ```js 380 | /** 381 | * "createPages" is a function to call for each page you want created 382 | * "data" is the quaff generated contents of the "_data" folder 383 | */ 384 | function createPages(createPage, data) { 385 | // use whatever parts of the data context (or external sources!) to determine 386 | // what pages should be generated 387 | for (const title of data.titles) { 388 | // call createPage for each one, passing in the template to use within 389 | // _layouts, where to output it in the output (_dist) folder, and optional 390 | // additional context 391 | createPage('template.html', `${title}.html`, { 392 | context: { title }, 393 | }); 394 | } 395 | } 396 | 397 | const baker = new Baker({ 398 | ..., 399 | createPages, 400 | }); 401 | ``` 402 | 403 | This works whether you're running the development server locally or building for production. 404 | 405 | If an optional additional context is passed, it will be merged with the global context **only** for that render and have precedence, meaning any overlapping keys will contain what was passed locally to `createPage` even if it also exists in the global context. It's recommended to do something similar to above and "namespace" your provided local context to lessen the chance of an unexpected overwrite. 406 | 407 | ### Changed 408 | 409 | - Moved from `rollup-plugin-babel` to the namespaced `@rollup/plugin-babel`. 410 | 411 | ## [0.21.0] - 2020-06-22 412 | 413 | ### Added 414 | 415 | - `.json`, `.geojson` and `.topojson` files in the `assets` directory will now have hashes generated and work like you'd expect with `{% static %}`. In production these files will also be minified. 416 | 417 | ### Changed 418 | 419 | - Baker no longer uses `hasha` to calculate file hashes and instead rolls its own with `crypto`, dropping two dependencies. 420 | 421 | ## [0.20.0] - 2020-06-17 422 | 423 | ### Added 424 | 425 | A new experimental Rollup plugin has been added to `baker` to provide an additional way to pull primitive values from files in the `_data` folder into JavaScript files. By importing a value from a special `data:*` path you can use the value directly. 426 | 427 | So if there was `meta.json` file in your `_data` folder: 428 | 429 | ```json 430 | { 431 | "breed": "corgi", 432 | "names": ["Abe", "Winston", "Willow"] 433 | } 434 | ``` 435 | 436 | Then you could tap into it like this: 437 | 438 | ```js 439 | import breed from 'data:meta.breed'; 440 | 441 | console.log(breed); // "corgi" 442 | ``` 443 | 444 | However - to prevent any excessively large imports this plugin will prevent the import of anything that's not a primitive value (number, boolean, string, etc.). This means _no_ arrays or objects. 445 | 446 | ```js 447 | import names from 'data:meta.names'; // will throw a Rollup error! 448 | ``` 449 | 450 | ### Changed 451 | 452 | - `postcss` will now be ran against any CSS in development as well. This prevents the (increasingly) rare case of where a CSS property only has support with a prefix. (`appearance: none` was the driver for this.) This could cryptically break in development and _then_ work in production, which is about as non-ideal as you can get. 453 | - `fs-extra` has been purged from the library in favor of native `fs.promises` and `rimraf`. 454 | - The CLI command now throws proper `process.exit()` calls. 455 | - The dev server (via `mini-sync`) now waits for the initial build to succeed before attempting to serve. This should help prevent partial serves and throw more helpful errors if there is something critically wrong without leaving the dev server in limbo. 456 | - We now use `colorette` for all our terminal coloring needs. 457 | 458 | ### Fixed 459 | 460 | - Failures to process a file in the `_data` directory will now throw legitmate errors via `quaff`. In the case of JSON this means you'll get actually useful line numbers. 461 | 462 | ## [0.20.0-alpha.0] - 2020-02-09 463 | 464 | ### Added 465 | 466 | - It's now possible to pass a `staticRoot` path to Baker, which will make every non-HTML engine output into the `staticRoot` **relative** to `output`. This is intented to make multi-page deploys more viable in certain scenarios. 467 | 468 | ## [0.19.0] - 2020-02-06 469 | 470 | ### Added 471 | 472 | - Each HTML page generated by Nunjucks now has access to the local context variable `current_page`. As of this release the only value in this object is `current_page.absolute_url`, which is intended to replace the global `CURRENT_URL`. 473 | 474 | ### Removed 475 | 476 | - The `CURRENT_URL` Nunjucks global is no longer available, and has been replaced by the local context object `current_page`. Please note this now means any usage of `current_page` **must** be passed into macros, who do not have access to the local context. 477 | 478 | ### Fixed 479 | 480 | - The `CURRENT_URL` Nunjucks global was subject to a race condition when Baker is in multiple-page output mode due to the async render method. This means it was possible for an HTML page to use the wrong `CURRENT_URL`. Now, the current URL of a page will appear on a local context variable called `current_page.absolute_url`, guaranteeing that each page can only ever see it's own `current_page` context. 481 | 482 | ## [0.18.2] - 2020-02-05 483 | 484 | ### Fixed 485 | 486 | - The included `preload` via `{% script %}` now passes `crossorigin`. 487 | 488 | ## [0.18.1] - 2020-02-05 489 | 490 | ### Fixed 491 | 492 | - The preload section of the `{% script %}` block now accounts for the `pathPrefix` and resolves relative to it. 493 | 494 | ## [0.18.0] - 2020-02-05 495 | 496 | ### Added 497 | 498 | - It's now possible to pass a second flag to the `{% script %}` block that instructs it to include any scripts that are candidates for preloading. This is recommended in browsers that support `rel=preload` in order to assist the browser in efficiently loading assets. You should **only** use this if something like [Lighthouse](https://developers.google.com/web/tools/lighthouse/) is suggesting it. To activate it, just pass `true` as the second parameter to `{% script %}` and Baker will do the rest. 499 | 500 | ```html 501 | {% script 'app', true %} 502 | ``` 503 | 504 | ## [0.17.0] - 2020-02-04 505 | 506 | ### Added 507 | 508 | - `dotenv-expand` has been added to our `.env` file logic, allowing for tapping into existing environment variables to build values `baker` can see. the `BAKER_` prefix is still enforced, however - but this provides a way to morph existing variables into compatible ones. 509 | 510 | ### Fixed 511 | 512 | - The reworked `inject` function from `0.15.0` did not account for local development when a manifest does not exist for `AssetsEngine` output. This has been fixed. It instead will look for the local version of the file in `development` and continue to error out in `production` if the manifest check fails. 513 | 514 | ## [0.16.1] - 2020-01-19 515 | 516 | ### Fixed 517 | 518 | - `@babel/preset-typescript` also needs to know what the JSX pragma is so it knows to ignore it. This patch ensures both `@babel/preset-typescript` and `@babel/plugin-transform-react-jsx` get passed the same one. 519 | 520 | ## [0.16.0] - 2020-01-19 521 | 522 | ### Added 523 | 524 | - Added support for TypeScript (`.ts`, `.tsx`) files in the `scripts` directory via `@babel/preset-typescript`. They are also allowed as `entrypoints` if passed. Actual type-checking is left up to the user, all this does is remove any TypeScript artifacts from the files - BYO `tsconfig.json` and `tsc` calls. 525 | 526 | ## [0.15.0] - 2020-01-17 527 | 528 | ### Added 529 | 530 | - Added a reworked `inject` block, which allows for inserting the contents of a file **post** processing directly into the HTML. This is useful for potentially "injecting" CSS or JavaScript into the page. 531 | 532 | ## [0.14.0] - 2020-01-16 533 | 534 | ### Added 535 | 536 | - Video files (`.mp4`, `.webm`) are now recognized by `AssetsEngine` and will be included in any hashing. 537 | 538 | ## [0.13.2] - 2020-01-10 539 | 540 | ### Fixed 541 | 542 | - Some `dependencies` got moved to `devDependencies`, meaning you wouldn't have got them on install. Oops. 543 | 544 | ## [0.13.1] - 2020-01-10 545 | 546 | ### Fixed 547 | 548 | - `Baker.pathPrefix` is now set to `/` in `development` mode so a passed `pathPrefix` does not break anything in serve mode. 549 | 550 | ## [0.13.0] - 2020-01-10 551 | 552 | ### Added 553 | 554 | - The Nunjucks environment now includes a new global named `CURRENT_URL`. This represents the final URL of each generated page that can be used in its template. It's based on a combination of the provided `domain`, `pathPrefix` and clean (without `index.html` appended) path of the output HTML itself. 555 | 556 | ## [0.12.0] - 2020-01-09 557 | 558 | ### Added 559 | 560 | - Added new built-in `date` filter to Nunjucks, which allows for formatting of an ISO date string or Date object with `date-fns` [formatting function](https://date-fns.org/v2.8.1/docs/format). 561 | - Added a new parameter that must be passed to `new Baker()` — `domain`. This is used by `staticabsolute` to prepare absolute project URLs. (May become optional later for scenarios where this doesn't matter.) 562 | - Added new `staticabsolute` block, which makes it possible to build absolute URLs to project files. 563 | 564 | ## [0.11.0] - 2019-12-06 565 | 566 | ### Added 567 | 568 | - Font files (`.woff2`, `.woff`, `.ttf`, `.otf`) are now recognized by `AssetsEngine` and will be included in any hashing. 569 | 570 | ### Fixed 571 | 572 | - Baker now picks up images with without all lowercase extensions and includes them the compression and hashing process. 573 | 574 | ## [0.10.0] - 2019-12-03 575 | 576 | ### Added 577 | 578 | - The function that resolves static files is now available on a Baker instance as `getStaticPath`. This enables users of Baker to tap into the file resolution logic however they see fit. 579 | 580 | ## [0.9.0] - 2019-11-18 581 | 582 | ### Added 583 | 584 | - Modern JavaScript builds now use [`@babel/preset-modules`](https://github.com/babel/preset-modules). This should result in even smaller modern bundles that natively support features that already exist in ~85% of browsers. 585 | 586 | ### Removed 587 | 588 | - Automatic web polyfill injection has been removed. It's just too much magic going on, and we shouldn't assume that every single thing will need `fetch` + `intersection-observer` + `classlist` injected into it. (JavaScript features are still polyfilled via `core-js`. In other words if it's something you'd be able to do in Node.js it's handled.) The gains of keeping a few polyfills out of the modern build aren't worth the confusion. However this does mean users are now responsible for importing their own polyfills. 589 | 590 | ## [0.8.0] - 2019-11-03 591 | 592 | ### Added 593 | 594 | - The Rollup engine now supports both Svelte (`.svelte` files) and Preact (the usage of JSX) as options for JavaScript-based HTML templating. 595 | 596 | ### Changed 597 | 598 | - `browser-sync` has been replaced with [`mini-sync`](https://github.com/rdmurphy/mini-sync). `browser-sync` was one of the largest packages installed in `baker`, and this should lead to quicker install times. 599 | 600 | ### Removed 601 | 602 | - The old legacy Rollup engine has been deleted and the one previously called `rollup2.js` has taken its place. 603 | 604 | ## [0.7.0] - 2019-10-18 605 | 606 | ### Added 607 | 608 | - Support for correctly formatted environment variables that are passed to `rollup-plugin-replace` has been added. Any environment variable that begins with `BAKER_` will be read and converted to the `process.env.BAKER_*` format that can be used in JavaScript files. Any environmental variables that do not have a match are ignored. 609 | 610 | It's also possible to manage these with a `.env` in the root of your project. The same rule regarding the `BAKER_` prefix applies. 611 | 612 | ## [0.6.0] - 2019-09-17 613 | 614 | ### Added 615 | 616 | - Two custom functions have been added to the `sass` renderer — `static-url` and `static-path`. These are implemented against the Node.js API (and not within a Sass file) because they need to reference the static asset manifests. They are used in the same scenarios as the `{% static %}` block in Nunjucks templates — you need to reference the path to a static asset in your project, but need it to be given the correct hash prefix on production builds. 617 | 618 | `static-url` is meant to be a shortcut for anything you'd normally put inside of `url()`, which it will include for you. 619 | 620 | _SCSS_ 621 | 622 | ```scss 623 | body { 624 | background-image: static-url('assets/background.png'); 625 | } 626 | ``` 627 | 628 | _CSS_ 629 | 630 | ```css 631 | body { 632 | background-image: url(/assets/background.123abc.png); 633 | } 634 | ``` 635 | 636 | `static-path` only adjusts the path and returns it as a string. This will probably be less used, but it's there as an escape hatch if you need it. 637 | 638 | ```scss 639 | body { 640 | background-image: url(static-path('assets/background.png')); 641 | } 642 | ``` 643 | 644 | _CSS_ 645 | 646 | ```css 647 | body { 648 | background-image: url(/assets/background.123abc.png); 649 | } 650 | ``` 651 | 652 | ### Changed 653 | 654 | - Now only valid images will receive the file hash in production mode. This is imperfect, but better than every random asset getting a hash unnecessarily and causing issues when they're used. (Looking at you, `.gltf` files.) Ideally this would be smarter, but not quite sure how to go about that yet. 655 | 656 | ## [0.5.0] - 2019-09-04 657 | 658 | ### Added 659 | 660 | - Nunjucks templates now have a better error logger. It's not perfect, but should help find specific lines causing issues. 661 | - Template files in the layout directory are now watched during a serve - if any changes are made templates are regenerated. 662 | - Files in the `data` directory are now watched during a serve and will trigger a template build. 663 | 664 | ### Changed 665 | 666 | - This package is now deployed on `npm` at `@datagraphics/baker` instead of `@datadesk/baker`, which has been deprecated. 667 | 668 | ## [0.4.0] - 2019-09-03 669 | 670 | ### Added 671 | 672 | - Legacy script builds now use `core-js` to polyfill and add features that may be missing in those browsers. This will likely cause the `iife` build to be bigger than it should be, but this prevents users from having to whack-a-mole issues with IE 11. It should just work. 673 | - Polyfills for both the modern and legacy are automatically inserted into every entrypoint, with the assumption there's a base set of features we should expect to be there. For modern builds, it's support for dynamic imports and IntersectionObserver. For legacy builds, it's fetch, Element.classList and IntersectionObserver. 674 | 675 | ### Changed 676 | 677 | - The engine for Rollup has been rewritten to be much smarter about how it navigates modern and legacy builds. This also does away with SystemJS in favor of native modules for browsers that support it, and an `iife` build for browsers that do not. 678 | 679 | ## [0.3.0] - 2019-08-16 680 | 681 | ### Added 682 | 683 | - Added `AssetsEngine` for management of generic assets files in a build. By default it looks for an `assets` directory in the input directory. 684 | 685 | ### Changed 686 | 687 | - The `ImagesEngine` is no more and has been merged into `AssetsEngine`. This means that images _must_ be in the `assets` directory to be found and handled. 688 | 689 | ## [0.2.1] - 2019-08-16 690 | 691 | ### Fixed 692 | 693 | - A `pathPrefix` should always have a leading slash to ensure pathing resolution works. 694 | 695 | ## [0.2.0] - 2019-08-08 696 | 697 | ### Changed 698 | 699 | - The serve task now runs all the engines in an initial pass before activating the server. This ensures that the local development URL is not presented as available before it truly is. 700 | 701 | ## [0.1.0] - 2019-08-07 702 | 703 | ### Added 704 | 705 | - Initial release. 706 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Los Angeles Times 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Baker 2 | 3 | `@datagraphics/baker` is a build tool by and for the Los Angeles Times. The Times uses it to build the static pages published at latimes.com/projects. You can use it however you'd like. 4 | 5 | An example of how The Times puts the package to use is available at [datadesk/baker-example-page-template](https://github.com/datadesk/baker-example-page-template). 6 | 7 | [![npm](https://badgen.net/npm/v/@datagraphics/baker)](https://www.npmjs.org/package/@datagraphics/baker) 8 | 9 | ## Requirements 10 | 11 | * [Node.js](https://nodejs.org/en/) version 14 and higher 12 | * [Node Package Manager](https://www.w3schools.com/whatis/whatis_npm.asp) 13 | 14 | ## Installation 15 | 16 | ```sh 17 | npm install -D @datagraphics/baker 18 | ``` 19 | 20 | ## What is Baker and why do you use it? 21 | 22 | Baker is a development environment that can be converted into a static website that’s ready for the web. With a minimal amount of HTML, CSS and JavaScript, you can publish a project. The Los Angeles Times uses Baker to write custom code for projects that aren’t possible within the rigid templates of our content management system. 23 | 24 | ## Does anyone else use Baker? 25 | 26 | Yes. Here are some examples of Baker is use outside of the Los Angeles Times. 27 | 28 | * [AMSAT’s amateur satellite index](https://amsat.org/amateur-satellite-index) 29 | * The ["First Visual Story"](https://palewi.re/docs/first-visual-story/) training tutorial by [Ben Welsh](https://palewi.re/who-is-ben-welsh/), [Armand Emamdjomeh](http://emamd.net/) and [Vanessa Martinez](https://www.vanessa-martinez.com/) 30 | * [e.e. cummings free poetry archive](https://cummings.ee/) by [Ben Welsh](https://palewi.re/who-is-ben-welsh/) 31 | * [Noodle Tracker](https://noodletracker.com/) by [Matt Stiles](https://mattstiles.me/) 32 | * [hotsauce.gay](https://hotsauce.gay/) and [men who don't move](https://caseymm.github.io/men-who-dont-move/) by [Casey Miller](https://caseymmiller.com/) 33 | * A variety of news applications by [ProPublica](https://propublica.org), including ["Does Your Local Museum or University Still Have Native American Remains?"](https://projects.propublica.org/repatriation-nagpra-database/) and ["Look Up Which Fortune 500 Companies Fund Election Deniers"](https://projects.propublica.org/fortune-500-company-election-deniers-jan-6/). 34 | * ["Did your neighborhood turn out to vote?"](https://projects.thecity.nyc/zeldin-hochul-election-voter-turnout-nyc/) and other features by [THE CITY](https://www.thecity.nyc/) 35 | * [Maryland precinct-level election results](https://www.thebaltimorebanner.com/politics-power/state-government/precinct-level-governor-election-vote-data-O25RWFHG35DEFCYOZNZDVRS374/) by [The Baltimore Banner](https://www.thebaltimorebanner.com/) 36 | * [moneyinpolitics.wtf](https://moneyinpolitics.wtf/) by [Ben Welsh](https://palewi.re/who-is-ben-welsh/), Derek Willis, Anu Narayanswamy and Agustin Armendariz 37 | 38 | If you know of other examples, please add them to the list. 39 | 40 | ## How does Baker work? 41 | 42 | Baker brings together a bunch of different technologies. 43 | 44 | The HTML templating is powered by [Nunjucks](https://mozilla.github.io/nunjucks/), giving us a Jinja2-like experience for organizing and creating our HTML pages. This is also very similar to the templating language used in Django. 45 | 46 | CSS styles are written using the preprocessor [Sass](https://sass-lang.com/). Sass enhances CSS by adding features that don't exist in CSS yet like nesting, mixins, inheritance and other tricks. Baker also uses the postprocessor called Autoprefixer, which automatically adds special prefixes to our CSS for browser support. (`--webkit`, `--moz`, etc.) 47 | 48 | JavaScript is bundled using [Rollup](https://www.rollupjs.org/guide/en/), which makes it possible for us to write modern JavaScript that gets optimized and prepared in a way that makes it load as fast as possible for our users. Code we write is passed through a JavaScript compiler called [Babel](https://babeljs.io/), which rewrites our code to make sure it works in all the browsers we support. 49 | 50 | Data imports, powered by [quaff](https://www.npmjs.com/package/quaff), allow for easily imported structured data files into templates, which is useful for making data visualizations. 51 | 52 | ## How do I get started using it? 53 | 54 | The repository at [github.com/datadesk/baker-example-page-template](https://github.com/datadesk/baker-example-page-template) is our premade starter that comes with HTML, styles and scripts ready for experimentation. It also includes GitHub Actions that can deploy staging and production version of your page. It works after only minimal configuration. You could customize it to match the look and feel of your site. 55 | 56 | ## Contributing 57 | 58 | [Fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) the repository and [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it locally. The enter the code directory and install the package's dependencies. 59 | 60 | ```sh 61 | npm install 62 | ``` 63 | 64 | [Branch](https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging) off. Make any changes. Preview them with the test server. 65 | 66 | ```sh 67 | npm start 68 | ``` 69 | 70 | Run our tests. 71 | 72 | ```sh 73 | npm run build 74 | ``` 75 | 76 | Once they pass, your changes should be briefly documented in the `CHANGELOG.md` file under the `[Unreleased]` header. Depending on the type of change you are making, you may need to add a new subheader as defined by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). For example, if you are changing how a feature works, you may need to add a `### [Changed]` subhead. 77 | 78 | [Commit](https://git-scm.com/docs/git-commit). Submit a [pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). 79 | 80 | ## Releasing 81 | 82 | This package is distributed using npm. To publish a new release, you will need to have an [npmjs](https://www.npmjs.com/) account with ownership of the [@datagraphics/baker](https://www.npmjs.com/package/@datagraphics/baker) namespace. 83 | 84 | Next you should use npm's version command to up the version number. You have to decide if you're a major, minor or patch release. If you're unsure, review the standards defined at [semver.org](https://semver.org/). Then run one of the commands below. The code will be updated appropriately. 85 | 86 | ```sh 87 | # Pick one and only one! 88 | npm version major 89 | npm version minor 90 | npm version patch 91 | ``` 92 | 93 | Rename the `[Unreleased]` section of the `CHANGELOG.md` with the same version number. Commit. 94 | 95 | ```sh 96 | git add CHANGELOG.md 97 | git commit -m "Updated CHANGELOG" 98 | ``` 99 | 100 | Release the new version of the package. 101 | 102 | ```sh 103 | npm publish 104 | ``` 105 | 106 | Push your work to GitHub, including tag created by the `npm version` command. 107 | 108 | ```sh 109 | git push origin main --tags 110 | ``` 111 | 112 | Create a new release on GitHub at [github.com/datadesk/baker/releases](https://github.com/datadesk/baker/releases) with the same version number. Paste the changelog entry into the post as a bullet list. 113 | -------------------------------------------------------------------------------- /bin/bake.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // native 4 | import { bold, green, red } from 'colorette'; 5 | import { resolve } from 'path'; 6 | 7 | // packages 8 | import debug from 'debug'; 9 | import mri from 'mri'; 10 | import { rollup } from 'rollup'; 11 | import requireFromString from 'require-from-string'; 12 | 13 | // local 14 | import { Baker } from '../lib/index.js'; 15 | import { logErrorMessage } from '../lib/utils.js'; 16 | 17 | const logger = debug('baker:cli'); 18 | 19 | const OUTPUT_DIR = '_dist'; 20 | const SCREENSHOT_DIR = '_screenshot'; 21 | 22 | const defaultConfigFile = 'baker.config.js'; 23 | 24 | const defaultConfig = { 25 | assets: 'assets', 26 | createPages: undefined, 27 | data: '_data', 28 | domain: undefined, 29 | embeds: 'embeds', 30 | entrypoints: 'scripts/app.js', 31 | input: process.cwd(), 32 | layouts: '_layouts', 33 | nunjucksVariables: undefined, 34 | nunjucksFilters: undefined, 35 | nunjucksTags: undefined, 36 | minifyOptions: undefined, 37 | svelteCompilerOptions: undefined, 38 | output: OUTPUT_DIR, 39 | pathPrefix: '/', 40 | port: 3000, 41 | staticRoot: '', 42 | crosswalkPath: undefined, 43 | }; 44 | 45 | function getDefaultFromConfig(module) { 46 | return module.__esModule ? module.default : module; 47 | } 48 | 49 | async function compileAndLoadConfig(pathToConfig) { 50 | const bundle = await rollup({ 51 | external: () => true, 52 | input: pathToConfig, 53 | treeshake: false, 54 | }); 55 | 56 | const { 57 | output: [{ code }], 58 | } = await bundle.generate({ 59 | exports: 'named', 60 | format: 'cjs', 61 | interop: 'auto', 62 | }); 63 | const loadedConfig = requireFromString(code, pathToConfig); 64 | 65 | return getDefaultFromConfig(loadedConfig); 66 | } 67 | 68 | async function prepareConfig(inputOptions) { 69 | // the input directory everything is relative to 70 | const input = inputOptions.input; 71 | 72 | // a config parameter was passed 73 | if (inputOptions.config) { 74 | // we check to see if it was passed as a boolean and use our default path to the config, otherwise we use what was given 75 | const pathToConfig = resolve( 76 | input, 77 | inputOptions.config === true ? defaultConfigFile : inputOptions.config 78 | ); 79 | 80 | inputOptions = await compileAndLoadConfig(pathToConfig); 81 | } 82 | 83 | // prep a helper function to resolve paths against input 84 | const resolver = (key) => inputOptions[key] || defaultConfig[key]; 85 | 86 | const options = {}; 87 | 88 | options.assets = resolver('assets'); 89 | options.createPages = resolver('createPages'); 90 | options.data = resolver('data'); 91 | options.domain = resolver('domain'); 92 | options.embeds = resolver('embeds'); 93 | options.entrypoints = resolver('entrypoints'); 94 | options.input = resolver('input'); 95 | options.layouts = resolver('layouts'); 96 | options.nunjucksVariables = resolver('nunjucksVariables'); 97 | options.nunjucksFilters = resolver('nunjucksFilters'); 98 | options.nunjucksTags = resolver('nunjucksTags'); 99 | options.minifyOptions = resolver('minifyOptions'); 100 | options.output = resolver('output'); 101 | options.pathPrefix = resolver('pathPrefix'); 102 | options.port = resolver('port'); 103 | options.staticRoot = resolver('staticRoot'); 104 | options.svelteCompilerOptions = resolver('svelteCompilerOptions'); 105 | options.crosswalkPath = resolver('crosswalkPath'); 106 | 107 | return options; 108 | } 109 | 110 | const mriConfig = { 111 | alias: { 112 | a: 'assets', 113 | c: 'config', 114 | d: 'data', 115 | e: 'entrypoints', 116 | i: 'input', 117 | l: 'layouts', 118 | o: 'output', 119 | p: 'pathPrefix', 120 | s: 'staticRoot', 121 | }, 122 | default: { 123 | input: process.cwd(), 124 | }, 125 | }; 126 | 127 | /** 128 | * The function that runs when the CLI is ran. 129 | * 130 | * @param {string[]} args The provided args 131 | */ 132 | async function run(args) { 133 | const { _, ...flags } = mri(args, mriConfig); 134 | 135 | const command = _[0]; 136 | const config = await prepareConfig(flags); 137 | 138 | logger('command:', command); 139 | logger('resolved input flags:', config); 140 | 141 | const baker = new Baker(config); 142 | 143 | switch (command) { 144 | case 'bake': 145 | case 'build': 146 | try { 147 | await baker.bake(); 148 | 149 | console.log(green(bold('The build was a success!'))); 150 | } catch (err) { 151 | console.log( 152 | red(bold("Build failed. Here's what possibly went wrong:\n")) 153 | ); 154 | logErrorMessage(err); 155 | process.exit(1); 156 | } 157 | break; 158 | case 'screenshot': 159 | // Change a few config options for taking screenshots 160 | const screenshotConfig = { ...config, output: SCREENSHOT_DIR }; 161 | const screenshotBaker = new Baker(screenshotConfig); 162 | 163 | try { 164 | await screenshotBaker.screenshot(); 165 | 166 | console.log(green(bold('The screenshot was a success!'))); 167 | } catch (err) { 168 | console.log( 169 | red(bold("Screenshot failed. Here's what possibly went wrong:\n")) 170 | ); 171 | logErrorMessage(err); 172 | process.exit(1); 173 | } 174 | break; 175 | case 'serve': 176 | await baker.serve(); 177 | } 178 | } 179 | 180 | run(process.argv.slice(2)).catch((err) => { 181 | console.error(err); 182 | // we want to throw a real exit value on crash and burn 183 | process.exit(1); 184 | }); 185 | -------------------------------------------------------------------------------- /example-simple/_data/titles.json: -------------------------------------------------------------------------------- 1 | ["title1", "title2", "title3", "title4"] 2 | -------------------------------------------------------------------------------- /example-simple/_layouts/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | A templated page 7 | {% block styles %}{% endblock styles %} 8 | {% script 'app' %} 9 | 10 | 11 | {% block content %}{% endblock content %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /example-simple/baker.config.js: -------------------------------------------------------------------------------- 1 | import { intcomma } from 'journalize'; 2 | 3 | export default { 4 | // special case because it is in a directory 5 | input: './example-simple', 6 | 7 | // we want to use the static root feature, so we supply the path 8 | staticRoot: '/static/', 9 | 10 | // pathPrefix and domain are required 11 | pathPrefix: '/', 12 | domain: 'https://www.latimes.com', 13 | 14 | // use createPages to generate pages on the fly 15 | createPages(createPage, data) { 16 | for (const title of data.titles) { 17 | createPage('template.html', `${title}.html`, { 18 | context: { title }, 19 | }); 20 | } 21 | }, 22 | 23 | // pass an object of filters to add to Nunjucks 24 | nunjucksFilters: { 25 | otherintcomma: intcomma, 26 | square(n) { 27 | n = +n; 28 | 29 | return n * n; 30 | }, 31 | logContext() { 32 | console.log(this.context); 33 | 34 | return 'check console'; 35 | }, 36 | }, 37 | 38 | nunjucksTags: { 39 | doubler(n) { 40 | return `

${n} doubled is ${n * 2}

`; 41 | }, 42 | delay, 43 | logger(x, y, z) { 44 | console.log(x, y, z); 45 | }, 46 | }, 47 | }; 48 | 49 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 50 | 51 | async function delay(ms = 2000) { 52 | console.log(`Delaying for ${ms}ms...`); 53 | await sleep(ms); 54 | console.log('Done delaying.'); 55 | 56 | return `I was delayed for ${ms}ms!`; 57 | } 58 | 59 | delay.async = true; 60 | -------------------------------------------------------------------------------- /example-simple/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'template.html' %} 2 | 3 | {% block styles %} 4 | 5 | {% endblock styles %} 6 | 7 | {% block content %} 8 | {{ 4|square }} 9 | {{ 4|log }} 10 | {{ 5|logContext }} 11 | {% doubler 20 %} 12 | 13 | {% logger 1, 2, three=true %} 14 | 15 | {% delay 3000 %} 16 | {% endblock content %} 17 | -------------------------------------------------------------------------------- /example-simple/other.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | I'm another page 7 | 8 | 9 | Other page 10 | 11 | 12 | -------------------------------------------------------------------------------- /example-simple/scripts/Paragraph.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 14 |

{text} = {square(value)}

15 | -------------------------------------------------------------------------------- /example-simple/scripts/app.js: -------------------------------------------------------------------------------- 1 | // your code here 2 | import('dataset:./sectors.csv').then(console.log); 3 | -------------------------------------------------------------------------------- /example-simple/scripts/sectors.csv: -------------------------------------------------------------------------------- 1 | series_id,industry_sector,current_employment,payroll,supersector,group,industry_sector_clean 2 | 20000000,Construction,746400,Nonfarm,Construction,Goods Producing,Construction 3 | 30000000,Manufacturing,1185000,Nonfarm,Manufacturing,Goods Producing,Manufacturing 4 | 41000000,Wholesale Trade,631700,Nonfarm,"Trade, Transportation and Utilities",Service Providing,Wholesale trade 5 | 42000000,Retail Trade,1372800,Nonfarm,"Trade, Transportation and Utilities",Service Providing,Retail trade 6 | 43000000,"Transportation, Warehousing and Utilitie",661800,Nonfarm,"Trade, Transportation and Utilities",Service Providing,"Transportation, warehousing, utilities" 7 | 50000000,Information,545800,Nonfarm,Information,Service Providing,Information 8 | 55520000,Finance and Insurance,542100,Nonfarm,Financial Activities,Service Providing,Finance and insurance 9 | 55530000,Real Estate and Rental and Leasing,279500,Nonfarm,Financial Activities,Service Providing,Real estate 10 | 60540000,"Professional, Scientific and Technical S",1267900,Nonfarm,Professional and Business Services,Service Providing,"Professional, scientific, technical" 11 | 60550000,Management of Companies and Enterprises,239200,Nonfarm,Professional and Business Services,Service Providing,Management of companies and enterprises 12 | 60560000,Administrative and Support and Waste Ser,969000,Nonfarm,Professional and Business Services,Service Providing,Administrative and support 13 | 65610000,Educational Services,352400,Nonfarm,Educational and Health Services,Service Providing,Educational services 14 | 65620000,Health Care and Social Assistance,2195700,Nonfarm,Educational and Health Services,Service Providing,Health care and social assistance 15 | 70710000,"Arts, Entertainment, and Recreation",175400,Nonfarm,Leisure and Hospitality,Service Providing,"Arts, entertainment and recreation" 16 | 70720000,Accommodation and Food Service,918800,Nonfarm,Leisure and Hospitality,Service Providing,Accommodation and food service 17 | 80000000,Other Services,411100,Nonfarm,Other Services,Service Providing,Other services 18 | 90000000,Government,2532500,Nonfarm,Government,Service Providing,Government -------------------------------------------------------------------------------- /example-simple/scripts/utils.ts: -------------------------------------------------------------------------------- 1 | export function square(n: number): number { 2 | return n * n; 3 | } 4 | -------------------------------------------------------------------------------- /example-simple/styles/main.scss: -------------------------------------------------------------------------------- 1 | $color: tomato; 2 | 3 | body { 4 | background-color: $color; 5 | } 6 | -------------------------------------------------------------------------------- /example/.env: -------------------------------------------------------------------------------- 1 | BAKER_DEMO_VAR=success 2 | BAKER_AWS_BUCKET=${AWS_BUCKET} 3 | -------------------------------------------------------------------------------- /example/_data/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "breed": "corgi", 3 | "list": ["This", "should", "fail"] 4 | } 5 | -------------------------------------------------------------------------------- /example/_layouts/base.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | This is the title. 8 | 9 | 10 | {% script 'app' %} 11 | 12 | 13 | {% include 'header.njk' %} 14 | {% block extra %} 15 | {% include 'extra.njk' %} 16 | {% endblock extra %} 17 | {% block content %}{% endblock content %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /example/_layouts/extra.njk: -------------------------------------------------------------------------------- 1 |

I'm here

2 | -------------------------------------------------------------------------------- /example/_layouts/header.njk: -------------------------------------------------------------------------------- 1 |
2 |

A pretend header

3 |
4 | -------------------------------------------------------------------------------- /example/_layouts/object.json.njk: -------------------------------------------------------------------------------- 1 | {{ obj|safe }} 2 | -------------------------------------------------------------------------------- /example/_layouts/object.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ obj }} 8 | 9 | 10 |

{{ obj }}

11 | 12 | 13 | -------------------------------------------------------------------------------- /example/_layouts/robots.txt.njk: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /example/_layouts/sitemap.xml.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% for url in urlList -%} 4 | 5 | {{ url }} 6 | 7 | {% endfor %} 8 | 9 | -------------------------------------------------------------------------------- /example/assets/demo.json: -------------------------------------------------------------------------------- 1 | { 2 | "meta": 5 3 | } 4 | -------------------------------------------------------------------------------- /example/assets/fonts/benton-gothic-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadesk/baker/59c185c8de06c89a4ab979789c2fb2eae44a653e/example/assets/fonts/benton-gothic-regular.woff -------------------------------------------------------------------------------- /example/assets/fonts/benton-gothic-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadesk/baker/59c185c8de06c89a4ab979789c2fb2eae44a653e/example/assets/fonts/benton-gothic-regular.woff2 -------------------------------------------------------------------------------- /example/assets/images/corgi-copy.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadesk/baker/59c185c8de06c89a4ab979789c2fb2eae44a653e/example/assets/images/corgi-copy.JPG -------------------------------------------------------------------------------- /example/assets/images/corgi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadesk/baker/59c185c8de06c89a4ab979789c2fb2eae44a653e/example/assets/images/corgi.jpg -------------------------------------------------------------------------------- /example/baker.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | input: './example', 3 | entrypoints: 'scripts/{app,client}.{js,ts}', 4 | pathPrefix: '/', 5 | domain: 'https://www.latimes.com', 6 | nunjucksVariables: { 7 | FOO: 'bar', 8 | }, 9 | staticRoot: 'static', 10 | svelteCompilerOptions: { 11 | hydratable: true, 12 | }, 13 | createPages(createPage, data) { 14 | // Create robots.txt 15 | createPage('robots.txt.njk', 'robots.txt'); 16 | const urlList = ['/', '/two/', '/three/']; 17 | for (const obj of data.meta.list) { 18 | // Create detail pages 19 | const objUrl = `/object/${obj.toLowerCase()}/`; 20 | createPage('object.njk', objUrl, { obj }); 21 | urlList.push(objUrl); 22 | // Create JSON output 23 | const jsonUrl = `/object/${obj.toLowerCase()}.json`; 24 | createPage('object.json.njk', jsonUrl, { 25 | obj: JSON.stringify({ value: obj }, null, 2), 26 | }); 27 | } 28 | createPage('sitemap.xml.njk', `sitemap.xml`, { 29 | urlList, 30 | }); 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /example/embeds/embed_example.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.njk' %} 2 | 3 | {% block content %} 4 |
5 |

I am a web component

6 |
7 | 8 | {% endblock content %} 9 | -------------------------------------------------------------------------------- /example/embeds/embed_example_2.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.njk' %} 2 | 3 | {% block content %} 4 |
5 |

I am a web component

6 |
7 | 8 | {% endblock content %} 9 | -------------------------------------------------------------------------------- /example/index.njk: -------------------------------------------------------------------------------- 1 | {% extends 'base.njk' %} 2 | 3 | {% block content %} 4 | Our favorite type of dog is always {{ meta.breed }} {{ "5000"|intcomma }}. 5 | 6 |

This text should have a different font! {{ '2018-05-10'|date('yyyy-MM-dd HH:mm:ss') }}

7 |
8 |
9 |
10 | 11 | {% static 'styles/main.scss' %} 12 | 13 | {% static 'assets/demo.json' %} 14 | 15 | 16 | 17 | {% if NODE_ENV == 'production' %} 18 | We are in production! 19 | {% endif %} 20 | {{ page|log }} 21 | 22 |
NOW: {{ NOW|date('yyyy-M-dd') }}
23 |
FOO: {{ FOO }}
24 | 25 | {% include '@datagraphics/cookbook/footer/template.html' %} 26 | {% endblock content %} 27 | -------------------------------------------------------------------------------- /example/scripts/Inner.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Hello, {name}!

6 | 7 | 12 | -------------------------------------------------------------------------------- /example/scripts/Other.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |

Hello, {name}!

8 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /example/scripts/app.js: -------------------------------------------------------------------------------- 1 | import App from './app.svelte'; 2 | import list from 'data:meta.breed'; 3 | import { csvParse } from 'd3-dsv'; 4 | 5 | if (process.env.BAKER_AWS_BUCKET === 'bigbuilder') { 6 | console.log('a big build!'); 7 | } else { 8 | console.log(list); 9 | } 10 | 11 | async function main() { 12 | const { intcomma } = await import('journalize'); 13 | console.log(intcomma(5432)); 14 | 15 | new App({ 16 | target: document.querySelector('#svelte'), 17 | props: { name: 'Svelte' }, 18 | }); 19 | 20 | const { getData } = await import('./nested/internal.js'); 21 | console.log(await getData()); 22 | 23 | const dataUrl = new URL('./cdcr.csv', import.meta.url); 24 | console.log(dataUrl); 25 | const payload = await fetch(dataUrl).then((res) => res.text()); 26 | console.log(payload); 27 | const csvData = csvParse(payload); 28 | console.log(csvData); 29 | } 30 | 31 | main(); 32 | -------------------------------------------------------------------------------- /example/scripts/app.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Hello, {name}!

6 | 7 | 12 | -------------------------------------------------------------------------------- /example/scripts/client.ts: -------------------------------------------------------------------------------- 1 | import App from './Other.svelte'; 2 | 3 | const map: Map = new Map(); 4 | const resolved = Promise.resolve(); 5 | 6 | console.log(map, resolved); 7 | 8 | new App({ 9 | target: document.querySelector('#svelte'), 10 | props: { name: 'Svelte' }, 11 | }); 12 | -------------------------------------------------------------------------------- /example/scripts/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/scripts/nested/internal.js: -------------------------------------------------------------------------------- 1 | export async function getData() { 2 | const data = await import('dataset:../cdcr.json'); 3 | return data; 4 | } 5 | -------------------------------------------------------------------------------- /example/scripts/woo/app.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datadesk/baker/59c185c8de06c89a4ab979789c2fb2eae44a653e/example/scripts/woo/app.js -------------------------------------------------------------------------------- /example/styles/_type.scss: -------------------------------------------------------------------------------- 1 | $font-family: Georgia, serif; 2 | -------------------------------------------------------------------------------- /example/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $color: yellow; 2 | -------------------------------------------------------------------------------- /example/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | @import 'type'; 3 | 4 | @font-face { 5 | font-family: 'Benton Gothic'; 6 | font-style: normal; 7 | font-weight: 500; 8 | font-display: swap; 9 | src: static-url('assets/fonts/benton-gothic-regular.woff2') format('woff2'), 10 | static-url('assets/fonts/benton-gothic-regular.woff') format('woff'); 11 | } 12 | 13 | body { 14 | background-color: $color; 15 | font-family: $font-family; 16 | } 17 | 18 | .bg-image { 19 | background-image: static-url('assets/images/corgi.jpg'); 20 | width: 400px; 21 | height: 400px; 22 | } 23 | 24 | p { 25 | font-family: 'Benton Gothic'; 26 | } 27 | 28 | select { 29 | appearance: none; 30 | } 31 | -------------------------------------------------------------------------------- /example/three/four.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./scripts/**/*"] 3 | } 4 | -------------------------------------------------------------------------------- /example/two.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | {% include 'header.njk' %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/blocks/inject.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { promises as fs } from 'fs'; 3 | import { join } from 'path'; 4 | 5 | export function createInjectBlock(outputDir, sources) { 6 | async function inject(file) { 7 | let path; 8 | 9 | // we try to find it in our sources 10 | for (const source of sources) { 11 | if (file in source.manifest) { 12 | path = source.manifest[file]; 13 | break; 14 | } 15 | } 16 | 17 | if (path) { 18 | const absolutePath = join(outputDir, path); 19 | 20 | return fs.readFile(absolutePath, 'utf8'); 21 | } else { 22 | throw new Error( 23 | `The inject block tried to load a file that does not exist: ${file}` 24 | ); 25 | } 26 | } 27 | 28 | inject.async = true; 29 | 30 | return inject; 31 | } 32 | -------------------------------------------------------------------------------- /lib/blocks/script.js: -------------------------------------------------------------------------------- 1 | // native 2 | import debug from 'debug'; 3 | import { getStaticPathFromManifestEntry } from './static.js'; 4 | 5 | const logger = debug('baker:blocks:script'); 6 | const EntryTypes = { 7 | CSS: 'css', 8 | MODERN: 'modern', 9 | LEGACY: 'legacy', 10 | }; 11 | 12 | export function createScriptBlock(rollup) { 13 | return function script(entry, shouldPreload = false) { 14 | const { manifest } = rollup; 15 | const { preloads } = manifest; 16 | 17 | const cssEntry = rollup.getManifestEntryByType(entry, EntryTypes.CSS); 18 | const modernEntry = rollup.getManifestEntryByType(entry, EntryTypes.MODERN); 19 | const legacyEntry = rollup.getManifestEntryByType(entry, EntryTypes.LEGACY); 20 | 21 | const output = []; 22 | 23 | if (!modernEntry) { 24 | throw new Error( 25 | `A script block tried to reference an entrypoint that does not exist: ${entry}. It's possible the bundling failed, or "${entry}" was not correctly configured as an entrypoint.` 26 | ); 27 | } 28 | 29 | const _static = (entry) => { 30 | return getStaticPathFromManifestEntry(rollup, entry); 31 | }; 32 | 33 | if (shouldPreload) { 34 | preloads.forEach((preload) => { 35 | output.push(``); 38 | }); 39 | } 40 | 41 | if (!!cssEntry) { 42 | output.push(``); 43 | } 44 | 45 | if (!!modernEntry) { 46 | output.push( 47 | `` 48 | ); 49 | } 50 | 51 | if (!!legacyEntry) { 52 | output.push( 53 | `` 54 | ); 55 | } 56 | 57 | logger('script blocks', output); 58 | return output.join('\n') + '\n'; 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /lib/blocks/static.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { join, relative } from 'path'; 3 | 4 | /** 5 | * Returns true if the string is a valid URL. 6 | * 7 | * @param {string} str The string to test 8 | */ 9 | export function isFullUrl(str) { 10 | try { 11 | new URL(str); 12 | return true; 13 | } catch { 14 | return false; 15 | } 16 | } 17 | 18 | export function createStaticBlock(cwd, engines) { 19 | return function _static(file) { 20 | return getStaticPath(file, cwd, engines); 21 | }; 22 | } 23 | 24 | export function getStaticPath(file, cwd, engines) { 25 | if (isFullUrl(file)) { 26 | return file; 27 | } 28 | 29 | // de-absolute the path if necessary 30 | file = relative(cwd, join(cwd, file)); 31 | 32 | // first we try to find it in our sources 33 | for (const engine of engines) { 34 | const entry = engine.getManifestEntry(file); 35 | if (entry) { 36 | return getStaticPathFromManifestEntry(engine, entry); 37 | } 38 | } 39 | 40 | // if it gets this far that file didn't exist 41 | throw new Error( 42 | `A static block tried to load a file that does not exist in provided sources: ${file}` 43 | ); 44 | } 45 | 46 | export function getStaticPathFromManifestEntry(engine, entry) { 47 | const { basePath } = engine; 48 | if (basePath.endsWith('/') && entry.startsWith('/')) { 49 | return `${basePath}${entry.slice(1)}`; 50 | } 51 | 52 | return `${basePath}${entry}`; 53 | } 54 | -------------------------------------------------------------------------------- /lib/engines/assets.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { promises as fs } from 'fs'; 3 | import { format, join, parse, relative } from 'path'; 4 | 5 | // packages 6 | import debug from 'debug'; 7 | import imagemin from 'imagemin'; 8 | import gifsicle from 'imagemin-gifsicle'; 9 | import jpegtran from 'imagemin-jpegtran'; 10 | import optipng from 'imagemin-optipng'; 11 | import svgo from 'imagemin-svgo'; 12 | 13 | // local 14 | import { BaseEngine } from './base.js'; 15 | import { isProductionEnv } from '../env.js'; 16 | import { 17 | getRevHash, 18 | validAudioExtensions, 19 | validFontExtensions, 20 | validImageExtensions, 21 | validJsonExtensions, 22 | validVideoExtensions, 23 | } from '../utils.js'; 24 | 25 | const logger = debug('baker:engines:assets'); 26 | 27 | export class AssetsEngine extends BaseEngine { 28 | constructor({ dir, ...args }) { 29 | super(args); 30 | 31 | this.name = 'assets'; 32 | 33 | // the directory where all assets live 34 | this.dir = relative(this.input, dir); 35 | 36 | // the glob for finding asset files, we accept anything 37 | this.filePattern = join(this.dir, '**/*'); 38 | 39 | // prepare the plugins for image processing 40 | this.plugins = [gifsicle(), jpegtran(), optipng(), svgo()]; 41 | 42 | // the set of valid extensions to hash 43 | this.validExtensions = new Set([ 44 | ...validFontExtensions, 45 | ...validImageExtensions, 46 | ...validJsonExtensions, 47 | ...validVideoExtensions, 48 | ...validAudioExtensions, 49 | ]); 50 | } 51 | 52 | async getOutputPath({ content, dir, ext, name }) { 53 | // ensure our check picks up extensions with different cases 54 | const normalizedExt = ext.toLowerCase(); 55 | 56 | // if we're in production and this is an image, font, video or JSON, hash it, otherwise skip 57 | if (isProductionEnv && this.validExtensions.has(normalizedExt)) { 58 | const hash = await getRevHash(content); 59 | name = `${name}.${hash}`; 60 | } 61 | 62 | return format({ dir, name, ext }); 63 | } 64 | 65 | async render(file) { 66 | // grab the path relative to the source directory 67 | const input = relative(this.input, file); 68 | logger('loading', input); 69 | 70 | // determine the file's extension 71 | const { ext } = parse(file); 72 | 73 | // make sure it's always lowercase for matching purposes 74 | const normalizedExt = ext.toLowerCase(); 75 | 76 | // read the file 77 | let buffer = await fs.readFile(file); 78 | 79 | // if this is an image, we want to do extra work 80 | if (isProductionEnv && validImageExtensions.includes(normalizedExt)) { 81 | // pass it through imagemin 82 | buffer = await imagemin.buffer(buffer, { plugins: this.plugins }); 83 | } 84 | 85 | // if this is JSON, we want to minify it 86 | if (isProductionEnv && validJsonExtensions.includes(normalizedExt)) { 87 | buffer = Buffer.from(JSON.stringify(JSON.parse(buffer.toString()))); 88 | } 89 | 90 | logger('finished processing of', input); 91 | 92 | return buffer; 93 | } 94 | 95 | /** 96 | * @param {string[]} files 97 | */ 98 | buildManifest(files) { 99 | return files.reduce((obj, file) => { 100 | // grab the path relative to the source directory 101 | const input = relative(this.input, file); 102 | 103 | logger('found asset', input); 104 | 105 | obj[input] = input; 106 | 107 | return obj; 108 | }, {}); 109 | } 110 | 111 | async build() { 112 | // clear out the dependencies and manifest 113 | this.invalidate(); 114 | 115 | // find the files to work with 116 | const files = await this.findFiles(); 117 | 118 | for (const file of files) { 119 | this.addDependency(file, file); 120 | } 121 | 122 | if (isProductionEnv) { 123 | try { 124 | await Promise.all(files.map((file) => this.outputFile(file))); 125 | } catch (err) { 126 | throw err; 127 | } 128 | } else { 129 | this.manifest = this.buildManifest(files); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/engines/base.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { format, join, parse, relative } from 'path'; 3 | 4 | // packages 5 | import chokidar from 'chokidar'; 6 | import glob from 'fast-glob'; 7 | 8 | // local 9 | import { noop, outputFile } from '../utils.js'; 10 | 11 | /** 12 | * The base builder engine for all asset types. For most engines this will 13 | * handle the majority of tasks. 14 | */ 15 | export class BaseEngine { 16 | /** 17 | * @param {object} options 18 | * @param {string} options.domain 19 | * @param {string} options.input 20 | * @param {string} options.output 21 | * @param {string} options.pathPrefix 22 | * @param {string} options.staticRoot 23 | * @param {string} options.basePath 24 | */ 25 | constructor({ domain, input, output, pathPrefix, staticRoot, basePath }) { 26 | this.basePath = basePath; 27 | 28 | // the project's domain 29 | this.domain = domain; 30 | 31 | // the input directory 32 | this.input = input; 33 | 34 | // the output directory 35 | this.output = output; 36 | 37 | // the path prefix 38 | this.pathPrefix = pathPrefix; 39 | 40 | // any additional pathing for static assets 41 | this.staticRoot = staticRoot; 42 | 43 | // the glob pattern to use for finding files to process 44 | this.filePattern = null; 45 | 46 | // any files or paths to ignore when searching 47 | this.ignorePattern = ['**/_*/**', '**/node_modules/**']; 48 | 49 | // the output manifest for this engine 50 | this.manifest = {}; 51 | 52 | // any files this engine considers a dependency for rendering its output 53 | /** @type {Set} */ 54 | this.dependencies = new Set(); 55 | 56 | /** @type {Map>} */ 57 | this.mappedDependencies = new Map(); 58 | 59 | // make sure we can tap into this embedded in other functions 60 | this.addDependency = this.addDependency.bind(this); 61 | 62 | this.getManifestEntry = this.getManifestEntry.bind(this); 63 | 64 | this.getManifestEntryByType = this.getManifestEntryByType.bind(this); 65 | } 66 | 67 | findFiles() { 68 | return glob(join(this.input, this.filePattern), { 69 | ignore: this.ignorePattern, 70 | }); 71 | } 72 | 73 | /** 74 | * @param {string} file 75 | */ 76 | addDependency(file, importer) { 77 | if (this.mappedDependencies.has(file)) { 78 | this.mappedDependencies.get(file).add(importer); 79 | } else { 80 | this.mappedDependencies.set(file, new Set([importer])); 81 | } 82 | 83 | return this.dependencies.add(file); 84 | } 85 | 86 | getDependencies() { 87 | return Array.from(this.dependencies); 88 | } 89 | 90 | invalidate() { 91 | this.manifest = {}; 92 | this.dependencies.clear(); 93 | this.mappedDependencies.clear(); 94 | } 95 | 96 | /** 97 | * @param {object} options 98 | * @param {string} [options.content] 99 | * @param {string} options.base 100 | * @param {string} options.dir 101 | * @param {string} options.ext 102 | * @param {string} options.input 103 | * @param {string} options.name 104 | */ 105 | getOutputPath({ dir, ext, name }) { 106 | return format({ dir, ext, name }); 107 | } 108 | 109 | getManifestEntry(key) { 110 | return this.manifest[key]; 111 | } 112 | 113 | getManifestEntryByType(entry, type) { 114 | return this.manifest[type]?.[entry]; 115 | } 116 | 117 | async outputFile(file) { 118 | // grab the path relative to the source directory 119 | const input = relative(this.input, file); 120 | 121 | // pull the relative path's extension and name 122 | const parts = parse(input); 123 | 124 | // render this file according to the engine 125 | const content = await this.render(file); 126 | 127 | // use the inheriting engine's instructions for generating the output path 128 | const output = await this.getOutputPath({ content, input, ...parts }); 129 | 130 | const staticOutput = join(this.staticRoot, output); 131 | 132 | // build the absolute output path 133 | const absolute = join(this.output, staticOutput); 134 | 135 | // write to disk 136 | await outputFile(absolute, content); 137 | 138 | // save a reference how the file path was modified 139 | this.manifest[input] = staticOutput; 140 | 141 | return staticOutput; 142 | } 143 | 144 | async build() { 145 | // clear out the dependencies and manifest 146 | this.invalidate(); 147 | 148 | // find the files to work with 149 | const files = await this.findFiles(); 150 | 151 | try { 152 | await Promise.all(files.map((file) => this.outputFile(file))); 153 | } catch (err) { 154 | throw err; 155 | } 156 | } 157 | 158 | /** 159 | * A wrapper around chokidar to serve as our default watcher for any given 160 | * file type. 161 | * 162 | * @param {Function} [fn] The function to call every time a change is detected 163 | */ 164 | watch(fn = noop) { 165 | const toWatch = Array.from(this.dependencies); 166 | 167 | this.watcher = chokidar.watch(toWatch, { 168 | ignoreInitial: true, 169 | }); 170 | 171 | const onChange = async (path) => { 172 | try { 173 | const entrypoints = this.mappedDependencies.get(path); 174 | 175 | const outputs = await Promise.all( 176 | Array.from(entrypoints).map((entrypoint) => 177 | this.outputFile(entrypoint) 178 | ) 179 | ); 180 | 181 | for (const file of this.dependencies) { 182 | this.watcher.add(file); 183 | } 184 | 185 | fn(null, outputs); 186 | } catch (err) { 187 | fn(err); 188 | } 189 | }; 190 | 191 | this.watcher.on('add', onChange); 192 | this.watcher.on('change', onChange); 193 | this.watcher.on('unlink', onChange); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /lib/engines/nunjucks.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { promises as fs } from 'fs'; 3 | import { join, parse, relative, resolve } from 'path'; 4 | 5 | // packages 6 | import { createCodeFrame } from 'simple-code-frame'; 7 | import chokidar from 'chokidar'; 8 | import debug from 'debug'; 9 | import nunjucks from 'nunjucks'; 10 | import { Environment, FileSystemLoader, Template } from 'nunjucks'; 11 | 12 | // local 13 | import { BaseEngine } from './base.js'; 14 | import { isProductionEnv, nodeEnv } from '../env.js'; 15 | import { outputFile } from '../utils.js'; 16 | 17 | const logger = debug('baker:engines:nunjucks'); 18 | 19 | export class NunjucksEngine extends BaseEngine { 20 | constructor({ layouts, createPages, nodeModules, globalVariables, ...args }) { 21 | super(args); 22 | 23 | this.name = 'nunjucks'; 24 | 25 | // the directory where Nunjucks templates and includes can be found 26 | this.layouts = layouts; 27 | 28 | // the likely location of the local node_modules directory 29 | this.nodeModules = nodeModules; 30 | 31 | // an optional generate function for dynamic pages 32 | this.createPages = createPages; 33 | 34 | // the glob pattern to use for finding files to process 35 | this.filePattern = '**/*.{html,njk}'; 36 | 37 | // any files or paths to ignore when searching 38 | this.ignorePattern = [this.layouts, ...this.ignorePattern]; 39 | 40 | // handles loading template files from the file system 41 | const fileLoader = new FileSystemLoader( 42 | [this.layouts, this.input, this.nodeModules], 43 | { 44 | noCache: !isProductionEnv, 45 | } 46 | ); 47 | 48 | // the prepared nunjucks environment 49 | this.env = new Environment([fileLoader], { 50 | autoescape: false, 51 | dev: !isProductionEnv, 52 | throwOnUndefined: true, 53 | trimBlocks: true, 54 | }); 55 | 56 | // add global reference to the current Node environment 57 | this.env.addGlobal('NODE_ENV', nodeEnv); 58 | 59 | // add global reference to the domain 60 | this.env.addGlobal('DOMAIN', this.domain); 61 | 62 | // add global reference to the current path prefix 63 | this.env.addGlobal('PATH_PREFIX', this.pathPrefix); 64 | 65 | // add global reference to the current time 66 | this.env.addGlobal('NOW', new Date()); 67 | 68 | // add any global variables submitted to this function 69 | for (const [key, value] of Object.entries(globalVariables)) { 70 | this.env.addGlobal(key, value); 71 | } 72 | 73 | // a hack to keep track of which dependencies each input uses 74 | this.inputPath = undefined; 75 | 76 | this.env.on('load', (_, { path: templatePath }) => { 77 | logger('├╶╶ loading dependency', relative(this.input, templatePath)); 78 | this.addDependency(templatePath, this.inputPath); 79 | }); 80 | 81 | this.context = {}; 82 | } 83 | 84 | getOutputPath({ base, ext, input, name }) { 85 | // If this is one of our data extensions, we return 86 | // the path "as is" to preserve the file name in the URL. 87 | const dataExt = ['.txt', '.yaml', '.yml', '.json', '.xml', '.csv', '.tsv']; 88 | if (dataExt.includes(ext)) { 89 | return input; 90 | } 91 | 92 | // For other files, which are expected to be HTML, 93 | // we always use "pretty" URLs, so we alter the pathname if it is index.html 94 | const pathname = 95 | name === 'index' ? input.replace(base, '') : input.replace(ext, ''); 96 | return join(pathname, 'index.html'); 97 | } 98 | 99 | async compile(file) { 100 | // read the raw string 101 | const raw = await fs.readFile(file, 'utf8'); 102 | 103 | // create the template 104 | const template = new Template(raw, this.env, resolve(file)); 105 | 106 | const render = (context) => { 107 | return new Promise((resolve, reject) => { 108 | template.render(context, async (err, text) => { 109 | if (err) { 110 | if (err.lineno && err.colno) { 111 | err.frame = createCodeFrame(raw, err.lineno, err.colno); 112 | } 113 | 114 | reject(err); 115 | } 116 | 117 | resolve(text); 118 | }); 119 | }); 120 | }; 121 | 122 | return render; 123 | } 124 | 125 | loadHTMLMinifier() { 126 | // this is a big import, so only load it if necessary 127 | if (!this.__minifier__) { 128 | this.__minifier__ = import('html-minifier-terser').then( 129 | (module) => module.minify 130 | ); 131 | } 132 | 133 | return this.__minifier__; 134 | } 135 | 136 | collectDynamicRenders() { 137 | const renders = []; 138 | 139 | const collect = (inputPath, outputPath, localContext) => { 140 | const template = join(this.layouts, inputPath); 141 | renders.push([template, outputPath, localContext]); 142 | }; 143 | 144 | return { collect, renders }; 145 | } 146 | 147 | async outputFile(inputPath, outputPath, localContext = {}) { 148 | // grab the path relative to the source directory 149 | const input = relative(this.input, inputPath); 150 | 151 | // pull the relative path's parts 152 | const parts = parse(outputPath || input); 153 | 154 | // pivot on whether this is an index file or not 155 | const pathname = parts.name === 'index' ? '/' : `${parts.name}/`; 156 | 157 | // prepare the current path 158 | const currentPath = join(this.basePath, parts.dir, pathname); 159 | 160 | // determine our outputPath, using the custom one if provided 161 | const output = this.getOutputPath({ 162 | input: outputPath ? outputPath : input, 163 | ...parts, 164 | }); 165 | 166 | // build the complete URL, using this.domain if it's set 167 | const currentUrl = this.domain 168 | ? new URL(currentPath, this.domain) 169 | : currentPath; 170 | 171 | // prepare the absolute URL to be passed to context 172 | const absoluteUrl = currentUrl.toString(); 173 | 174 | // prep the page tracking object 175 | // TODO: get rid of the snakecase variables in 1.0 176 | const page = { 177 | url: currentPath, 178 | absoluteUrl, 179 | absolute_url: absoluteUrl, 180 | inputPath: input, 181 | outputPath: output, 182 | }; 183 | 184 | // mark this file as a dependency 185 | this.addDependency(inputPath, inputPath); 186 | 187 | // set it as the current input 188 | this.inputPath = inputPath; 189 | 190 | // compile the Nunjucks template 191 | const render = await this.compile(inputPath); 192 | 193 | // prepare the render context, accounting for a possible local pass-in 194 | const context = { ...this.context, ...localContext }; 195 | 196 | // render the page 197 | // TODO: Get rid of current_page in 1.0 198 | let content = await render({ page, current_page: page, ...context }); 199 | logger(`└${isProductionEnv ? '┮' : '╼'} finished render of`, input); 200 | 201 | // if we are in production, minify this 202 | if (isProductionEnv) { 203 | const minifier = await this.loadHTMLMinifier(); 204 | content = await minifier(content, this.minifyOptions); 205 | logger(' ┕ finished minify of rendered', input); 206 | } 207 | 208 | // build our absolute path for writing 209 | const absolute = join(this.output, output); 210 | 211 | // write to disk 212 | await outputFile(absolute, content); 213 | 214 | // save a reference how the file path was modified 215 | this.manifest[input] = output; 216 | 217 | return output; 218 | } 219 | 220 | async build() { 221 | // clear out the dependencies and manifest 222 | this.invalidate(); 223 | 224 | // find the files to work with 225 | const files = await this.findFiles(); 226 | 227 | try { 228 | for (const file of files) { 229 | logger('┌╼ rendering', relative(this.input, file)); 230 | await this.outputFile(file); 231 | } 232 | // if we have a dynamic generator, let's find them 233 | if (this.createPages) { 234 | const { collect, renders } = this.collectDynamicRenders(); 235 | await this.createPages(collect, this.context); 236 | 237 | for (const [inputPath, outputPath, localContext] of renders) { 238 | logger('┌╼ dynamically rendering', outputPath); 239 | await this.outputFile(inputPath, outputPath, localContext); 240 | } 241 | } 242 | } catch (err) { 243 | throw err; 244 | } 245 | } 246 | 247 | /** 248 | * @param {string} name 249 | * @param {any} value 250 | */ 251 | addCustomGlobal(name, value) { 252 | this.env.addGlobal(name, value); 253 | } 254 | 255 | /** 256 | * @param {string} name The name of the filter 257 | * @param {(...args) => unknown} fn The function for the filter 258 | */ 259 | addCustomFilter(name, fn) { 260 | this.env.addFilter(name, fn.bind(this)); 261 | } 262 | 263 | /** 264 | * @param {{[name: string]: (...args) => unknown}} obj 265 | */ 266 | addCustomFilters(obj) { 267 | for (const [name, fn] of Object.entries(obj)) { 268 | this.addCustomFilter(name, fn); 269 | } 270 | } 271 | 272 | addCustomTag(name, fn) { 273 | const addDependency = this.addDependency.bind(this); 274 | const isAsync = fn.async; 275 | 276 | class CustomTag { 277 | constructor() { 278 | this.tags = [name]; 279 | } 280 | 281 | parse(parser, nodes) { 282 | // prep args variable 283 | let args; 284 | 285 | // get the tag token 286 | const token = parser.nextToken(); 287 | 288 | // parse the supplied args 289 | args = parser.parseSignature(true, true); 290 | 291 | // step around bug with no-args tags 292 | if (args.children.length === 0) { 293 | args.addChild(new nodes.Literal(0, 0, '')); 294 | } 295 | 296 | // advance to the end of the block 297 | parser.advanceAfterBlockEnd(token.value); 298 | 299 | const CallExtension = isAsync 300 | ? nodes.CallExtensionAsync 301 | : nodes.CallExtension; 302 | 303 | // pass things along to run() 304 | return new CallExtension(this, 'run', args); 305 | } 306 | 307 | run(_, ...args) { 308 | if (isAsync) { 309 | const resolve = args.pop(); 310 | 311 | fn(...args) 312 | .then((value, dependencies = []) => { 313 | for (const dependency of dependencies) { 314 | addDependency(dependency); 315 | } 316 | 317 | resolve(null, new nunjucks.runtime.SafeString(value)); 318 | }) 319 | .catch((err) => { 320 | resolve(err); 321 | }); 322 | } else { 323 | const value = fn(...args); 324 | // pass the function through to nunjucks with the parameters 325 | return new nunjucks.runtime.SafeString(value); 326 | } 327 | } 328 | } 329 | 330 | this.env.addExtension(name, new CustomTag()); 331 | } 332 | 333 | /** 334 | * @param {{[name: string]: (...args) => unknown}} obj 335 | */ 336 | addCustomTags(obj) { 337 | for (const [name, fn] of Object.entries(obj)) { 338 | this.addCustomTag(name, fn); 339 | } 340 | } 341 | 342 | addCustomBlockTag(name, fn) { 343 | class CustomBlockTag { 344 | constructor() { 345 | this.tags = [name]; 346 | } 347 | 348 | parse(parser, nodes) { 349 | // get the tag token 350 | const token = parser.nextToken(); 351 | 352 | // parse the supplied args 353 | const args = parser.parseSignature(true, true); 354 | 355 | // advance to the end of the block 356 | parser.advanceAfterBlockEnd(token.value); 357 | 358 | // get the contents in between the beginning and end blocks 359 | const body = parser.parseUntilBlocks(`end${name}`); 360 | 361 | // finish out the end block 362 | parser.advanceAfterBlockEnd(); 363 | 364 | return new nodes.CallExtension(this, 'run', args, [body]); 365 | } 366 | 367 | run(_, ...args) { 368 | // the body is always the last item 369 | const body = args.pop(); 370 | 371 | // pass the function through to nunjucks with the parameters 372 | return new nunjucks.runtime.SafeString(fn(body(), ...args)); 373 | } 374 | } 375 | 376 | this.env.addExtension(name, new CustomBlockTag()); 377 | } 378 | 379 | /** 380 | * A static version of the render function that skips using the Nunjucks 381 | * environment. This is useful for files that may be getting templated but 382 | * shouldn't be able to use inheritance, like Sass files. 383 | * 384 | * @param {string} filepath The path to the file to render 385 | * @param {*} context An object to be provided as context to the template 386 | */ 387 | static async renderOnly(filepath, context) { 388 | // read the raw string 389 | const raw = await fs.readFile(filepath, 'utf8'); 390 | 391 | // create the template 392 | const template = new Template(raw, resolve(filepath)); 393 | 394 | // resolve the render 395 | return new Promise((resolve, reject) => { 396 | template.render(context, (err, text) => { 397 | if (err) reject(err); 398 | 399 | resolve(text); 400 | }); 401 | }); 402 | } 403 | 404 | async watch(fn) { 405 | const toWatch = Array.from(this.dependencies); 406 | 407 | this.watcher = chokidar.watch(toWatch, { 408 | ignoreInitial: true, 409 | }); 410 | 411 | const onChange = async (path) => { 412 | 413 | // find the files to work with 414 | const files = await this.findFiles(); 415 | 416 | try { 417 | for (const file of files) { 418 | if (file !== path) 419 | continue; 420 | logger('┌╼ rendering', relative(this.input, file)); 421 | await this.outputFile(file); 422 | } 423 | // if we have a dynamic generator, let's find them 424 | if (this.createPages) { 425 | const { collect, renders } = this.collectDynamicRenders(); 426 | await this.createPages(collect, this.context); 427 | 428 | for (const [inputPath, outputPath, localContext] of renders) { 429 | if (inputPath !== path) 430 | continue; 431 | logger('┌╼ dynamically rendering', outputPath); 432 | await this.outputFile(inputPath, outputPath, localContext); 433 | } 434 | } 435 | } catch (err) { 436 | throw err; 437 | } 438 | }; 439 | 440 | this.watcher.on('add', onChange); 441 | this.watcher.on('change', onChange); 442 | this.watcher.on('unlink', onChange); 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /lib/engines/rollup.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { extname, join, parse } from 'path'; 3 | 4 | // packages 5 | import { rollup, watch } from 'rollup'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import { babel } from '@rollup/plugin-babel'; 8 | import commonjs from '@rollup/plugin-commonjs'; 9 | import json from '@rollup/plugin-json'; 10 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 11 | import replace from '@rollup/plugin-replace'; 12 | import svelte from 'rollup-plugin-svelte'; 13 | import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets'; 14 | 15 | // local 16 | import { BaseEngine } from './base.js'; 17 | import { getEnvironment, isProductionEnv } from '../env.js'; 18 | import { prependEntry } from '../rollup-plugins/prepend-entry.js'; 19 | import { dataPlugin } from '../rollup-plugins/data-plugin.js'; 20 | import { datasetPlugin } from '../rollup-plugins/dataset-plugin.js'; 21 | import { cssPlugin } from '../rollup-plugins/css-plugin.js'; 22 | import { polyfillsDynamicImport } from '../paths.js'; 23 | import * as svelteConfig from '../../svelte.config.js'; 24 | 25 | import { createRequire } from 'module'; 26 | 27 | const require = createRequire(import.meta.url); 28 | 29 | export class RollupEngine extends BaseEngine { 30 | 31 | constructor({ entrypoints, svelteCompilerOptions = {}, ...args }) { 32 | super(args); 33 | 34 | this.name = 'rollup'; 35 | 36 | // the path, array of paths or glob of where entrypoints can be found 37 | this.filePattern = entrypoints; 38 | 39 | // we only want to ignore node_modules, but shouldn't really matter 40 | this.ignorePattern = ['**/node_modules/**']; 41 | 42 | // by default scripts will get put into a scripts directory 43 | this.dir = join(this.output, this.staticRoot, 'scripts'); 44 | 45 | // any custom Svelte compiler options to pass along to rollup-plugin-svelte 46 | this.svelteCompilerOptions = svelteCompilerOptions; 47 | 48 | // internal tracking of Rollup's chunks for building the manifest and errors 49 | // for reporting out 50 | this._chunks = []; 51 | this._errors = []; 52 | 53 | this.context = {}; 54 | } 55 | 56 | generateModernInput(entrypoints) { 57 | // keep track of every entrypoint we find 58 | const entrypointNames = new Set(); 59 | 60 | const entries = {}; 61 | 62 | for (const entrypoint of entrypoints) { 63 | const { name } = parse(entrypoint); 64 | 65 | if (entrypointNames.has(name)) { 66 | throw new Error( 67 | `Multiple JavaScript entrypoints are trying to use "${name}" as their identifier - each entrypoint must be unique. It's possible your "entrypoints" glob is too greedy and is finding more files than you expect.` 68 | ); 69 | } 70 | 71 | // track that this name is in use 72 | entrypointNames.add(name); 73 | 74 | // add name to our input 75 | entries[name] = entrypoint; 76 | } 77 | 78 | return entries; 79 | } 80 | 81 | generateLegacyInput(entrypoint) { 82 | return [entrypoint]; 83 | } 84 | 85 | generateInputOptions(input, replaceValues, nomodule = false) { 86 | // inline dynamic imports if we're in nomodule mode 87 | const inlineDynamicImports = nomodule; 88 | 89 | // all valid input extensions 90 | const extensions = ['.js', '.jsx', '.mjs', '.ts', '.tsx']; 91 | 92 | // ensure everything has the same JSX Pragma 93 | const jsxPragma = 'h'; 94 | 95 | const content = isProductionEnv 96 | ? `import "${polyfillsDynamicImport}";\n` 97 | : `import "${require.resolve( 98 | 'mini-sync/client' 99 | )}";\nimport "${polyfillsDynamicImport}";\n`; 100 | 101 | const config = { 102 | input, 103 | plugins: [ 104 | !nomodule && prependEntry({ content }), 105 | // this will replace any text that appears in JS with these values 106 | replace({ 107 | preventAssignment: true, 108 | values: { 'process.env.LEGACY_BUILD': nomodule, ...replaceValues }, 109 | }), 110 | datasetPlugin(), 111 | dataPlugin(this.context), 112 | nodeResolve({ extensions }), 113 | commonjs(), 114 | json(), 115 | svelte({ 116 | compilerOptions: { 117 | dev: !isProductionEnv, 118 | ...this.svelteCompilerOptions, 119 | }, 120 | emitCss: true, 121 | preprocess: svelteConfig.preprocess, 122 | }), 123 | cssPlugin(), 124 | babel({ 125 | babelHelpers: 'bundled', 126 | exclude: 'node_modules/**', 127 | extensions, 128 | presets: [ 129 | [ 130 | require.resolve('@babel/preset-env'), 131 | { 132 | corejs: nomodule ? 3 : false, 133 | exclude: [ 134 | 'transform-regenerator', 135 | 'transform-async-to-generator', 136 | ], 137 | targets: nomodule 138 | ? { browsers: 'defaults' } 139 | : { esmodules: true }, 140 | bugfixes: !nomodule, 141 | useBuiltIns: nomodule ? 'usage' : false, 142 | }, 143 | ], 144 | [ 145 | require.resolve('@babel/preset-typescript'), 146 | { 147 | jsxPragma, 148 | allowDeclareFields: true, 149 | onlyRemoveTypeImports: true, 150 | }, 151 | ], 152 | ].filter(Boolean), 153 | plugins: [ 154 | [ 155 | require.resolve('@babel/plugin-transform-react-jsx'), 156 | { 157 | // we assume Preact is what you want, TODO to make configurable 158 | pragma: jsxPragma, 159 | pragmaFrag: 'Fragment', 160 | }, 161 | ], 162 | // only when we are not using modules (AKA no promises) do we 163 | // convert async-await like this 164 | nomodule && [ 165 | require.resolve('babel-plugin-transform-async-to-promises'), 166 | { inlineHelpers: true }, 167 | ], 168 | require.resolve('babel-plugin-macros'), 169 | ].filter(Boolean), 170 | }), 171 | importMetaAssets(), 172 | { 173 | name: '__internal__', 174 | renderChunk: (_, chunk) => { 175 | this._chunks.push(chunk); 176 | }, 177 | }, 178 | ].filter(Boolean), 179 | inlineDynamicImports, 180 | preserveEntrySignatures: false, 181 | onwarn, 182 | }; 183 | 184 | return config; 185 | } 186 | 187 | generateOutputOptions({ dir, nomodule = false }) { 188 | // in production we hash the URLs 189 | const entryFileNames = isProductionEnv ? '[name].[hash].js' : '[name].js'; 190 | const chunkFileNames = isProductionEnv 191 | ? '[name].[hash].chunk.js' 192 | : '[name].chunk.js'; 193 | const assetFileNames = isProductionEnv 194 | ? 'assets/[name].[hash][extname]' 195 | : 'assets/[name][extname]'; 196 | 197 | // in a modern build we use authentic ESM modules, otherwise an IIFE 198 | const format = nomodule ? 'iife' : 'esm'; 199 | 200 | const dynamicImportFunction = nomodule ? undefined : '__import__'; 201 | 202 | const options = { 203 | entryFileNames, 204 | chunkFileNames, 205 | assetFileNames, 206 | format, 207 | sourcemap: true, 208 | interop: 'auto', 209 | dynamicImportFunction, 210 | plugins: [ 211 | isProductionEnv && 212 | terser({ 213 | ecma: nomodule ? 5 : 8, 214 | safari10: true, 215 | }), 216 | ].filter(Boolean), 217 | }; 218 | 219 | options.dir = nomodule ? join(dir, 'nomodule') : dir; 220 | 221 | return options; 222 | } 223 | 224 | async generateBundle(input, entrypoints, replaceValues, nomodule = false) { 225 | // generate the Rollup inputOptions 226 | const inputOptions = this.generateInputOptions( 227 | input, 228 | replaceValues, 229 | nomodule 230 | ); 231 | 232 | // generate the Rollup outputOptions 233 | const outputOptions = this.generateOutputOptions({ 234 | dir: this.dir, 235 | nomodule, 236 | }); 237 | 238 | // prepare the Rollup bundle 239 | const bundle = await rollup(inputOptions); 240 | 241 | // write out the files 242 | const info = await bundle.write(outputOptions); 243 | 244 | // prep the manifest object 245 | const manifest = {}; 246 | 247 | // prep list of CSS 248 | const css = {}; 249 | 250 | // collect the manifest 251 | for (const entrypoint of entrypoints) { 252 | const { name } = parse(entrypoint); 253 | 254 | const chunk = info.output.find( 255 | (chunk) => chunk.type === 'chunk' && entrypoint === chunk.facadeModuleId 256 | ); 257 | 258 | if (chunk) { 259 | manifest[name] = join( 260 | this.staticRoot, 261 | nomodule ? 'scripts/nomodule' : 'scripts', 262 | chunk.fileName 263 | ); 264 | 265 | const chunkCss = chunk.imports.find((p) => extname(p) === '.css'); 266 | 267 | css[name] = chunkCss 268 | ? join(this.staticRoot, 'scripts', chunkCss) 269 | : null; 270 | } 271 | } 272 | 273 | const preloads = info.output 274 | .filter(({ isEntry }) => !isEntry) 275 | .map(({ fileName }) => join(this.staticRoot, 'scripts', fileName)); 276 | 277 | return { manifest, preloads, css }; 278 | } 279 | 280 | /** 281 | * Rollup has its own file output mechanism built-in, so it's easier to 282 | * sidestep BaseEngine.build and nudge Rollup in the right direction. 283 | */ 284 | async build() { 285 | // find our entrypoints by tapping into BaseEngine.findFiles 286 | const entrypoints = await this.findFiles(); 287 | 288 | // if there are no entrypoints no need to continue 289 | if (!entrypoints.length) return; 290 | 291 | // use our list of entrypoints to create the Rollup input 292 | const modernInput = this.generateModernInput(entrypoints); 293 | 294 | // get our current environment for passing into the input bundle 295 | const { stringified: replaceValues } = getEnvironment(this.pathPrefix); 296 | 297 | // create the modern bundle 298 | const { 299 | manifest: modern, 300 | preloads, 301 | css, 302 | } = await this.generateBundle(modernInput, entrypoints, replaceValues); 303 | 304 | // add the modern build to the manifest 305 | this.manifest.modern = modern; 306 | 307 | // add the generated CSS 308 | this.manifest.css = css; 309 | 310 | // add the preloads to the manifest 311 | this.manifest.preloads = preloads; 312 | 313 | if (isProductionEnv) { 314 | // if we're in production, build the legacy build too 315 | this.manifest.legacy = {}; 316 | 317 | for (const entrypoint of entrypoints) { 318 | const legacyInput = this.generateLegacyInput(entrypoint); 319 | 320 | const { manifest: legacy } = await this.generateBundle( 321 | legacyInput, 322 | entrypoints, 323 | replaceValues, 324 | true 325 | ); 326 | 327 | Object.assign(this.manifest.legacy, legacy); 328 | } 329 | } 330 | } 331 | 332 | async watch(fn) { 333 | // find our entrypoints by tapping into BaseEngine.findFiles 334 | const entrypoints = await this.findFiles(); 335 | 336 | // if there are no entrypoints no need to continue 337 | if (!entrypoints.length) return; 338 | 339 | // use our list of entrypoints to create the Rollup input 340 | const input = this.generateModernInput(entrypoints); 341 | 342 | // get our current environment for passing into the input bundle 343 | const { stringified: replaceValues } = getEnvironment(this.pathPrefix); 344 | 345 | // generate the Rollup inputOptions 346 | const inputOptions = this.generateInputOptions(input, replaceValues); 347 | 348 | // generate the Rollup outputOptions 349 | const outputOptions = this.generateOutputOptions({ dir: this.dir }); 350 | 351 | // set up the Rollup watcher 352 | const watcher = watch({ 353 | ...inputOptions, 354 | output: outputOptions, 355 | }); 356 | 357 | // reset the tracking variables whenever the bundle changes 358 | watcher.on('change', () => { 359 | this._chunks = []; 360 | this._errors = []; 361 | }); 362 | 363 | // check a few events from a build 364 | watcher.on('event', (event) => { 365 | switch (event.code) { 366 | // if there's a normal error, add it to the list and share it out 367 | case 'ERROR': 368 | this._errors.push(event.error); 369 | 370 | fn(this._errors); 371 | break; 372 | // when the bundle ends, generate a new manifest 373 | case 'BUNDLE_END': 374 | const manifest = { 375 | modern: {}, 376 | legacy: {}, 377 | }; 378 | 379 | // collect the manifest 380 | for (const name of Object.keys(input)) { 381 | const chunk = this._chunks.find((chunk) => name in chunk.modules); 382 | 383 | if (chunk) { 384 | manifest.modern[name] = join( 385 | this.staticRoot, 386 | 'scripts', 387 | chunk.fileName 388 | ); 389 | } 390 | } 391 | 392 | fn(null, manifest); 393 | break; 394 | } 395 | }); 396 | } 397 | } 398 | 399 | export function onwarn(warning, onwarn) { 400 | if ( 401 | warning.code === 'CIRCULAR_DEPENDENCY' && 402 | /[/\\]d3-\w+[/\\]/.test(warning.message) 403 | ) { 404 | return; 405 | } 406 | 407 | onwarn(warning); 408 | } 409 | -------------------------------------------------------------------------------- /lib/engines/sass.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { format, relative } from 'path'; 3 | 4 | // packages 5 | import debug from 'debug'; 6 | import * as sass from 'sass'; 7 | import CleanCSS from 'clean-css'; 8 | import autoprefixer from 'autoprefixer'; 9 | import postcss from 'postcss'; 10 | import postcssFlexbugsFixes from 'postcss-flexbugs-fixes'; 11 | 12 | // local 13 | import { BaseEngine } from './base.js'; 14 | import { isProductionEnv } from '../env.js'; 15 | import { getRevHash } from '../utils.js'; 16 | 17 | const logger = debug('baker:engines:sass'); 18 | 19 | export class SassEngine extends BaseEngine { 20 | constructor({ assets, includePaths, ...args }) { 21 | super(args); 22 | 23 | this.name = 'sass'; 24 | 25 | // SCSS tools 26 | this.sass = sass; 27 | this.cleancss = new CleanCSS({ 28 | returnPromise: true, 29 | level: { 1: { specialComments: false }, 2: { all: true } }, 30 | }); 31 | this.postcss = postcss([ 32 | postcssFlexbugsFixes, 33 | autoprefixer({ flexbox: 'no-2009' }), 34 | ]); 35 | 36 | // make sure to exclude any files with leading underscores because they 37 | // mean something special in Sass 38 | this.filePattern = '**/[^_]*.{sass,scss}'; 39 | 40 | // the paths to consider when resolving in Sass 41 | this.includePaths = includePaths; 42 | 43 | // custom functions for the sass renderer 44 | this.functions = {}; 45 | } 46 | 47 | async getOutputPath({ content, dir, name }) { 48 | if (isProductionEnv) { 49 | const hash = await getRevHash(content); 50 | name = `${name}.${hash}`; 51 | } 52 | return format({ dir, name, ext: '.css' }); 53 | } 54 | 55 | async minify(css) { 56 | const { styles } = await this.cleancss.minify(css); 57 | return styles; 58 | } 59 | 60 | async render(file) { 61 | // grab the path relative to the source directory 62 | const input = relative(this.input, file); 63 | logger('rendering', input); 64 | 65 | let result; 66 | 67 | // render the Sass file 68 | try { 69 | result = this.sass.renderSync({ 70 | file, 71 | includePaths: this.includePaths, 72 | functions: this.functions, 73 | }); 74 | } catch (err) { 75 | throw err; 76 | } 77 | 78 | logger('finished render of', input); 79 | 80 | // grab the CSS result 81 | let css = result.css.toString(); 82 | 83 | for (const included of result.stats.includedFiles) { 84 | this.addDependency(included, file); 85 | } 86 | 87 | const { css: postProcessed } = this.postcss.process(css, { 88 | from: file, 89 | }); 90 | 91 | css = postProcessed; 92 | 93 | // if we are in production, post-process and minify the CSS 94 | if (isProductionEnv) { 95 | const { styles } = await this.cleancss.minify(css); 96 | logger('finished minify of rendered', input); 97 | 98 | css = styles; 99 | } 100 | 101 | return css; 102 | } 103 | 104 | addFunction(key, fn) { 105 | // pass in a reference to sass so incoming helpers don't have to import 106 | this.functions[key] = fn(this.sass); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/env.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | 3 | const VALID_NODE_ENVS = ['development', 'production']; 4 | const DEFAULT_NODE_ENV = 'production'; 5 | 6 | const DEBUG = process.env.DEBUG; 7 | const NODE_ENV = process.env.NODE_ENV; 8 | 9 | export const nodeEnv = 10 | NODE_ENV && VALID_NODE_ENVS.includes(NODE_ENV) ? NODE_ENV : DEFAULT_NODE_ENV; 11 | export const isProductionEnv = nodeEnv === 'production'; 12 | 13 | export const inDebugMode = Boolean(DEBUG); 14 | 15 | /** 16 | * Regex for grabbing any environmental variables that may start with "BAKER_". 17 | * @type {RegExp} 18 | */ 19 | const BAKER_REGEX = /^BAKER_/i; 20 | 21 | /** 22 | * @param {string} pathPrefix 23 | */ 24 | export function getEnvironment(pathPrefix) { 25 | // find all the keys of the environment 26 | const keys = Object.keys(process.env); 27 | 28 | // find any of them that match our regex for BAKER_ exclusive ones 29 | const bakerKeys = keys.filter((key) => BAKER_REGEX.test(key)); 30 | 31 | // build the object of environment variables 32 | const raw = bakerKeys.reduce( 33 | (env, key) => { 34 | env[key] = process.env[key]; 35 | return env; 36 | }, 37 | { 38 | // Are we in production mode or not? 39 | NODE_ENV: nodeEnv, 40 | // Useful for resolving the correct path relative to the project files 41 | PATH_PREFIX: pathPrefix, 42 | } 43 | ); 44 | 45 | // Stringify all values so we can pass it directly to rollup-plugin-replace 46 | const stringified = Object.keys(raw).reduce((env, key) => { 47 | env[`process.env.${key}`] = JSON.stringify(raw[key]); 48 | return env; 49 | }, {}); 50 | 51 | return { raw, stringified }; 52 | } 53 | 54 | export function getBasePath(domain, pathPrefix) { 55 | // Local can use relative paths 56 | if (!isProductionEnv) return '/'; 57 | 58 | if (!domain) { 59 | console.warn('WARNING: `domain` is missing from Baker config. Outputs from this project will not be embeddable in external pages/apps.'); 60 | return resolve('/', pathPrefix); 61 | } 62 | 63 | // Dev and Prod deployments need to use absolute paths 64 | let basePath = new URL(pathPrefix, domain).toString(); 65 | if (!basePath.endsWith('/')) { 66 | basePath = `${basePath}/`; 67 | } 68 | 69 | return basePath; 70 | } 71 | -------------------------------------------------------------------------------- /lib/filters/date.js: -------------------------------------------------------------------------------- 1 | import { format, isDate, parseISO } from 'date-fns'; 2 | 3 | export function dateFilter(value, formatString) { 4 | if (!formatString) { 5 | throw new Error('A "formatString" must be passed to the date filter'); 6 | } 7 | 8 | // we want to be able to accept both ISO date strings and Date objects, 9 | // so we check for that and convert if needed 10 | if (!isDate(value)) { 11 | value = parseISO(value); 12 | } 13 | 14 | // TODO: should we check with isDate again just in case parseISO 15 | // returned an invalid date? 16 | 17 | return format(value, formatString); 18 | } 19 | 20 | -------------------------------------------------------------------------------- /lib/filters/json-script.js: -------------------------------------------------------------------------------- 1 | const _jsonScriptMap = { 2 | '>': '\\u003E', 3 | '<': '\\u003C', 4 | '&': '\\u0026', 5 | }; 6 | 7 | const escapeHtmlRegex = new RegExp( 8 | `[${Object.keys(_jsonScriptMap).join('')}]`, 9 | 'g' 10 | ); 11 | 12 | function escapeHtml(text) { 13 | return text.replace(escapeHtmlRegex, (m) => _jsonScriptMap[m]); 14 | } 15 | 16 | export function jsonScriptFilter(value, elementId) { 17 | return ``; 20 | } 21 | -------------------------------------------------------------------------------- /lib/filters/log.js: -------------------------------------------------------------------------------- 1 | export function logFilter(value) { 2 | // log the input 3 | console.log(value); 4 | 5 | // output the value 6 | return value; 7 | } 8 | 9 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | // native 2 | import EventEmitter from 'events'; 3 | import { readdir, mkdirSync } from 'fs'; 4 | import path, { join, resolve } from 'path'; 5 | 6 | // packages 7 | import chokidar from 'chokidar'; 8 | import { green, yellow } from 'colorette'; 9 | import debounce from 'lodash.debounce'; 10 | import dotenv from 'dotenv'; 11 | import dotenvExpand from 'dotenv-expand'; 12 | import * as journalize from 'journalize'; 13 | import { premove } from 'premove'; 14 | import puppeteer from 'puppeteer'; 15 | 16 | // local 17 | import { isProductionEnv, getBasePath } from './env.js'; 18 | import { 19 | onError, 20 | printInstructions, 21 | validAudioExtensions, 22 | validImageExtensions, 23 | validVideoExtensions, 24 | } from './utils.js'; 25 | 26 | // blocks 27 | import { createInjectBlock } from './blocks/inject.js'; 28 | import { createScriptBlock } from './blocks/script.js'; 29 | import { createStaticBlock } from './blocks/static.js'; 30 | 31 | // filters 32 | import { dateFilter } from './filters/date.js'; 33 | import { jsonScriptFilter } from './filters/json-script.js'; 34 | import { logFilter } from './filters/log.js'; 35 | 36 | // engines 37 | import { AssetsEngine } from './engines/assets.js'; 38 | import { NunjucksEngine } from './engines/nunjucks.js'; 39 | import { RollupEngine } from './engines/rollup.js'; 40 | import { SassEngine } from './engines/sass.js'; 41 | 42 | const FALLBACKS_DIR = '_fallbacks'; 43 | 44 | const CROSSWALK_ALLOWED_ASSET_TYPES = [ 45 | 'img', 46 | 'aud', 47 | 'vid' 48 | ]; 49 | 50 | const CROSSWALK_ALLOWED_EXTS = { 51 | img: validImageExtensions.map((ext) => ext.replace('.', '')), 52 | aud: validAudioExtensions.map((ext) => ext.replace('.', '')), 53 | vid: validVideoExtensions.map((ext) => ext.replace('.', '')), 54 | }; 55 | 56 | // Default to 375px snapshot width to accommodate mobile viewports 57 | // TODO: Allow for both desktop and mobile optimized screenshots 58 | const FIXED_FALLBACK_SCREENSHOT_WIDTH = 375; 59 | const EXTRA_CONTENT_HEIGHT = 20; 60 | 61 | /** 62 | * @typedef {Object} BakerOptions 63 | * @property {string} assets 64 | * @property {Function} [createPages] 65 | * @property {string} data 66 | * @property {string} [domain] 67 | * @property {string} entrypoints 68 | * @property {string} input 69 | * @property {string} layouts 70 | * @property {{[key: string]: (...args) => unknown}} [nunjucksVariables] 71 | * @property {{[key: string]: (...args) => unknown}} [nunjucksFilters] 72 | * @property {{[key: string]: (...args) => unknown}} [nunjucksTags] 73 | * @property {{[key: string]: (...args) => unknown}} [minifyOptions] 74 | * @property {string} output 75 | * @property {string} pathPrefix 76 | * @property {string} staticRoot 77 | * @property {import('svelte/types/compiler/interfaces').CompileOptions} [svelteCompilerOptions] 78 | */ 79 | 80 | export class Baker extends EventEmitter { 81 | /** 82 | * @param {BakerOptions} config 83 | */ 84 | constructor({ 85 | assets, 86 | createPages, 87 | data, 88 | domain, 89 | embeds, 90 | entrypoints, 91 | input, 92 | layouts, 93 | nunjucksVariables, 94 | nunjucksFilters, 95 | nunjucksTags, 96 | minifyOptions, 97 | output, 98 | pathPrefix, 99 | port, 100 | staticRoot, 101 | svelteCompilerOptions, 102 | }) { 103 | super(); 104 | 105 | // the input directory of this baker 106 | this.input = resolve(input); 107 | 108 | // load the dotfile if it exists 109 | dotenvExpand.expand(dotenv.config({ path: resolve(this.input, '.env') })); 110 | 111 | // the likely location of the local node_modules directory 112 | this.nodeModules = resolve(process.cwd(), 'node_modules'); 113 | 114 | // where we will be outputting processed files 115 | this.output = resolve(input, output); 116 | 117 | // a special directory of files that should be considered extendable 118 | // templates by Nunjucks 119 | this.layouts = resolve(input, layouts); 120 | 121 | // a special directory where data files for passing to templates are loaded 122 | this.data = resolve(input, data); 123 | 124 | // a special directory where asset files live 125 | this.assets = resolve(input, assets); 126 | 127 | // where in the output directory non-HTML files should go 128 | this.staticRoot = staticRoot; 129 | 130 | this.embeds = embeds; 131 | 132 | // the path to a file, an array of paths, or a glob for determining what's 133 | // considered an entrypoint for script bundles 134 | this.entrypoints = entrypoints; 135 | 136 | // a path prefix that should be applied where needed to static asset paths 137 | // to prep for deploy, ensures there's a leading slash 138 | this.pathPrefix = isProductionEnv ? resolve('/', pathPrefix) : '/'; 139 | 140 | this.port = port; 141 | 142 | // the root domain for the project, which will get pulled into select 143 | // places for URL building 144 | this.domain = domain ? domain : undefined; 145 | 146 | this.basePath = getBasePath(domain, this.pathPrefix); 147 | 148 | // an optional function that can be provided to Baker to dynamically generate pages 149 | this.createPages = createPages; 150 | 151 | // the default input and output arguments passed to each engine 152 | const defaults = { 153 | domain: this.domain, 154 | input: this.input, 155 | output: this.output, 156 | pathPrefix: this.pathPrefix, 157 | staticRoot: this.staticRoot, 158 | basePath: this.basePath, 159 | }; 160 | 161 | this.assets = new AssetsEngine({ 162 | dir: this.assets, 163 | ...defaults, 164 | }); 165 | 166 | // for sass compiling 167 | this.sass = new SassEngine({ 168 | includePaths: [this.nodeModules], 169 | assets: this.assets, 170 | ...defaults, 171 | }); 172 | 173 | // for scripts 174 | this.rollup = new RollupEngine({ 175 | entrypoints: this.entrypoints, 176 | svelteCompilerOptions, 177 | ...defaults, 178 | }); 179 | 180 | // for nunjucks compiling 181 | this.nunjucks = new NunjucksEngine({ 182 | layouts: this.layouts, 183 | nodeModules: this.nodeModules, 184 | createPages: this.createPages, 185 | globalVariables: nunjucksVariables || {}, 186 | ...defaults, 187 | }); 188 | 189 | // add all the features of journalize to nunjucks as filters 190 | this.nunjucks.addCustomFilters(journalize); 191 | 192 | // add our custom inject tag 193 | const injectBlock = createInjectBlock(this.output, [ 194 | this.assets, 195 | this.sass, 196 | ]); 197 | 198 | this.nunjucks.addCustomTag('inject', injectBlock); 199 | 200 | // create our static tag 201 | const staticBlock = createStaticBlock(this.input, [this.assets, this.sass]); 202 | 203 | // save a reference to our static block function for use elsewhere 204 | // TODO: refactor away in v1 205 | this.getStaticPath = staticBlock; 206 | this.nunjucks.getStaticPath = staticBlock; 207 | 208 | // hook up our custom static tag and pass in the manifest 209 | this.nunjucks.addCustomTag('static', staticBlock); 210 | 211 | // create our script tag 212 | this.nunjucks.addCustomTag('script', createScriptBlock(this.rollup)); 213 | 214 | // a custom date filter based on date-fns "format" function 215 | this.nunjucks.addCustomFilter('date', dateFilter); 216 | this.nunjucks.addCustomFilter('log', logFilter); 217 | this.nunjucks.addCustomFilter('jsonScript', jsonScriptFilter); 218 | 219 | // if an object of custom nunjucks filters was provided, add them now 220 | if (nunjucksFilters) { 221 | this.nunjucks.addCustomFilters(nunjucksFilters); 222 | } 223 | 224 | this.nunjucks.addCustomFilter('prepareCrosswalk', this.prepareCrosswalk); 225 | 226 | // if an object of custom nunjucks tags was provided, add them now 227 | if (nunjucksTags) { 228 | this.nunjucks.addCustomTags(nunjucksTags); 229 | } 230 | 231 | // Set the nunjucks minification options 232 | this.nunjucks.minifyOptions = minifyOptions || { collapseWhitespace: true }; 233 | 234 | // hook up our custom sass functions 235 | this.sass.addFunction( 236 | 'static-path($file)', 237 | (sass) => ($file) => new sass.types.String(staticBlock($file.getValue())) 238 | ); 239 | 240 | this.sass.addFunction( 241 | 'static-url($file)', 242 | (sass) => ($file) => 243 | new sass.types.String(`url(${staticBlock($file.getValue())})`) 244 | ); 245 | } 246 | 247 | async getData() { 248 | const { load } = await import('quaff'); 249 | 250 | try { 251 | return await load(this.data); 252 | } catch (err) { 253 | // the directory didn't exist and that's okay 254 | if (err.code === 'ENOENT') { 255 | return {}; 256 | } 257 | 258 | // otherwise we want the real error 259 | throw err; 260 | } 261 | } 262 | 263 | /** 264 | * @typedef {Object} CrosswalkImage 265 | * @property {string} jpg Filepath for the jpg version of this image 266 | * @property {string} png Filepath for the png version of this image 267 | * @property {string} avif Filepath for the avif version of this image 268 | * @property {string} webp Filepath for the webp version of this image 269 | **/ 270 | 271 | /** 272 | * @typedef {Object} CrosswalkAudio 273 | * @property {string} mp3 Filepath for the mp3 version of this audio 274 | **/ 275 | 276 | /** 277 | * @typedef {Object} CrosswalkVideo 278 | * @property {string} mp4 Filepath for the mp4 version of this video 279 | * @property {string} webm Filepath for the webm version of this video 280 | **/ 281 | 282 | /** 283 | * @typedef {Object} CrosswalkAssets 284 | * @property {Object.} img Object containing CrosswalkImage configurations 285 | * @property {Object.} aud Object containing CrosswalkAudio configurations 286 | * @property {Object.} vid Object containing CrosswalkVideo configurations 287 | **/ 288 | 289 | /** 290 | * @typedef {Object} CrosswalkData 291 | * @property {Object.} assets Object containing all crosswalk static assets 292 | * @property {Array.>} additionalData Array of additional, unrecognized data objects 293 | */ 294 | 295 | /** 296 | * Prepares a function to process crosswalk image paths. 297 | * @param {Object.[]} crossWalkRows - AN array of key/value pairs 298 | * @returns {CrosswalkData} A function that maps over data entries to update image paths. 299 | */ 300 | prepareCrosswalk(crossWalkRows) { 301 | const preppedData = { 302 | assets: {}, 303 | additionalData: [], 304 | }; 305 | 306 | return crossWalkRows.reduce((acc, crosswalkRow) => { 307 | if (!crosswalkRow.assetType) { 308 | acc.additionalData.push(crosswalkRow); 309 | return acc; 310 | } 311 | 312 | const { assetName, assetType, ...assetSources } = crosswalkRow; 313 | 314 | const normalizedAssetName = assetName.toLowerCase(); 315 | const normalizedAssetType = assetType.toLowerCase(); 316 | 317 | if (!CROSSWALK_ALLOWED_ASSET_TYPES.includes(normalizedAssetType)) { 318 | throw new Error( 319 | `Crosswalk: Unrecognized assetType: ${normalizedAssetType} for asset ${normalizedAssetName}` 320 | ); 321 | } 322 | 323 | if (!assetName) { 324 | throw new Error( 325 | "Crosswalk: Unable to process crowsswalk tsv/csv. Missing required 'assetName' column." 326 | ); 327 | } 328 | 329 | if ( 330 | normalizedAssetType === 'img' && 331 | !assetSources.jpg && 332 | !assetSources.png 333 | ) { 334 | throw new Error( 335 | 'Crosswalk: Image assets require either a jpg or png file for basic compatibility.' 336 | ); 337 | } 338 | 339 | if (normalizedAssetType === 'aud' && !assetSources.mp3) { 340 | throw new Error( 341 | 'Crosswalk: Audio asset type requires a mp3 file for basic compatibility' 342 | ); 343 | } 344 | 345 | if (normalizedAssetType === 'vid' && !assetSources.mp4) { 346 | throw new Error( 347 | 'Crosswalk: Audio asset type requires a mp4 file for basic compatibility' 348 | ); 349 | } 350 | 351 | if (!preppedData.assets[normalizedAssetType]) 352 | preppedData.assets[normalizedAssetType] = {}; 353 | preppedData.assets[normalizedAssetType][normalizedAssetName] = {}; 354 | 355 | Object.entries(assetSources).forEach(([ext, path]) => { 356 | const allowedExtensions = CROSSWALK_ALLOWED_EXTS[normalizedAssetType]; 357 | if (!allowedExtensions.includes(ext)) { 358 | console.warn( 359 | `Attribute: ${ext} not allowed for asset type: ${normalizedAssetType}. Skipping.` 360 | ); 361 | return; 362 | } 363 | 364 | preppedData.assets[normalizedAssetType][normalizedAssetName][ext] = 365 | this.getStaticPath(path); 366 | }); 367 | 368 | return acc; 369 | }, preppedData); 370 | } 371 | 372 | /** 373 | * Generates fallback images for web-component embeds. 374 | * @param {string} baseUrl The local server's base URL 375 | * @returns {Promise} 376 | */ 377 | async buildEmbedFallbacks(baseUrl) { 378 | const distDir = this.output.split('/').slice(0, -1).join('/'); 379 | const embedFilePattern = path.join(distDir, '_dist', 'embeds'); 380 | 381 | /** 382 | * An array of file paths representing embed files. 383 | * @type {string[]} 384 | */ 385 | const embedFiles = await new Promise((resolve, reject) => { 386 | readdir(embedFilePattern, (err, files) => { 387 | //handling error 388 | if (err) { 389 | reject('Unable to scan directory: ' + err); 390 | } 391 | 392 | resolve(files); 393 | }); 394 | }); 395 | 396 | if (!embedFiles.length) return; 397 | 398 | const browser = await puppeteer.launch({ 399 | headless: true, 400 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 401 | }); 402 | 403 | for (const embedName of embedFiles) { 404 | try { 405 | const embedPath = `embeds/${embedName}/index.html`; 406 | const screenshotLocalUrl = `${baseUrl}/${embedPath}`; 407 | console.log(`Taking screenshot of: ${screenshotLocalUrl}`); 408 | 409 | const page = await browser.newPage(); 410 | await page.goto(screenshotLocalUrl, { 411 | waitUntil: ['networkidle0', 'domcontentloaded'], 412 | }); 413 | 414 | // set the viewport to the content height 415 | const bodyElement = await page.$('body'); 416 | if (!bodyElement) { 417 | console.error(' not found in document. Exiting.'); 418 | return; 419 | } 420 | 421 | const boundingBox = await bodyElement.boundingBox(); 422 | if (!boundingBox) { 423 | console.error('Could not retrieve bounding box for element with body selector.'); 424 | return; 425 | } 426 | 427 | const currentScrollHeight = await page.evaluate(() => { 428 | return document.body.scrollHeight; 429 | }); 430 | 431 | await page.setViewport({ 432 | width: FIXED_FALLBACK_SCREENSHOT_WIDTH, 433 | height: currentScrollHeight, 434 | deviceScaleFactor: 2, 435 | }); 436 | 437 | await page.waitForNetworkIdle(); 438 | 439 | // store the fallback image in the _dist directory 440 | const parentDir = path.dirname(this.output); 441 | const fallbacksDir = join(parentDir, FALLBACKS_DIR); 442 | const screenshotEmbedDir = path.join( 443 | fallbacksDir, 444 | path.dirname(embedPath) 445 | ); 446 | 447 | mkdirSync(screenshotEmbedDir, { recursive: true }); 448 | 449 | const screenshotStoragePath = join(screenshotEmbedDir, 'fallback.png'); 450 | console.log(`Storing the fallback image at: ${screenshotStoragePath}.`); 451 | 452 | const updatedScrollHeight = await page.evaluate(() => { 453 | return document.body.scrollHeight; 454 | }); 455 | 456 | const clipHeight = updatedScrollHeight + EXTRA_CONTENT_HEIGHT; 457 | 458 | await page.screenshot({ 459 | path: screenshotStoragePath, 460 | clip: { 461 | x: boundingBox.x, 462 | y: boundingBox.y, 463 | width: FIXED_FALLBACK_SCREENSHOT_WIDTH, 464 | height: clipHeight, 465 | }, 466 | }); 467 | await page.close(); 468 | } catch (err) { 469 | console.error(`Failed to process ${embedName}: ${err.message}`); 470 | } 471 | } 472 | 473 | await browser.close(); 474 | } 475 | 476 | async serve() { 477 | // remove the output directory to make sure it's clean 478 | await premove(this.output); 479 | 480 | // we only load mini-sync if it is being used 481 | const { create } = await import('mini-sync'); 482 | 483 | // prep the server instance 484 | const server = create({ 485 | dir: [this.output, this.input], 486 | port: this.port 487 | }); 488 | 489 | // track the errors 490 | let templatesError = null; 491 | let stylesError = null; 492 | let scriptsError = null; 493 | let dataError = null; 494 | let assetsError = null; 495 | 496 | console.log(yellow('Starting server...')); 497 | 498 | let data; 499 | 500 | try { 501 | data = await this.getData(); 502 | } catch (err) { 503 | dataError = err; 504 | } 505 | 506 | try { 507 | await this.assets.build(); 508 | } catch (err) { 509 | assetsError = err; 510 | } 511 | 512 | // we need an initial run to populate the manifest 513 | try { 514 | await this.sass.build(); 515 | } catch (err) { 516 | stylesError = err; 517 | } 518 | 519 | try { 520 | this.rollup.context = data; 521 | await this.rollup.build(); 522 | } catch (err) { 523 | scriptsError = err; 524 | } 525 | 526 | try { 527 | this.nunjucks.context = data; 528 | await this.nunjucks.build(); 529 | } catch (err) { 530 | templatesError = err; 531 | } 532 | 533 | await server.start(); 534 | 535 | const logStatus = () => { 536 | let hadError = false; 537 | 538 | if (templatesError) { 539 | hadError = true; 540 | onError('Templates', templatesError); 541 | } 542 | 543 | if (stylesError) { 544 | hadError = true; 545 | onError('Styles', stylesError); 546 | } 547 | 548 | if (scriptsError) { 549 | hadError = true; 550 | onError('Scripts', scriptsError); 551 | } 552 | 553 | if (dataError) { 554 | hadError = true; 555 | onError('Data', dataError); 556 | } 557 | 558 | if (assetsError) { 559 | hadError = true; 560 | onError('Assets', assetsError); 561 | } 562 | 563 | if (!hadError) { 564 | console.log(green('Project compiled successfully!')); 565 | printInstructions(this.port); 566 | } 567 | }; 568 | 569 | // our initial status log 570 | logStatus(); 571 | 572 | // set up the watcher 573 | this.sass.watch((err, outputs) => { 574 | if (err) { 575 | stylesError = err; 576 | } else { 577 | stylesError = null; 578 | 579 | for (const output of outputs) { 580 | server.reload(resolve(this.pathPrefix, output)); 581 | } 582 | } 583 | 584 | logStatus(); 585 | }); 586 | 587 | this.rollup.watch((err) => { 588 | if (err) { 589 | scriptsError = err; 590 | } else { 591 | scriptsError = null; 592 | 593 | server.reload(); 594 | } 595 | 596 | logStatus(); 597 | }); 598 | 599 | this.nunjucks.watch((err) => { 600 | if (err) { 601 | templatesError = err; 602 | } else { 603 | templatesError = null; 604 | 605 | server.reload(); 606 | } 607 | 608 | logStatus(); 609 | }); 610 | 611 | this.assets.watch((err) => { 612 | if (err) { 613 | assetsError = err; 614 | } else { 615 | assetsError = null; 616 | } 617 | 618 | logStatus(); 619 | }); 620 | 621 | const dataWatcher = chokidar.watch(join(this.data, '**/*'), { 622 | ignoreInitial: true, 623 | }); 624 | 625 | const onChange = debounce(async () => { 626 | let data; 627 | 628 | dataError = null; 629 | scriptsError = null; 630 | templatesError = null; 631 | 632 | try { 633 | data = await this.getData(); 634 | } catch (err) { 635 | dataError = err; 636 | } 637 | 638 | this.rollup.context = data; 639 | this.nunjucks.context = data; 640 | 641 | try { 642 | this.rollup.context = data; 643 | await this.rollup.build(); 644 | } catch (err) { 645 | scriptsError = err; 646 | } 647 | 648 | try { 649 | this.nunjucks.context = data; 650 | await this.nunjucks.build(); 651 | } catch (err) { 652 | templatesError = err; 653 | } 654 | 655 | if (!dataError && !scriptsError && !templatesError) { 656 | server.reload(); 657 | } 658 | 659 | logStatus(); 660 | }, 200); 661 | 662 | ['add', 'change', 'unlink'].forEach((event) => { 663 | dataWatcher.on(event, onChange); 664 | }); 665 | } 666 | 667 | async buildDistribution() { 668 | // remove the output directory to make sure it's clean 669 | await premove(this.output); 670 | 671 | // prep the data 672 | const data = await this.getData(); 673 | 674 | // pass the data context to rollup and nunjucks 675 | this.rollup.context = data; 676 | this.nunjucks.context = data; 677 | 678 | // wait for all the assets to prepare first 679 | await this.assets.build(); 680 | 681 | // compile the rest of the assets 682 | await Promise.all([this.sass.build(), this.rollup.build()]); 683 | 684 | // build the HTML 685 | await this.nunjucks.build(); 686 | } 687 | 688 | async bake() { 689 | // emit event that a bake has begun 690 | this.emit('bake:start'); 691 | 692 | // build the distribution 693 | await this.buildDistribution(); 694 | 695 | // emit event that a bake has completed 696 | this.emit('bake:end'); 697 | } 698 | 699 | async screenshot() { 700 | // build the _screenshot directory 701 | await this.buildDistribution(); 702 | 703 | // we only load mini-sync if it is being used 704 | const { create } = await import('mini-sync'); 705 | 706 | // prep the server instance 707 | const server = create({ 708 | dir: [this.output, this.input], 709 | port: this.port, 710 | }); 711 | 712 | // Start the server 713 | console.log(yellow('Starting screenshot server...')); 714 | const { local: baseUrl, network: external } = await server.start(); 715 | 716 | // screenshot the embeds 717 | await this.buildEmbedFallbacks(baseUrl); 718 | 719 | console.log(yellow('Closing server...')); 720 | await server.close(); 721 | } 722 | } 723 | -------------------------------------------------------------------------------- /lib/paths.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { dirname, resolve } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)); 6 | 7 | /** 8 | * Resolves a relative path against this library's root directory. 9 | * 10 | * @param {string} relativePath The desired path relative to this package's directory 11 | * @returns {string} 12 | */ 13 | function resolveOwn(relativePath) { 14 | return resolve(__dirname, '..', relativePath); 15 | } 16 | 17 | export const polyfillsDynamicImport = resolveOwn( 18 | 'lib/polyfills/dynamic-import.js' 19 | ); 20 | -------------------------------------------------------------------------------- /lib/polyfills/dynamic-import.js: -------------------------------------------------------------------------------- 1 | import dynamicImportPolyfill from 'dynamic-import-polyfill'; 2 | 3 | // This needs to be done before any dynamic imports are used 4 | dynamicImportPolyfill.initialize({ modulePath: 'scripts/' }); 5 | -------------------------------------------------------------------------------- /lib/rollup-plugins/css-plugin.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import("rollup").Plugin} RollupPlugin */ 2 | 3 | // packages 4 | import { createFilter } from '@rollup/pluginutils'; 5 | 6 | export function cssPlugin() { 7 | const filter = createFilter('**/*.css'); 8 | const styles = new Map(); 9 | const order = new Set(); 10 | 11 | return /** @type {RollupPlugin} */ ({ 12 | name: 'css-plugin', 13 | 14 | transform(code, id) { 15 | // we only care about CSS files 16 | if (!filter(id)) return; 17 | 18 | // track the order of CSS found 19 | order.add(id); 20 | 21 | // track the rendered styles for output later 22 | styles.set(id, code); 23 | 24 | // we don't want Rollup to attempt to remove this 25 | return { code: '', moduleSideEffects: 'no-treeshake' }; 26 | }, 27 | 28 | generateBundle(_, bundle) { 29 | // first we build a mapping of Svelte components to output CSS 30 | const components = new Map(); 31 | 32 | // using our found CSS earlier, let's sort out which components import them 33 | for (const id of order) { 34 | const info = this.getModuleInfo(id); 35 | 36 | // should be the Svelte component 37 | const component = info.importers[0]; 38 | 39 | // join the rendered CSS to the importing component's path 40 | components.set(component, styles.get(id)); 41 | } 42 | 43 | // next we figure out which entrypoints use which components 44 | for (const chunk of Object.values(bundle)) { 45 | // we only care about entrypoints 46 | if (chunk.type === 'chunk' && chunk.isEntry) { 47 | const modules = Object.keys(chunk.modules); 48 | 49 | let css = ''; 50 | 51 | // if any of the modules are our Svelte components, this entrypoint may have CSS 52 | for (const module of modules) { 53 | if (components.has(module)) { 54 | css += components.get(module); 55 | } 56 | } 57 | 58 | // no need to output if there was no CSS 59 | if (css.length > 0) { 60 | // output our prepared CSS 61 | const referenceId = this.emitFile({ 62 | type: 'asset', 63 | name: `${chunk.name}.css`, 64 | source: css, 65 | }); 66 | 67 | // find its generated file name 68 | const fileName = this.getFileName(referenceId); 69 | 70 | // tell the entrypoint chunk it has a new "import" for downstream use 71 | chunk.imports.push(fileName); 72 | } 73 | } 74 | } 75 | }, 76 | }); 77 | } 78 | 79 | -------------------------------------------------------------------------------- /lib/rollup-plugins/data-plugin.js: -------------------------------------------------------------------------------- 1 | // local 2 | import { isObject } from '../utils.js'; 3 | 4 | // vendor 5 | import { dlv } from '../vendor/dlv.js'; 6 | 7 | const moduleStart = 'data:'; 8 | 9 | export function dataPlugin(data) { 10 | return { 11 | name: 'data-plugin', 12 | 13 | resolveId(id) { 14 | if (!id.startsWith(moduleStart)) return; 15 | return id; 16 | }, 17 | 18 | load(id) { 19 | if (!id.startsWith(moduleStart)) return; 20 | 21 | const key = id.slice(moduleStart.length); 22 | const value = dlv(data, key); 23 | 24 | if (value === undefined) { 25 | this.error(`"${key}" returned an undefined value.`); 26 | return; 27 | } 28 | 29 | if (isObject(value)) { 30 | this.error( 31 | `To prevent leakage or importing too much data, you can only use "${moduleStart}:*" to output simple values. "${key}" tried to return a value with a type of "${typeof value}".` 32 | ); 33 | return; 34 | } 35 | 36 | return `export default ${JSON.stringify(value)};`; 37 | }, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /lib/rollup-plugins/dataset-plugin.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import("rollup").Plugin} RollupPlugin */ 2 | 3 | // native 4 | import { readFileSync } from 'fs'; 5 | import { extname } from 'path'; 6 | 7 | // packages 8 | import { csvParse, tsvParse } from 'd3-dsv'; 9 | import parseJson from 'parse-json'; 10 | 11 | // constants 12 | const moduleName = 'dataset-plugin'; 13 | const moduleStart = 'dataset:'; 14 | const internalModuleStart = `\0${moduleStart}`; 15 | const parsers = { '.csv': csvParse, '.tsv': tsvParse, '.json': parseJson }; 16 | 17 | export function datasetPlugin() { 18 | return /** @type {RollupPlugin} */ ({ 19 | name: moduleName, 20 | 21 | async resolveId(id, importer) { 22 | // if it doesn't match our prefix, pass it along 23 | if (!id.startsWith(moduleStart)) return null; 24 | 25 | // resolve the path and tag it with our virtual module so other plugins leave it alone 26 | id = id.slice(moduleStart.length); 27 | const resolved = await this.resolve(id, importer, { skipSelf: true }); 28 | 29 | return resolved && `${internalModuleStart}${resolved.id}`; 30 | }, 31 | 32 | load(id) { 33 | // only mess with files with our special flag from "resolveId" 34 | if (!id.startsWith(internalModuleStart)) { 35 | return null; 36 | } 37 | 38 | id = id.slice(internalModuleStart.length); 39 | 40 | const ext = extname(id); 41 | 42 | // check if we have a processor for this - should probably throw a harder error if no match 43 | if (!(ext in parsers)) { 44 | return null; 45 | } 46 | 47 | // because we are handling the load here we need to tell Rollup to watch it 48 | this.addWatchFile(id); 49 | 50 | // read the raw file, parse it, and turn it into a string 51 | const code = readFileSync(id, 'utf8'); 52 | const data = parsers[ext](code); 53 | const str = JSON.stringify(data); 54 | 55 | // we check size here because JSON.parse is typically only more performant for 10kb+ files 56 | // source: https://v8.dev/blog/cost-of-javascript-2019#json 57 | // source: https://v8.dev/features/subsume-json#embedding-json-parse 58 | const size = Buffer.byteLength(str, 'utf8'); 59 | 60 | const output = 61 | size > 10000 62 | ? `export default JSON.parse(${JSON.stringify(str)});` 63 | : `export default ${str}`; 64 | 65 | return output; 66 | }, 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /lib/rollup-plugins/prepend-entry.js: -------------------------------------------------------------------------------- 1 | // packages 2 | import MagicString from 'magic-string'; 3 | 4 | export function prependEntry({ content }) { 5 | return { 6 | name: 'prepend-entry', 7 | transform(code, id) { 8 | const { isEntry } = this.getModuleInfo(id); 9 | 10 | if (isEntry) { 11 | const magicString = new MagicString(code); 12 | magicString.prepend(content); 13 | 14 | return { code: magicString.toString(), map: magicString.generateMap() }; 15 | } 16 | }, 17 | }; 18 | } 19 | 20 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | // native 2 | import { createHash } from 'crypto'; 3 | import { promises as fs } from 'fs'; 4 | import { dirname } from 'path'; 5 | 6 | // packages 7 | import { bold, red, yellow } from 'colorette'; 8 | 9 | // local 10 | import { inDebugMode } from './env.js'; 11 | 12 | export function noop() {} 13 | 14 | /** 15 | * @param {string} input 16 | */ 17 | export function getRevHash(input) { 18 | return createHash('md5').update(input).digest('hex').slice(0, 8); 19 | } 20 | 21 | export const isInteractive = process.stdout.isTTY; 22 | 23 | export function clearConsole() { 24 | if (isInteractive && !inDebugMode) { 25 | process.stdout.write( 26 | process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H' 27 | ); 28 | } 29 | } 30 | 31 | /** 32 | * @param {number|string} port 33 | */ 34 | export function printInstructions(port) { 35 | console.log('\nYou can now view your project in your browser\n'); 36 | 37 | console.log(`${bold('Local server is running on:')}`); 38 | 39 | console.log(` * http://localhost:${port}`); 40 | console.log(` * http://127.0.0.1:${port}\n`); 41 | } 42 | 43 | export function logErrorMessage(err) { 44 | if (!Array.isArray(err)) err = [err]; 45 | 46 | err.forEach((e) => { 47 | if (e.message) { 48 | console.error(e.message); 49 | 50 | if (e.frame) { 51 | console.error(e.frame); 52 | } 53 | } else { 54 | console.error(e); 55 | } 56 | }); 57 | 58 | console.log('\n'); 59 | } 60 | 61 | export function onError(type, err) { 62 | console.log(red(`${type} failed to compile.\n`)); 63 | logErrorMessage(err); 64 | } 65 | 66 | export function onWarning(type, err) { 67 | console.log(yellow(`${type} compiled with warnings.\n`)); 68 | logErrorMessage(err); 69 | } 70 | 71 | /** 72 | * List of image file extensions for use in tasks. 73 | * 74 | * @type {String[]} 75 | */ 76 | export const validImageExtensions = [ 77 | '.jpg', 78 | '.jpeg', 79 | '.png', 80 | '.gif', 81 | '.svg', 82 | '.avif', 83 | '.webp', 84 | ]; 85 | 86 | /** 87 | * List of font file extensions we recognize in tasks. 88 | * 89 | * @type {String[]} 90 | */ 91 | export const validFontExtensions = ['.woff2', '.woff', '.ttf', '.otf']; 92 | 93 | /** 94 | * List of video file extensions we recognize in tasks. 95 | * 96 | * @type {String[]} 97 | */ 98 | export const validVideoExtensions = ['.mp4', '.webm']; 99 | 100 | /** 101 | * List of video file extensions we recognize in tasks. 102 | * 103 | * @type {String[]} 104 | */ 105 | export const validAudioExtensions = ['.mp3']; 106 | 107 | /** 108 | * List of JSON file extensions we recognize in tasks. 109 | * 110 | * @type {String[]} 111 | */ 112 | export const validJsonExtensions = ['.json', '.geojson', '.topojson']; 113 | 114 | /** 115 | * Takes a file path and ensures all directories in that path exist. 116 | * 117 | * @param {string} path The full path of the file to ensure has directories 118 | * @returns {Promise} If no error is thrown, returns `void`. 119 | */ 120 | export async function ensureDir(path) { 121 | const dir = dirname(path); 122 | 123 | try { 124 | await fs.readdir(dir); 125 | } catch { 126 | await ensureDir(dir); 127 | 128 | try { 129 | await fs.mkdir(dir); 130 | } catch (err) { 131 | if (err.code !== 'EEXIST') throw err; 132 | } 133 | } 134 | } 135 | 136 | /** 137 | * Takes a string and writes it out to the provided file path. All directories 138 | * in the path are created if needed. 139 | * 140 | * @param {string} dest The output file path 141 | * @param {string} data The content to be written to the `dest` 142 | * @returns {Promise} If no error is thrown, returns `void`. 143 | */ 144 | export async function outputFile(dest, data) { 145 | await ensureDir(dest); 146 | 147 | try { 148 | await fs.writeFile(dest, data); 149 | } catch (e) { 150 | throw e; 151 | } 152 | } 153 | 154 | /** 155 | * Checks if the `value` is of type `Object`. (e.g. arrays, functions, objects, 156 | * regexes, `new Number(0)`, and `new String('')`) 157 | * 158 | * @param {*} value The value to check 159 | * @returns {boolean} Returns `true` if `value` is an object, else `false`. 160 | */ 161 | export function isObject(value) { 162 | const type = typeof value; 163 | 164 | return value != null && (type === 'object' || type === 'function'); 165 | } 166 | -------------------------------------------------------------------------------- /lib/vendor/dlv.js: -------------------------------------------------------------------------------- 1 | // https://github.com/developit/dlv 2 | 3 | export function dlv(obj, key, def, p, undef) { 4 | key = key.split ? key.split('.') : key; 5 | for (p = 0; p < key.length; p++) { 6 | obj = obj ? obj[key[p]] : undef; 7 | } 8 | return obj === undef ? def : obj; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@datagraphics/baker", 3 | "type": "module", 4 | "version": "0.47.7", 5 | "exports": "./lib/index.js", 6 | "files": [ 7 | "bin", 8 | "lib", 9 | "svelte.config.js" 10 | ], 11 | "engines": { 12 | "node": ">=14.13.1" 13 | }, 14 | "bin": { 15 | "bake": "./bin/bake.js" 16 | }, 17 | "scripts": { 18 | "build": "./bin/bake.js build --config example/baker.config.js", 19 | "build:simple": "./bin/bake.js build --config example-simple/baker.config.js", 20 | "build:screenshot": "NODE_ENV=development ./bin/bake.js screenshot --config example/baker.config.js", 21 | "release": "np --no-yarn --no-tests", 22 | "git-pre-commit": "precise-commits", 23 | "serve:simple": "NODE_ENV=development ./bin/bake.js serve --config example-simple/baker.config.js", 24 | "start": "NODE_ENV=development ./bin/bake.js serve --config example/baker.config.js", 25 | "postinstall": "patch-package" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/datadesk/baker.git" 30 | }, 31 | "keywords": [ 32 | "templates", 33 | "static", 34 | "generator" 35 | ], 36 | "author": "CalTimes News Apps", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/datadesk/baker/issues" 40 | }, 41 | "homepage": "https://github.com/datadesk/baker#readme", 42 | "dependencies": { 43 | "@babel/core": "^7.22.1", 44 | "@babel/plugin-transform-react-jsx": "^7.23.4", 45 | "@babel/plugin-transform-runtime": "^7.22.9", 46 | "@babel/preset-env": "^7.23.6", 47 | "@babel/preset-typescript": "^7.22.5", 48 | "@rollup/plugin-babel": "^6.0.3", 49 | "@rollup/plugin-commonjs": "^25.0.0", 50 | "@rollup/plugin-json": "^6.1.0", 51 | "@rollup/plugin-node-resolve": "^15.0.1", 52 | "@rollup/plugin-replace": "^5.0.5", 53 | "@rollup/pluginutils": "^5.0.2", 54 | "@web/rollup-plugin-import-meta-assets": "^2.2.1", 55 | "autoprefixer": "^10.4.16", 56 | "babel-plugin-macros": "^3.1.0", 57 | "babel-plugin-transform-async-to-promises": "^0.8.18", 58 | "chokidar": "^3.5.3", 59 | "clean-css": "^5.3.3", 60 | "colorette": "^2.0.20", 61 | "core-js": "^3.27.1", 62 | "d3-dsv": "^2.0.0", 63 | "date-fns": "^3.3.1", 64 | "debug": "^4.3.4", 65 | "dotenv": "^16.3.1", 66 | "dotenv-expand": "^10.0.0", 67 | "dynamic-import-polyfill": "^0.1.1", 68 | "fast-glob": "^3.3.1", 69 | "html-minifier-terser": "^7.2.0", 70 | "imagemin": "^8.0.1", 71 | "imagemin-gifsicle": "^7.0.0", 72 | "imagemin-jpegtran": "^7.0.0", 73 | "imagemin-optipng": "^8.0.0", 74 | "imagemin-svgo": "^10.0.1", 75 | "journalize": "^2.6.0", 76 | "lodash.debounce": "^4.0.8", 77 | "magic-string": "^0.30.6", 78 | "mini-sync": "^0.3.0", 79 | "mri": "^1.1.4", 80 | "node-fetch": "^3.3.2", 81 | "nunjucks": "^3.2.4", 82 | "parse-json": "^8.1.0", 83 | "patch-package": "^8.0.0", 84 | "postcss": "^8.4.31", 85 | "postcss-flexbugs-fixes": "^5.0.2", 86 | "premove": "^4.0.0", 87 | "puppeteer": "^13.7.0", 88 | "quaff": "^5.0.0", 89 | "require-from-string": "^2.0.2", 90 | "rev-path": "^3.0.0", 91 | "rollup": "^2.79.1", 92 | "rollup-plugin-svelte": "^7.1.6", 93 | "rollup-plugin-terser": "^7.0.0", 94 | "sass": "^1.69.6", 95 | "simple-code-frame": "^1.1.1", 96 | "svelte": "^3.59.2", 97 | "svelte-preprocess": "^5.0.0", 98 | "typescript": "^4.9.5" 99 | }, 100 | "devDependencies": { 101 | "@datagraphics/cookbook": "^1.14.2", 102 | "@datagraphics/prettier-config": "^2.0.0", 103 | "@types/node": "^18.15.11", 104 | "@vercel/git-hooks": "^1.0.0", 105 | "np": "^7.6.3", 106 | "preact": "^10.19.3", 107 | "precise-commits": "^1.0.2", 108 | "prettier": "^2.8.8" 109 | }, 110 | "prettier": "@datagraphics/prettier-config" 111 | } 112 | -------------------------------------------------------------------------------- /patches/mini-sync+0.3.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/mini-sync/server.js b/node_modules/mini-sync/server.js 2 | index 5a26905..ee69282 100644 3 | --- a/node_modules/mini-sync/server.js 4 | +++ b/node_modules/mini-sync/server.js 5 | @@ -61,6 +61,7 @@ const clientScript = fs.readFileSync(resolve(__dirname, pkg['umd:main'])); 6 | * @property {string} local The localhost URL for the static site 7 | * @property {string} network The local networked URL for the static site 8 | * @property {number} port The port the server ended up on 9 | + * @property {string} hostname The hostname the server should bind to 10 | */ 11 | 12 | /** 13 | @@ -90,7 +91,7 @@ const clientScript = fs.readFileSync(resolve(__dirname, pkg['umd:main'])); 14 | * await server.close(); 15 | * 16 | */ 17 | -function create({ dir = process.cwd(), port = 3000 } = {}) { 18 | +function create({ dir = process.cwd(), port = 3000, hostname = '127.0.0.1' } = {}) { 19 | // create a raw instance of http.Server so we can hook into it 20 | const server = http.createServer(); 21 | 22 | @@ -190,28 +191,21 @@ function create({ dir = process.cwd(), port = 3000 } = {}) { 23 | * @returns {Promise} 24 | */ 25 | function start() { 26 | + let interval; 27 | + 28 | return new Promise((resolve, reject) => { 29 | server.on('error', (e) => { 30 | - if (e.code === 'EADDRINUSE') { 31 | - setTimeout(() => { 32 | - server.close(); 33 | - server.listen(++port); 34 | - }, 100); 35 | - } else { 36 | - reject(e); 37 | - } 38 | + reject(e); 39 | }); 40 | 41 | - let interval; 42 | - 43 | server.on('listening', () => { 44 | // ping every 10 seconds 45 | interval = setInterval(sendPing, 10e3); 46 | 47 | // get paths to networks 48 | - const { local, network } = access({ port }); 49 | + const { local, network } = access({ port, hostname }); 50 | 51 | - resolve({ local, network, port }); 52 | + resolve({ local, network, port, hostname }); 53 | }); 54 | 55 | server.on('close', () => { 56 | @@ -221,7 +215,7 @@ function create({ dir = process.cwd(), port = 3000 } = {}) { 57 | } 58 | }); 59 | 60 | - app.listen(port); 61 | + app.listen(port, hostname); 62 | }); 63 | } 64 | 65 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | // packages 2 | import sveltePreprocess from 'svelte-preprocess'; 3 | 4 | export const preprocess = [sveltePreprocess.typescript()]; 5 | --------------------------------------------------------------------------------