├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── elderjs.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── Elder.ts ├── __tests__ │ ├── Elder.spec.ts │ ├── __snapshots__ │ │ ├── Elder.spec.ts.snap │ │ └── externalHelpers.spec.ts.snap │ ├── externalHelpers.spec.ts │ └── workerBuild.spec.ts ├── build │ ├── __tests__ │ │ ├── build.spec.ts │ │ └── parseBuildPerf.spec.ts │ ├── build.ts │ └── parseBuildPerf.ts ├── esbuild │ ├── esbuildBundler.ts │ └── esbuildPluginSvelte.ts ├── externalHelpers.ts ├── hooks │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── hooks.spec.ts.snap │ │ ├── hookEntityDefinitions.spec.ts │ │ └── hooks.spec.ts │ ├── hookEntityDefinitions.ts │ ├── hookInterface.ts │ ├── index.ts │ └── types.ts ├── index.ts ├── partialHydration │ ├── __fixtures__ │ │ └── largeProp.json │ ├── __tests__ │ │ ├── hydrateComponents.spec.ts │ │ ├── inlineSvelteComponent.spec.ts │ │ ├── mountComponentsInHtml.spec.ts │ │ ├── partialHydration.spec.ts │ │ ├── prepareFindSvelteComponent.spec.ts │ │ └── propCompression.spec.ts │ ├── hydrateComponents.ts │ ├── inlineSvelteComponent.ts │ ├── mountComponentsInHtml.ts │ ├── partialHydration.ts │ ├── prepareFindSvelteComponent.ts │ └── propCompression.ts ├── plugins │ ├── __tests__ │ │ └── plugins.spec.ts │ └── index.ts ├── rollup │ ├── __tests__ │ │ ├── __fixtures__ │ │ │ ├── external │ │ │ │ ├── node_modules │ │ │ │ │ └── test-external-svelte-library │ │ │ │ │ │ ├── package.json │ │ │ │ │ │ └── src │ │ │ │ │ │ ├── components │ │ │ │ │ │ ├── Button.svelte │ │ │ │ │ │ ├── Icon.svelte │ │ │ │ │ │ └── Unused.svelte │ │ │ │ │ │ └── index.js │ │ │ │ └── src │ │ │ │ │ ├── components │ │ │ │ │ └── Component.svelte │ │ │ │ │ └── layouts │ │ │ │ │ └── External.svelte │ │ │ └── simple │ │ │ │ └── src │ │ │ │ ├── components │ │ │ │ └── One.svelte │ │ │ │ ├── layouts │ │ │ │ └── Two.svelte │ │ │ │ └── routes │ │ │ │ └── Three.svelte │ │ ├── getRollupConfig.spec.ts │ │ ├── rollupPlugin.spec.ts │ │ └── rollupPlugin.test.ts │ ├── getRollupConfig.ts │ └── rollupPlugin.ts ├── routes │ ├── __tests__ │ │ ├── makeDynamicPermalinkFn.spec.ts │ │ ├── prepareRouter.spec.ts │ │ └── routes.spec.ts │ ├── makeDynamicPermalinkFn.ts │ ├── prepareRouter.ts │ ├── routes.ts │ └── types.ts ├── shortcodes │ ├── __tests__ │ │ └── shortcodes.spec.ts │ ├── index.ts │ └── types.ts ├── utils │ ├── Page.ts │ ├── __tests__ │ │ ├── Page.spec.ts │ │ ├── __snapshots__ │ │ │ ├── Page.spec.ts.snap │ │ │ ├── perf.spec.ts.snap │ │ │ └── validations.spec.ts.snap │ │ ├── asyncForEach.spec.ts │ │ ├── capitalizeFirstLetter.spec.ts │ │ ├── createReadOnlyProxy.spec.ts │ │ ├── getConfig.spec.ts │ │ ├── getPluginLocations.spec.ts │ │ ├── getUniqueId.spec.ts │ │ ├── index.spec.ts │ │ ├── normalizePrefix.spec.ts │ │ ├── normalizeSnapshot.spec.ts │ │ ├── notProduction.spec.ts │ │ ├── outputStyles.spec.ts │ │ ├── perf.spec.ts │ │ ├── permalinks.spec.ts │ │ ├── prepareInlineShortcode.spec.ts │ │ ├── prepareProcessStack.spec.ts │ │ ├── prepareRunHook.spec.ts │ │ ├── prepareServer.spec.ts │ │ ├── prepareShortcodeParser.spec.ts │ │ ├── replaceCircular.spec.ts │ │ ├── shuffleArray.spec.ts │ │ ├── svelteComponent.spec.ts │ │ ├── validations.spec.ts │ │ ├── windowsPathFix.spec.ts │ │ └── wrapPermalinkFn.spec.ts │ ├── asyncForEach.ts │ ├── capitalizeFirstLetter.ts │ ├── createReadOnlyProxy.ts │ ├── fixCircularJson.ts │ ├── getConfig.ts │ ├── getPluginLocations.ts │ ├── getUniqueId.ts │ ├── index.ts │ ├── normalizePrefix.ts │ ├── normalizeSnapshot.ts │ ├── notProduction.ts │ ├── outputStyles.ts │ ├── perf.ts │ ├── permalinks.ts │ ├── prepareInlineShortcode.ts │ ├── prepareProcessStack.ts │ ├── prepareRunHook.ts │ ├── prepareServer.ts │ ├── prepareShortcodeParser.ts │ ├── shuffleArray.ts │ ├── svelteComponent.ts │ ├── types.ts │ ├── validations.ts │ ├── windowsPathFix.ts │ └── wrapPermalinkFn.ts └── workerBuild.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build/** -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2020: true, 5 | 'jest/globals': true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | 'plugin:jest/recommended', 10 | 'prettier/@typescript-eslint', 11 | 'plugin:prettier/recommended', 12 | 'plugin:jest/style', 13 | ], 14 | settings: { 15 | 'import/resolver': { 16 | node: { 17 | extensions: ['.js', '.ts'], 18 | }, 19 | }, 20 | }, 21 | parser: '@typescript-eslint/parser', 22 | parserOptions: { 23 | ecmaVersion: 11, 24 | sourceType: 'module', 25 | }, 26 | plugins: ['@typescript-eslint', 'jest', 'prettier'], 27 | rules: { 28 | 'prettier/prettier': 'error', 29 | // do not require ext. when importing file 30 | 'import/extensions': [ 31 | 'error', 32 | 'ignorePackages', 33 | { 34 | ts: 'never', 35 | }, 36 | ], 37 | 38 | // avoid having warnings when we import types and they are detected as unused imports 39 | 'no-unused-vars': 'off', 40 | '@typescript-eslint/no-unused-vars': ['error'], 41 | 42 | // 43 | 'no-param-reassign': ['warn'], 44 | 'global-require': ['warn'], 45 | 46 | // -- OVERRIDES by choice -- 47 | // allow ForOfStatement 48 | 'no-restricted-syntax': [ 49 | // override aribnb config here to allow for (const ... of ...) https://github.com/airbnb/javascript/blob/64b965efe0355c8290996ff5a675cd8fb30bf843/packages/eslint-config-airbnb-base/rules/style.js#L334-L352 50 | 'error', 51 | { 52 | selector: 'ForInStatement', 53 | message: 54 | 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', 55 | }, 56 | { 57 | selector: 'LabeledStatement', 58 | message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', 59 | }, 60 | { 61 | selector: 'WithStatement', 62 | message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', 63 | }, 64 | ], 65 | // allow console logs 66 | 'no-console': ['off'], 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Elder.js CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | 14 | jobs: 15 | ubuntu: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | 19 | strategy: 20 | matrix: 21 | node-version: [12.x, 14.x] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: npm ci 30 | - run: npm run eslint 31 | - run: npm run build --if-present 32 | - run: npm run test:coverage 33 | - uses: codecov/codecov-action@v1 34 | with: 35 | name: codecov-ci # optional 36 | fail_ci_if_error: true 37 | 38 | windows: 39 | runs-on: windows-latest 40 | timeout-minutes: 10 41 | 42 | strategy: 43 | matrix: 44 | node-version: [12.x, 14.x] 45 | 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Use Node.js ${{ matrix.node-version }} 49 | uses: actions/setup-node@v1 50 | with: 51 | node-version: ${{ matrix.node-version }} 52 | - run: npm ci 53 | - run: npm run build --if-present 54 | - run: npm run test:coverage 55 | - uses: codecov/codecov-action@v1 56 | with: 57 | name: codecov-ci # optional 58 | fail_ci_if_error: true 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /coverage/ 3 | /build/ 4 | 5 | .DS_store 6 | .idea 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand"], 9 | "console": "integratedTerminal", 10 | "internalConsoleOptions": "neverOpen", 11 | "port": 9229 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Elderjs 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 |
2 | Elder.js 3 |
4 | 5 |

Elder.js: an SEO first Svelte Framework & Static Site Generator

6 | 7 |
8 | 9 | version 10 | 11 | 12 | codecov 13 | 14 | 15 | elder.js ci 16 | 17 | 18 | node version 19 | 20 |
21 | 22 |
23 | 24 | [Elder.js](https://elderguide.com/tech/elderjs/) is an opinionated static site generator and web framework built with SEO in mind. (Supports SSR and Static Site Generation.) 25 | 26 | - [Full Docs](https://elderguide.com/tech/elderjs/) 27 | - [Template](https://github.com/Elderjs/template) 28 | - [Plugins](https://github.com/Elderjs/plugins) 29 | 30 | **Features:** 31 | 32 | - [**Build hooks**](https://elderguide.com/tech/elderjs/#hooks-how-to-customize-elderjs) allow you to plug into any part of entire page generation process and customize as needed. 33 | - **A Highly Optimized Build Process:** that will span as many CPU cores as you can throw at it to make building your site as fast as possible. For reference Elder.js easily generates a data intensive 18,000 page site in 8 minutes using a budget 4 core VM. 34 | - **Svelte Everywhere:** Use Svelte for your SSR templates and with partial hydration on the client for tiny html/bundle sizes. 35 | - **Straightforward Data Flow:** By simply associating a `data` function in your `route.js`, you have complete control over how you fetch, prepare, and manipulate data before sending it to your Svelte template. Anything you can do in Node.js, you can do to fetch your data. Multiple data sources, no problem. 36 | - **Community Plugins:** Easily extend what your Elder.js site can do by adding [prebuilt plugins](https://github.com/Elderjs/plugins) to your site. 37 | - **Shortcodes:** Future proof your content, whether it lives in a CMS or in static files using smart placeholders. These shortcodes can be async! 38 | - **0KB JS**: Defaults to 0KB of JS if your page doesn't need JS. 39 | - **Partial Hydration**: Unlike most frameworks, Elder.js lets you hydrate just the parts of the client that need to be interactive allowing you to dramatically reduce your payloads while still having full control over component lazy-loading, preloading, and eager-loading. 40 | 41 | **Context** 42 | 43 | Elder.js is the result of our team's work to build this site ([ElderGuide.com](https://elderguide.com)) and was purpose built to solve the unique challenges of building flagship SEO sites with 10-100k+ pages. 44 | 45 | Elder Guide Co-Founder [Nick Reese](https://nicholasreese.com) has built or managed 5 major SEO properties over the past 14 years. After leading the transition of several complex sites to static site generators he loved the benefits of the JAM stack, but wished there was a better solution for complex, data intensive, projects. Elder.js is his vision for how static site generators can become viable for sites of all sizes regardless of the number of pages or how complex the data being presented is. 46 | 47 | We hope you find this project useful whether you're building a small personal blog or a flagship SEO site that impacts millions of users. 48 | 49 | ## Project Status: Stable 50 | 51 | Elder.js is stable and production ready. 52 | 53 | It is being used on ElderGuide.com and 2 other flagship SEO properties that are managed by the maintainers of this project. 54 | 55 | We believe Elder.js has reached a level of maturity where we have achieved the majority of the vision we had for the project when we set out to build a static site generator. 56 | 57 | Our goal is to keep the hookInterface, plugin interface, and general structure of the project as static as possible. 58 | 59 | This is a lot of words to say we’re not looking to ship a bunch of breaking changes any time soon, but will be shipping bug fixes and incremental changes that are mostly “under the hood.” 60 | 61 | The ElderGuide.com team expects to maintain this project until 2023-2024. For a clearer vision of what we mean by this and what to expect from the Elder.js team as far as what is considered "in scope" and what isn't, [please see this comment](https://github.com/Elderjs/elderjs/issues/31#issuecomment-690694857). 62 | 63 | ## Getting Started: 64 | 65 | The quickest way to get started is to get started with the [Elder.js template](https://github.com/Elderjs/template) using [degit](https://github.com/Rich-Harris/degit): 66 | 67 | ```sh 68 | npx degit Elderjs/template elderjs-app 69 | 70 | cd elderjs-app 71 | 72 | npm install # or "yarn" 73 | 74 | npm start 75 | 76 | open http://localhost:3000 77 | ``` 78 | 79 | This spawns a development server, so simply edit a file in `src`, save it, and reload the page to see your changes. 80 | 81 | Here is a demo of the template: [https://elderjs.pages.dev/](https://elderjs.pages.dev/) 82 | 83 | ### To Build/Serve HTML Locally: 84 | 85 | ```bash 86 | npm run build 87 | ``` 88 | 89 | Let the build finish. 90 | 91 | ```bash 92 | npx sirv-cli public 93 | ``` 94 | 95 | ## Full documentation here: https://elderguide.com/tech/elderjs/ 96 | -------------------------------------------------------------------------------- /elderjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elderjs/elderjs/6dae253bc12b438d71b12bb78365991284f536fa/elderjs.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ['/node_modules/', '/build/'], 5 | collectCoverageFrom: ['src/**/*.ts'], 6 | coverageReporters: ['json', 'lcov', 'text', 'text-summary'], 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elderjs/elderjs", 3 | "version": "1.7.5", 4 | "main": "./build/index.js", 5 | "types": "./build/index.d.ts", 6 | "engineStrict": true, 7 | "engines": { 8 | "node": ">= 12.0.0" 9 | }, 10 | "scripts": { 11 | "build": "rimraf ./build && tsc", 12 | "dev": "tsc -w", 13 | "prepare": "npm run build", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:coverage": "jest --collect-coverage", 17 | "eslint": "eslint --ext .ts .", 18 | "eslint:fix": "eslint --fix --ext .ts ." 19 | }, 20 | "files": [ 21 | "build/**/*" 22 | ], 23 | "dependencies": { 24 | "@babel/core": "^7.13.10", 25 | "@elderjs/shortcodes": "^2.0.2", 26 | "@rollup/plugin-commonjs": "^17.1.0", 27 | "@rollup/plugin-json": "^4.1.0", 28 | "@rollup/plugin-node-resolve": "^11.2.0", 29 | "@rollup/plugin-replace": "^2.4.2", 30 | "btoa": "^1.2.1", 31 | "chokidar": "^3.5.1", 32 | "clean-css": "^5.1.1", 33 | "cli-progress": "^3.9.0", 34 | "cosmiconfig": "^7.0.0", 35 | "del": "^6.0.0", 36 | "devalue": "^2.0.1", 37 | "esbuild": "^0.12.29", 38 | "fs-extra": "^9.1.0", 39 | "glob": "^7.1.6", 40 | "lodash.defaultsdeep": "^4.6.1", 41 | "lodash.get": "^4.4.2", 42 | "lodash.kebabcase": "^4.1.1", 43 | "nanoid": "^3.3.4", 44 | "regexparam": "^2.0.0", 45 | "rollup": "^2.51.2", 46 | "rollup-plugin-babel": "^4.4.0", 47 | "rollup-plugin-multi-input": "^1.2.0", 48 | "rollup-plugin-terser": "^7.0.2", 49 | "route-sort": "^1.0.0", 50 | "spark-md5": "^3.0.1", 51 | "svelte": "^3.38.3", 52 | "yup": "^0.29.3" 53 | }, 54 | "devDependencies": { 55 | "@types/fs-extra": "^9.0.8", 56 | "@types/jest": "^26.0.20", 57 | "@types/node": "^14.14.33", 58 | "@typescript-eslint/eslint-plugin": "^4.28.1", 59 | "@typescript-eslint/parser": "^4.28.1", 60 | "cz-conventional-changelog": "^3.3.0", 61 | "eslint": "^7.21.0", 62 | "eslint-config-airbnb-base": "^14.2.1", 63 | "eslint-config-prettier": "^6.11.0", 64 | "eslint-plugin-import": "^2.22.1", 65 | "eslint-plugin-jest": "^24.2.1", 66 | "eslint-plugin-prettier": "^3.1.4", 67 | "jest": "^26.6.3", 68 | "locate-character": "^2.0.5", 69 | "prettier": "^2.3.2", 70 | "rimraf": "^3.0.2", 71 | "ts-jest": "^26.5.3", 72 | "ts-node": "^9.1.1", 73 | "typescript": "^4.7.2" 74 | }, 75 | "description": "Elder.js is an opinionated static site generator and web framework built with SEO in mind.", 76 | "repository": { 77 | "type": "git", 78 | "url": "git+https://github.com/Elderjs/elderjs.git" 79 | }, 80 | "keywords": [ 81 | "svelte", 82 | "seo", 83 | "static", 84 | "site", 85 | "generator", 86 | "ssg", 87 | "sveltejs" 88 | ], 89 | "author": "Nick Reese", 90 | "license": "MIT", 91 | "bugs": { 92 | "url": "https://github.com/Elderjs/elderjs/issues" 93 | }, 94 | "homepage": "https://elderguide.com/tech/elderjs/", 95 | "config": { 96 | "commitizen": { 97 | "path": "./node_modules/cz-conventional-changelog" 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/__tests__/Elder.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | import path, { sep } from 'path'; 3 | import normalizeSnapshot from '../utils/normalizeSnapshot'; 4 | 5 | describe('#Elder', () => { 6 | jest.mock(`..${sep}routes${sep}routes`, () => () => ({ 7 | 'route-a': { 8 | hooks: [ 9 | { 10 | hook: 'routeHook', 11 | name: 'route hook', 12 | description: 'test', 13 | run: jest.fn(), 14 | }, 15 | ], 16 | }, 17 | 'test-b': { hooks: [] }, 18 | })); 19 | 20 | jest.mock(`..${sep}utils${sep}getConfig`, () => () => ({ 21 | $$internal: { 22 | clientComponents: `test${sep}public${sep}svelte`, 23 | ssrComponents: `test${sep}___ELDER___${sep}compiled`, 24 | findComponent: () => ({ ssr: true, client: true, iife: undefined }), 25 | }, 26 | debug: { 27 | automagic: true, 28 | }, 29 | distDir: `test${sep}public`, 30 | rootDir: 'test', 31 | srcDir: `test${sep}src`, 32 | server: { 33 | prefix: `/dev`, 34 | }, 35 | build: { 36 | shuffleRequests: false, 37 | numberOfWorkers: 4, 38 | }, 39 | plugins: { 40 | 'elder-plugin-upload-s3': { 41 | dataBucket: 'elderguide.com', 42 | htmlBucket: 'elderguide.com', 43 | deployId: '11111111', 44 | }, 45 | }, 46 | hooks: { 47 | disable: ['randomHook'], 48 | }, 49 | })); 50 | 51 | jest.mock(`..${sep}workerBuild`); 52 | 53 | jest.mock( 54 | `..${sep}utils${sep}prepareRunHook`, 55 | () => (page) => 56 | async function processHook(hook) { 57 | if (hook === 'bootstrap' && page.hooks && page.hooks.length) { 58 | for (const pluginHook of page.hooks) { 59 | if (pluginHook.$$meta.type === 'plugin') { 60 | // eslint-disable-next-line 61 | await pluginHook.run({}); 62 | } 63 | } 64 | } 65 | return null; 66 | }, 67 | ); 68 | beforeEach(() => jest.resetModules()); 69 | 70 | it('hookSrcFile not found', async () => { 71 | jest.mock('../utils/validations', () => ({ 72 | validatePlugin: (i) => i, 73 | validateHook: (i) => i, 74 | validateRoute: (i) => i, 75 | validateShortcode: (i) => i, 76 | })); 77 | jest.mock('fs-extra', () => ({ 78 | existsSync: () => true, 79 | })); 80 | jest.mock('test/___ELDER___/compiled/fakepath/Test.js', () => () => ({}), { virtual: true }); 81 | jest.mock( 82 | 'test/__ELDER__/hooks.js', 83 | () => ({ 84 | default: [ 85 | { 86 | hook: 'bootstrap', 87 | name: 'test hook from file', 88 | description: 'just for testing', 89 | run: () => jest.fn(), 90 | }, 91 | ], 92 | }), 93 | { virtual: true }, 94 | ); 95 | jest.mock( 96 | path.resolve(process.cwd(), `./test/src/plugins/elder-plugin-upload-s3/index.js`), 97 | () => ({ 98 | hooks: [], 99 | routes: { 'test-a': { hooks: [], template: 'fakepath/Test.svelte', all: [] }, 'test-b': { data: () => {} } }, 100 | config: {}, 101 | name: 'test', 102 | description: 'test', 103 | init: jest.fn(), 104 | }), 105 | { 106 | virtual: true, 107 | }, 108 | ); 109 | // eslint-disable-next-line import/no-dynamic-require 110 | const { Elder } = require(`..${sep}index`); 111 | const elder = await new Elder({ context: 'server', worker: false }); 112 | await elder.bootstrap(); 113 | await elder.worker([]); 114 | delete elder.perf.timings; 115 | expect(normalizeSnapshot(elder)).toMatchSnapshot(); 116 | }); 117 | 118 | it('srcPlugin found', async () => { 119 | jest.mock(`..${sep}utils${sep}validations`, () => ({ 120 | validatePlugin: (i) => i, 121 | validateHook: (i) => i, 122 | validateRoute: (i) => i, 123 | validateShortcode: (i) => i, 124 | })); 125 | jest.mock('fs-extra', () => ({ 126 | existsSync: () => true, 127 | })); 128 | jest.mock( 129 | `${process.cwd()}${sep}test${sep}___ELDER___${sep}compiled${sep}fakepath${sep}Test.js`, 130 | () => () => ({}), 131 | { virtual: true }, 132 | ); 133 | jest.mock( 134 | `${process.cwd()}${sep}test${sep}src${sep}hooks.js`, 135 | () => ({ 136 | default: [ 137 | { 138 | hook: 'bootstrap', 139 | name: 'test hook from file', 140 | description: 'just for testing', 141 | run: () => jest.fn(), 142 | }, 143 | ], 144 | }), 145 | { virtual: true }, 146 | ); 147 | jest.mock( 148 | `${process.cwd()}${sep}test${sep}src${sep}plugins${sep}elder-plugin-upload-s3${sep}index.js`, 149 | () => ({ 150 | hooks: [ 151 | { 152 | hook: 'customizeHooks', 153 | name: 'test hook', 154 | description: 'just for testing', 155 | run: () => Promise.resolve({ plugin: 'elder-plugin-upload-s3' }), 156 | $$meta: { 157 | type: 'hooks.js', 158 | addedBy: 'validations.spec.ts', 159 | }, 160 | }, 161 | { 162 | hook: 'bootstrap', 163 | name: 'test hook 2', 164 | description: 'just for testing', 165 | run: () => Promise.resolve({}), 166 | $$meta: { 167 | type: 'hooks.js', 168 | addedBy: 'validations.spec.ts', 169 | }, 170 | }, 171 | { 172 | hook: 'bootstrap', 173 | name: 'test hook 3', 174 | description: 'just for testing', 175 | run: () => Promise.resolve(null), 176 | $$meta: { 177 | type: 'hooks.js', 178 | addedBy: 'validations.spec.ts', 179 | }, 180 | }, 181 | ], 182 | routes: { 183 | 'test-a': { 184 | hooks: [], 185 | template: `fakepath${sep}Test.svelte`, 186 | all: () => Promise.resolve([{ slug: `${sep}test` }]), 187 | permalink: () => '/', 188 | }, 189 | 'test-b': { data: () => {}, all: [], permalink: () => '/' }, 190 | }, 191 | config: {}, 192 | name: 'test', 193 | description: 'test', 194 | init: jest.fn().mockImplementation((p) => p), 195 | }), 196 | { 197 | virtual: true, 198 | }, 199 | ); 200 | // eslint-disable-next-line import/no-dynamic-require 201 | const { Elder } = require(`..${sep}index`); 202 | const elder = await new Elder({ context: 'server', worker: false }); 203 | await elder.bootstrap(); 204 | 205 | delete elder.perf.timings; 206 | expect(normalizeSnapshot(elder)).toMatchSnapshot(); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/externalHelpers.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#externalHelpers works - userHelpers is a function 1`] = ` 4 | Object { 5 | "userHelper": [Function], 6 | } 7 | `; 8 | 9 | exports[`#externalHelpers works - userHelpers is a function 2`] = ` 10 | Object { 11 | "userHelper": [Function], 12 | } 13 | `; 14 | 15 | exports[`#externalHelpers works - userHelpers is not a function 1`] = ` 16 | Object { 17 | "userHelper": [Function], 18 | } 19 | `; 20 | 21 | exports[`#externalHelpers works - userHelpers is not a function 2`] = ` 22 | Object { 23 | "userHelper": [Function], 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /src/__tests__/externalHelpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { sep } from 'path'; 2 | import normalizeSnapshot from '../utils/normalizeSnapshot'; 3 | 4 | process.cwd = () => 'test'; 5 | 6 | const settings = { 7 | debug: { 8 | automagic: true, 9 | }, 10 | srcDir: `.${sep}src${sep}`, 11 | }; 12 | const query = {}; 13 | 14 | class StatSyncError extends Error { 15 | code: 'ENOENT'; 16 | 17 | constructor(msg: string) { 18 | super(msg); 19 | this.code = 'ENOENT'; 20 | } 21 | } 22 | 23 | describe('#externalHelpers', () => { 24 | beforeEach(() => jest.resetModules()); 25 | it('throws', async () => { 26 | jest.mock('fs', () => ({ 27 | statSync: jest.fn(() => { 28 | throw new StatSyncError('no file'); 29 | }), 30 | })); 31 | // eslint-disable-next-line global-require 32 | const externalHelpers = require('../externalHelpers').default; 33 | // @ts-ignore 34 | expect(await externalHelpers({ settings, query, helpers: [] })).toBeUndefined(); 35 | const modifiedSettings = { 36 | ...settings, 37 | debug: { automagic: false }, 38 | srcDir: '', 39 | }; 40 | expect( 41 | await externalHelpers({ 42 | settings: modifiedSettings, 43 | query, 44 | helpers: [], 45 | }), 46 | ).toBeUndefined(); 47 | }); 48 | it('returns undefined if file is not there', async () => { 49 | jest.mock('fs', () => ({ 50 | statSync: jest.fn().mockImplementationOnce(() => { 51 | throw new Error(''); 52 | }), 53 | })); 54 | // eslint-disable-next-line global-require 55 | const externalHelpers = require('../externalHelpers').default; 56 | // @ts-ignore 57 | expect(await externalHelpers({ settings, query, helpers: [] })).toBeUndefined(); 58 | }); 59 | it('works - userHelpers is not a function', async () => { 60 | jest.mock( 61 | `src${sep}helpers${sep}index.js`, 62 | 63 | () => ({ 64 | userHelper: () => 'something', 65 | }), 66 | { virtual: true }, 67 | ); 68 | jest.mock('fs', () => ({ 69 | statSync: jest.fn().mockImplementationOnce(() => {}), 70 | })); 71 | // eslint-disable-next-line global-require 72 | const externalHelpers = require('../externalHelpers').default; 73 | // @ts-ignore 74 | const c1 = await externalHelpers({ settings, query, helpers: [] }); 75 | expect(normalizeSnapshot(c1)).toMatchSnapshot(); 76 | // from cache 77 | // @ts-ignore 78 | const c2 = await externalHelpers({ settings, query, helpers: [] }); 79 | expect(normalizeSnapshot(c2)).toMatchSnapshot(); 80 | }); 81 | it('works - userHelpers is a function', async () => { 82 | jest.mock( 83 | `src${sep}helpers${sep}index.js`, 84 | () => () => 85 | Promise.resolve({ 86 | userHelper: () => 'something', 87 | }), 88 | { virtual: true }, 89 | ); 90 | jest.mock('fs', () => ({ 91 | statSync: jest.fn().mockImplementationOnce(() => {}), 92 | })); 93 | // eslint-disable-next-line global-require 94 | const externalHelpers = require('../externalHelpers').default; 95 | // @ts-ignore 96 | const c1 = await externalHelpers({ settings, query, helpers: [] }); 97 | expect(normalizeSnapshot(c1)).toMatchSnapshot(); 98 | // from cache 99 | // @ts-ignore 100 | const c2 = await externalHelpers({ settings, query, helpers: [] }); 101 | expect(normalizeSnapshot(c2)).toMatchSnapshot(); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/__tests__/workerBuild.spec.ts: -------------------------------------------------------------------------------- 1 | const input = { 2 | settings: { server: { prefix: '/dev' } }, 3 | query: { 4 | db: { 5 | db: {}, 6 | pool: {}, 7 | cnString: { 8 | connectionString: 'postgresql://user:user@localhost:5099/db', 9 | }, 10 | }, 11 | }, 12 | helpers: { 13 | permalinks: {}, 14 | metersInAMile: 0.00062137119224, 15 | }, 16 | data: {}, 17 | routes: { 18 | state: { 19 | hooks: [], 20 | permalink: jest.fn(), 21 | all: jest.fn(), 22 | template: 'State.svelte', 23 | parent: 'home', 24 | breadcrumbLabel: jest.fn(), 25 | templateComponent: jest.fn(), 26 | data: jest.fn(), 27 | layout: jest.fn(), 28 | $$meta: { type: 'route', addedBy: 'routejs' }, 29 | }, 30 | }, 31 | allRequests: [ 32 | { 33 | id: 37, 34 | slug: 'north-dakota', 35 | random: 82, 36 | route: 'state', 37 | type: 'server', 38 | premalink: '/north-dakota/', 39 | }, 40 | { 41 | id: 38, 42 | slug: 'south-dakota', 43 | random: 42, 44 | route: 'state', 45 | type: 'server', 46 | premalink: '/south-dakota/', 47 | }, 48 | ], 49 | runHook: jest.fn(), 50 | errors: [], 51 | customProps: {}, 52 | }; 53 | 54 | describe('#workerBuild', () => { 55 | beforeEach(() => { 56 | jest.resetModules(); 57 | }); 58 | 59 | it('has build error', async () => { 60 | jest.mock('../utils/Page', () => { 61 | return jest.fn().mockImplementation(() => ({ 62 | build: () => Promise.resolve({ errors: ['test error'], timings: [{ name: 'foo', duration: 500 }] }), 63 | })); 64 | }); 65 | 66 | const processSendMock = jest.fn(); 67 | process.send = processSendMock; 68 | // eslint-disable-next-line global-require 69 | const workerBuild = require('../workerBuild').default; 70 | expect(await workerBuild({ bootstrapComplete: Promise.resolve(input), workerRequests: input.allRequests })).toEqual( 71 | { 72 | errors: [ 73 | { 74 | errors: ['test error'], 75 | request: { 76 | id: 37, 77 | premalink: '/north-dakota/', 78 | random: 82, 79 | route: 'state', 80 | slug: 'north-dakota', 81 | type: 'server', 82 | }, 83 | }, 84 | { 85 | errors: ['test error'], 86 | request: { 87 | id: 38, 88 | premalink: '/south-dakota/', 89 | random: 42, 90 | route: 'state', 91 | slug: 'south-dakota', 92 | type: 'server', 93 | }, 94 | }, 95 | ], 96 | timings: [[{ duration: 500, name: 'foo' }], [{ duration: 500, name: 'foo' }]], 97 | }, 98 | ); 99 | expect(processSendMock).toHaveBeenCalledTimes(3); 100 | }); 101 | 102 | it('works', async () => { 103 | jest.mock('../utils/Page', () => { 104 | return jest.fn().mockImplementation(() => ({ 105 | build: () => Promise.resolve({ errors: [], timings: [{ name: 'foo', duration: 500 }] }), 106 | })); 107 | }); 108 | 109 | const processSendMock = jest.fn(); 110 | process.send = processSendMock; 111 | // eslint-disable-next-line global-require 112 | const workerBuild = require('../workerBuild').default; 113 | expect(await workerBuild({ bootstrapComplete: Promise.resolve(input), workerRequests: input.allRequests })).toEqual( 114 | { 115 | errors: [], 116 | timings: [[{ duration: 500, name: 'foo' }], [{ duration: 500, name: 'foo' }]], 117 | }, 118 | ); 119 | expect(processSendMock).toHaveBeenCalledTimes(3); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/build/__tests__/parseBuildPerf.spec.ts: -------------------------------------------------------------------------------- 1 | import parseBuildPerf from '../parseBuildPerf'; 2 | 3 | test('#parseBuildPerf', async () => { 4 | expect(parseBuildPerf([])).toEqual({}); 5 | expect(parseBuildPerf([[]])).toEqual({}); 6 | expect(parseBuildPerf([[{ name: '1.1.1.1', duration: 1 }]])).toEqual({ '1': { '1': { '1': { '1': 1 } } } }); 7 | expect( 8 | parseBuildPerf([ 9 | [ 10 | { name: '1', duration: 10 }, 11 | { name: '1.1.1.1', duration: 1 }, 12 | ], 13 | ]), 14 | // FIXME: rounding error? 15 | ).toEqual({ '1': { avg: 100, '1': { '1': { '1': 1 } } } }); 16 | expect( 17 | parseBuildPerf([ 18 | [ 19 | { name: '1.1', duration: 10 }, 20 | { name: '1.1.1.1', duration: 1 }, 21 | ], 22 | ]), 23 | ).toEqual({ '1': { '1': { avg: 10, '1': { '1': 1 } } } }); 24 | expect(parseBuildPerf([[{ name: '1.1.1', duration: 1 }]])).toEqual({ '1': { '1': { '1': 1 } } }); 25 | expect(parseBuildPerf([[{ name: 'foo', duration: -5 }]])).toEqual({ foo: -50 }); 26 | expect(parseBuildPerf([[{ name: 'bar', duration: 0 }], [{ name: 'foo', duration: -5 }]])).toEqual({ 27 | foo: -50, 28 | bar: 0, 29 | }); 30 | expect( 31 | parseBuildPerf([ 32 | [{ name: 'foo.bar', duration: 0 }], 33 | [{ name: 'foo', duration: 0 }], 34 | [{ name: 'bar.foo', duration: -5 }], 35 | [{ name: 'bar.foo', duration: -10 }], 36 | [{ name: 'bar', duration: -15 }], 37 | ]), 38 | ).toEqual({ 39 | foo: { bar: 0 }, 40 | bar: { foo: -7.5, avg: -150 }, 41 | }); 42 | expect( 43 | parseBuildPerf([ 44 | [{ name: 'foo', duration: 30 }], 45 | [{ name: 'bar', duration: 55 }], 46 | [{ name: 'foo.bar.1', duration: 15 }], 47 | // [{ name: 'foo.bar', duration: 30 }], 48 | [{ name: 'bar.foo.1', duration: 55 }], 49 | // [{ name: 'bar.foo', duration: 55 }], 50 | // [{ name: 'bar', duration: 55 }], 51 | ]), 52 | ).toEqual({ 53 | foo: { bar: { '1': 15 }, avg: 300 }, 54 | bar: { foo: { '1': 55 }, avg: 550 }, 55 | }); 56 | expect( 57 | parseBuildPerf([ 58 | [ 59 | { 60 | name: 'task.a.1.1', 61 | duration: 7520, 62 | }, 63 | { 64 | name: 'task.b.2.1', 65 | duration: 400, 66 | }, 67 | { 68 | name: 'task.c.3.1', 69 | duration: 180, 70 | }, 71 | { 72 | name: 'task', 73 | duration: 8100, 74 | }, 75 | { 76 | name: 'task.a', 77 | duration: 7520, 78 | }, 79 | { 80 | name: 'task.a.1', 81 | duration: 7520, 82 | }, 83 | ], 84 | [ 85 | { 86 | name: 'task.a.1.1', 87 | duration: 6894, 88 | }, 89 | { 90 | name: 'task.b.2.1', 91 | duration: 321, 92 | }, 93 | { 94 | name: 'task.c.3.1', 95 | duration: 255, 96 | }, 97 | { 98 | name: 'task', 99 | duration: 7470, 100 | }, 101 | ], 102 | [ 103 | { 104 | name: 'task.c.4.1', 105 | duration: 510, 106 | }, 107 | { 108 | name: 'task.c.4.1', 109 | duration: 525, 110 | }, 111 | { 112 | name: 'task.c.5.1', 113 | duration: 510, 114 | }, 115 | { 116 | name: 'task', 117 | duration: 1545, 118 | }, 119 | ], 120 | ]), 121 | ).toEqual({ 122 | task: { 123 | avg: 57050, 124 | a: { 125 | avg: 7520, 126 | '1': { 127 | avg: 7520, 128 | '1': 7207, 129 | }, 130 | }, 131 | b: { 132 | '2': { 133 | '1': 360.5, 134 | }, 135 | }, 136 | c: { 137 | '3': { '1': 217.5 }, 138 | '4': { '1': 517.5 }, 139 | '5': { '1': 510 }, 140 | }, 141 | }, 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /src/build/parseBuildPerf.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { Timing } from '../utils/types'; 3 | 4 | function parseBuildPerf(timings: Array): any { 5 | const counts = {}; 6 | 7 | timings.forEach((request) => { 8 | request.forEach((timing) => { 9 | if (typeof counts[timing.name] !== 'object') { 10 | counts[timing.name] = { 11 | sum: 0, 12 | count: 0, 13 | }; 14 | } 15 | counts[timing.name].sum += timing.duration; 16 | counts[timing.name].count += 1; 17 | }); 18 | }); 19 | 20 | return Object.keys(counts) 21 | .map((key) => [key.split('.'), counts[key]]) 22 | .sort((a, b) => a[0].length - b[0].length) 23 | .reduce((out, cv) => { 24 | const [root, subkey, detail, more] = cv[0]; 25 | const { sum, count } = cv[1]; 26 | 27 | if (root && subkey && detail && more) { 28 | if (!out[root]) out[root] = {}; 29 | if (typeof out[root] === 'number') { 30 | out[root] = { 31 | avg: out[root], 32 | }; 33 | } 34 | 35 | if (!out[root][subkey]) out[root][subkey] = {}; 36 | if (typeof out[root][subkey] === 'number') { 37 | out[root][subkey] = { 38 | avg: out[root][subkey], 39 | }; 40 | } 41 | 42 | if (!out[root][subkey][detail]) out[root][subkey][detail] = {}; 43 | if (typeof out[root][subkey][detail] === 'number') { 44 | out[root][subkey][detail] = { 45 | avg: out[root][subkey][detail], 46 | }; 47 | } 48 | 49 | out[root][subkey][detail][more] = Math.round((sum / count) * 1000) / 1000; 50 | } else if (root && subkey && detail) { 51 | if (!out[root]) out[root] = {}; 52 | if (typeof out[root] === 'number') { 53 | out[root] = { 54 | avg: out[root], 55 | }; 56 | } 57 | 58 | if (!out[root][subkey]) out[root][subkey] = {}; 59 | if (typeof out[root][subkey] === 'number') { 60 | out[root][subkey] = { 61 | avg: out[root][subkey], 62 | }; 63 | } 64 | out[root][subkey][detail] = Math.round((sum / count) * 1000) / 1000; 65 | } else if (root && subkey) { 66 | if (!out[root]) out[root] = {}; 67 | if (typeof out[root] === 'number') { 68 | out[root] = { 69 | avg: out[root], 70 | }; 71 | } 72 | out[root][subkey] = Math.round((sum / count) * 1000) / 1000; 73 | } else if (root) { 74 | if (!out[root]) out[root] = Math.round((sum / count) * 1000) / 100; 75 | } 76 | 77 | return out; 78 | }, {}); 79 | } 80 | 81 | export default parseBuildPerf; 82 | -------------------------------------------------------------------------------- /src/esbuild/esbuildBundler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-dynamic-require */ 2 | // reload the build process when svelte files are added clearing the cache. 3 | 4 | // server that reloads the app which watches the file system for changes. 5 | // reload can also be called after esbuild finishes the rebuild. 6 | // the file watcher should restart the entire esbuild process when a new svelte file is seen. This includes clearing caches. 7 | 8 | import { build, BuildResult } from 'esbuild'; 9 | import glob from 'glob'; 10 | import path from 'path'; 11 | 12 | import fs from 'fs-extra'; 13 | 14 | // eslint-disable-next-line import/no-unresolved 15 | import { PreprocessorGroup } from 'svelte/types/compiler/preprocess/types'; 16 | import esbuildPluginSvelte from './esbuildPluginSvelte'; 17 | import { InitializationOptions, SettingsOptions } from '../utils/types'; 18 | import { getElderConfig } from '..'; 19 | import { devServer } from '../rollup/rollupPlugin'; 20 | import getPluginLocations from '../utils/getPluginLocations'; 21 | 22 | const production = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'PRODUCTION'; 23 | export type TPreprocess = PreprocessorGroup | PreprocessorGroup[] | false; 24 | export type TSvelteHandler = { 25 | config: SettingsOptions; 26 | preprocess: TPreprocess; 27 | }; 28 | 29 | export function getSvelteConfig(elderConfig: SettingsOptions): TPreprocess { 30 | const svelteConfigPath = path.resolve(elderConfig.rootDir, `./svelte.config.js`); 31 | if (fs.existsSync(svelteConfigPath)) { 32 | try { 33 | // eslint-disable-next-line import/no-dynamic-require 34 | const req = require(svelteConfigPath); 35 | if (req) { 36 | return req; 37 | } 38 | } catch (err) { 39 | if (err.code === 'MODULE_NOT_FOUND') { 40 | console.warn(`Unable to load svelte.config.js from ${svelteConfigPath}`, err); 41 | } 42 | return false; 43 | } 44 | } 45 | return false; 46 | } 47 | 48 | export function getPackagesWithSvelte(pkg, elderConfig: SettingsOptions) { 49 | const pkgs = [] 50 | .concat(pkg.dependents ? Object.keys(pkg.dependents) : []) 51 | .concat(pkg.devDependencies ? Object.keys(pkg.devDependencies) : []); 52 | const sveltePackages = pkgs.reduce((out, cv) => { 53 | try { 54 | const resolved = path.resolve(elderConfig.rootDir, `./node_modules/${cv}/package.json`); 55 | const current = require(resolved); 56 | if (current.svelte) { 57 | out.push(cv); 58 | } 59 | } catch (e) { 60 | // 61 | } 62 | return out; 63 | }, []); 64 | return sveltePackages; 65 | } 66 | 67 | const getRestartHelper = (startOrRestartServer) => { 68 | let state; 69 | const defaultState = { ssr: false, client: false }; 70 | const resetState = () => { 71 | state = JSON.parse(JSON.stringify(defaultState)); 72 | }; 73 | 74 | resetState(); 75 | 76 | // eslint-disable-next-line consistent-return 77 | return (type: 'start' | 'reset' | 'client' | 'ssr') => { 78 | if (type === 'start') { 79 | return startOrRestartServer(); 80 | } 81 | if (type === 'reset') { 82 | return resetState(); 83 | } 84 | 85 | state[type] = true; 86 | if (state.ssr && state.client) { 87 | startOrRestartServer(); 88 | resetState(); 89 | } 90 | }; 91 | }; 92 | 93 | // eslint-disable-next-line consistent-return 94 | const svelteHandler = async ({ elderConfig, svelteConfig, replacements, restartHelper }) => { 95 | try { 96 | const builders: { ssr?: BuildResult; client?: BuildResult } = {}; 97 | 98 | // eslint-disable-next-line global-require 99 | const pkg = require(path.resolve(elderConfig.rootDir, './package.json')); 100 | const globPath = path.resolve(elderConfig.rootDir, `./src/**/*.svelte`); 101 | const initialEntryPoints = glob.sync(globPath); 102 | const sveltePackages = getPackagesWithSvelte(pkg, elderConfig); 103 | const elderPlugins = getPluginLocations(elderConfig); 104 | 105 | builders.ssr = await build({ 106 | entryPoints: [...initialEntryPoints, ...elderPlugins.files], 107 | bundle: true, 108 | outdir: elderConfig.$$internal.ssrComponents, 109 | plugins: [ 110 | esbuildPluginSvelte({ 111 | type: 'ssr', 112 | sveltePackages, 113 | elderConfig, 114 | svelteConfig, 115 | }), 116 | ], 117 | watch: { 118 | onRebuild(error) { 119 | restartHelper('ssr'); 120 | if (error) console.error('ssr watch build failed:', error); 121 | }, 122 | }, 123 | format: 'cjs', 124 | target: ['node12'], 125 | platform: 'node', 126 | sourcemap: !production, 127 | minify: production, 128 | outbase: 'src', 129 | external: pkg.dependents ? [...Object.keys(pkg.dependents)] : [], 130 | define: { 131 | 'process.env.componentType': "'server'", 132 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 133 | ...replacements, 134 | }, 135 | }); 136 | 137 | builders.client = await build({ 138 | entryPoints: [...initialEntryPoints.filter((i) => i.includes('src/components')), ...elderPlugins.files], 139 | bundle: true, 140 | outdir: elderConfig.$$internal.clientComponents, 141 | entryNames: '[dir]/[name].[hash]', 142 | plugins: [ 143 | esbuildPluginSvelte({ 144 | type: 'client', 145 | sveltePackages, 146 | elderConfig, 147 | svelteConfig, 148 | }), 149 | ], 150 | watch: { 151 | onRebuild(error) { 152 | if (error) console.error('client watch build failed:', error); 153 | restartHelper('client'); 154 | }, 155 | }, 156 | format: 'esm', 157 | target: ['es2020'], 158 | platform: 'browser', 159 | sourcemap: !production, 160 | minify: true, 161 | splitting: true, 162 | chunkNames: 'chunks/[name].[hash]', 163 | logLevel: 'error', 164 | outbase: 'src', 165 | define: { 166 | 'process.env.componentType': "'browser'", 167 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 168 | ...replacements, 169 | }, 170 | }); 171 | 172 | restartHelper('start'); 173 | 174 | const restart = async () => { 175 | if (builders.ssr) await builders.ssr.stop(); 176 | if (builders.client) await builders.client.stop(); 177 | restartHelper('reset'); 178 | return svelteHandler({ 179 | elderConfig, 180 | svelteConfig, 181 | replacements, 182 | restartHelper, 183 | }); 184 | }; 185 | 186 | return restart; 187 | } catch (e) { 188 | console.error(e); 189 | } 190 | }; 191 | 192 | type TEsbuildBundler = { 193 | initializationOptions?: InitializationOptions; 194 | replacements?: { [key: string]: string | boolean }; 195 | }; 196 | 197 | const esbuildBundler = async ({ initializationOptions = {}, replacements = {} }: TEsbuildBundler = {}) => { 198 | try { 199 | const elderConfig = getElderConfig(initializationOptions); 200 | const svelteConfig = getSvelteConfig(elderConfig); 201 | 202 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 203 | const { startOrRestartServer, startWatcher, childProcess } = devServer({ 204 | forceStart: true, 205 | elderConfig, 206 | }); 207 | 208 | const restartHelper = getRestartHelper(startOrRestartServer); 209 | 210 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 211 | const restartEsbuild = await svelteHandler({ 212 | elderConfig, 213 | svelteConfig, 214 | replacements, 215 | restartHelper, 216 | }); 217 | 218 | startWatcher(); 219 | } catch (e) { 220 | console.log(e); 221 | } 222 | }; 223 | export default esbuildBundler; 224 | -------------------------------------------------------------------------------- /src/esbuild/esbuildPluginSvelte.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsPromises } from 'fs'; 2 | import { dirname, resolve, isAbsolute, sep, relative } from 'path'; 3 | // eslint-disable-next-line import/no-unresolved 4 | 5 | import { Plugin, PartialMessage } from 'esbuild'; 6 | 7 | import del from 'del'; 8 | import crypto from 'crypto'; 9 | import fs from 'fs-extra'; 10 | 11 | // eslint-disable-next-line import/no-unresolved 12 | import { PreprocessorGroup } from 'svelte/types/compiler/preprocess/types'; 13 | import { resolveFn, minifyCss, transformFn, loadCss } from '../rollup/rollupPlugin'; 14 | import { SettingsOptions } from '..'; 15 | 16 | function md5(string) { 17 | return crypto.createHash('md5').update(string).digest('hex'); 18 | } 19 | 20 | /** 21 | * Convert a warning or error emitted from the svelte compiler for esbuild. 22 | */ 23 | function convertWarning(source, { message, filename, start, end }, level) { 24 | if (level === 'warning') { 25 | if (message.includes('Unused CSS selector')) return false; 26 | } 27 | 28 | if (!start || !end) { 29 | return { text: message }; 30 | } 31 | const lines = source.split(/\r\n|\r|\n/); 32 | const lineText = lines[start.line - 1]; 33 | const location = { 34 | file: filename, 35 | line: start.line, 36 | column: start.column, 37 | length: (start.line === end.line ? end.column : lineText.length) - start.column, 38 | lineText, 39 | }; 40 | return { text: message, location }; 41 | } 42 | 43 | export type cssCacheObj = { 44 | code: string; 45 | map: string; 46 | time: number; 47 | priority: number; 48 | }; 49 | export type TCache = Map< 50 | string, 51 | { 52 | contents: string; 53 | css?: cssCacheObj; 54 | warnings: PartialMessage[]; 55 | time: number; 56 | priority?: number; 57 | map?: string; 58 | } 59 | >; 60 | 61 | export type TPreprocess = PreprocessorGroup | PreprocessorGroup[] | false; 62 | 63 | export interface IEsBuildPluginSvelte { 64 | type: 'ssr' | 'client'; 65 | svelteConfig: any; 66 | elderConfig: SettingsOptions; 67 | sveltePackages: string[]; 68 | startDevServer?: boolean; 69 | } 70 | 71 | function esbuildPluginSvelte({ type, svelteConfig, elderConfig, sveltePackages = [] }: IEsBuildPluginSvelte): Plugin { 72 | return { 73 | name: 'esbuild-plugin-elderjs', 74 | 75 | setup(build) { 76 | try { 77 | // clean out old css files 78 | build.onStart(() => { 79 | if (type === 'ssr') { 80 | del.sync(elderConfig.$$internal.ssrComponents); 81 | del.sync(resolve(elderConfig.$$internal.distElder, `.${sep}assets${sep}`)); 82 | del.sync(resolve(elderConfig.$$internal.distElder, `.${sep}props${sep}`)); 83 | } else if (type === 'client') { 84 | del.sync(resolve(elderConfig.$$internal.distElder, `.${sep}svelte${sep}`)); 85 | } 86 | }); 87 | 88 | if (sveltePackages.length > 0) { 89 | const filter = 90 | sveltePackages.length > 1 91 | ? new RegExp(`(${sveltePackages.join('|')})`) 92 | : new RegExp(`${sveltePackages[0]}`); 93 | build.onResolve({ filter }, ({ path, importer }) => { 94 | // below largely adapted from the rollup svelte plugin 95 | // ---------------------------------------------- 96 | 97 | if (!importer || path[0] === '.' || path[0] === '\0' || isAbsolute(path)) return null; 98 | // if this is a bare import, see if there's a valid pkg.svelte 99 | const parts = path.split('/'); 100 | 101 | let dir; 102 | let pkg; 103 | let name = parts.shift(); 104 | if (name[0] === '@') { 105 | name += `/${parts.shift()}`; 106 | } 107 | 108 | try { 109 | const file = `.${sep}${['node_modules', name, 'package.json'].join(sep)}`; 110 | const resolved = resolve(process.cwd(), file); 111 | dir = dirname(resolved); 112 | // eslint-disable-next-line import/no-dynamic-require 113 | pkg = require(resolved); 114 | } catch (err) { 115 | if (err.code === 'MODULE_NOT_FOUND') return null; 116 | throw err; 117 | } 118 | 119 | // use pkg.svelte 120 | if (parts.length === 0 && pkg.svelte) { 121 | return { 122 | path: resolve(dir, pkg.svelte), 123 | pluginName: 'esbuild-plugin-elderjs', 124 | }; 125 | } 126 | return null; 127 | }); 128 | } 129 | 130 | build.onResolve({ filter: /\.svelte$/ }, ({ path, importer, resolveDir }) => { 131 | const importee = resolve(resolveDir, path); 132 | resolveFn(importee, importer); 133 | return {}; 134 | }); 135 | 136 | build.onResolve({ filter: /\.css$/ }, ({ path, importer, resolveDir }) => { 137 | const importee = resolve(resolveDir, path); 138 | resolveFn(importee, importer); 139 | 140 | return { path: importee }; 141 | }); 142 | 143 | build.onLoad({ filter: /\.css$/ }, async ({ path }) => { 144 | loadCss(path); 145 | 146 | return { 147 | contents: undefined, 148 | }; 149 | }); 150 | 151 | build.onLoad({ filter: /\.svelte$/ }, async ({ path }) => { 152 | const code = await fsPromises.readFile(path, 'utf-8'); 153 | 154 | const { output, warnings } = await transformFn({ 155 | svelteConfig, 156 | elderConfig, 157 | type, 158 | })(code, path); 159 | 160 | const out = { 161 | contents: output.code, 162 | warnings: type === 'ssr' ? warnings.map((w) => convertWarning(code, w, 'warning')).filter((w) => w) : [], 163 | }; 164 | return out; 165 | }); 166 | 167 | build.onEnd(async () => { 168 | if (type === 'ssr') { 169 | const s = Date.now(); 170 | const r = await minifyCss('all', elderConfig); 171 | console.log(`>>>> minifying css and adding sourcemaps took ${Date.now() - s}ms`); 172 | const hash = md5(r.styles); 173 | 174 | const svelteCss = resolve(elderConfig.$$internal.distElder, `.${sep}assets${sep}svelte-${hash}.css`); 175 | 176 | if (process.env.NODE_ENV !== 'production' || process.env.NODE_ENV !== 'production') { 177 | const sourceMapFileRel = `/${relative( 178 | elderConfig.distDir, 179 | resolve(elderConfig.$$internal.distElder, `${svelteCss}.map`), 180 | )}`; 181 | r.styles = `${r.styles}\n /*# sourceMappingURL=${sourceMapFileRel} */`; 182 | } 183 | 184 | fs.outputFileSync(svelteCss, r.styles); 185 | 186 | if (r.sourceMap) { 187 | fs.outputFileSync(`${svelteCss}.map`, r.sourceMap.toString()); 188 | } 189 | } 190 | }); 191 | } catch (e) { 192 | console.error(e); 193 | } 194 | }, 195 | }; 196 | } 197 | export default esbuildPluginSvelte; 198 | -------------------------------------------------------------------------------- /src/externalHelpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | /* eslint-disable import/no-dynamic-require */ 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | 6 | import { ExternalHelperRequestOptions } from './utils/types'; 7 | 8 | let userHelpers; 9 | 10 | let cache; 11 | 12 | async function externalHelpers({ settings, query, helpers }: ExternalHelperRequestOptions) { 13 | const srcHelpers = path.join(settings.srcDir, 'helpers/index.js'); 14 | try { 15 | if (!cache) { 16 | try { 17 | fs.statSync(srcHelpers); 18 | userHelpers = require(srcHelpers); 19 | 20 | if (typeof userHelpers === 'function') { 21 | userHelpers = await userHelpers({ settings, query, helpers }); 22 | } 23 | cache = userHelpers; 24 | } catch (err) { 25 | if (err.code === 'ENOENT') { 26 | if (settings.debug.automagic) { 27 | console.log( 28 | `debug.automagic:: We attempted to automatically add in helpers, but we couldn't find the file at ${srcHelpers}.`, 29 | ); 30 | } 31 | } 32 | } 33 | } else { 34 | userHelpers = cache; 35 | } 36 | } catch (e) { 37 | console.error(`Error importing ${srcHelpers}`); 38 | console.error(e); 39 | } 40 | 41 | return userHelpers; 42 | } 43 | 44 | export default externalHelpers; 45 | -------------------------------------------------------------------------------- /src/hooks/__tests__/__snapshots__/hooks.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#hooks elderAddExternalHelpers 1`] = ` 4 | Object { 5 | "helpers": Object { 6 | "old": [MockFunction], 7 | "permalink": [MockFunction], 8 | }, 9 | } 10 | `; 11 | 12 | exports[`#hooks elderAddMetaCharsetToHead 1`] = ` 13 | Object { 14 | "headStack": Array [ 15 | Object { 16 | "priority": 100, 17 | "source": "elderAddMetaCharsetToHead", 18 | "string": "", 19 | }, 20 | ], 21 | } 22 | `; 23 | 24 | exports[`#hooks elderAddMetaViewportToHead 1`] = ` 25 | Object { 26 | "headStack": Array [ 27 | Object { 28 | "priority": 90, 29 | "source": "elderAddMetaViewportToHead", 30 | "string": "", 31 | }, 32 | ], 33 | } 34 | `; 35 | 36 | exports[`#hooks matchesSnapshot 1`] = ` 37 | Array [ 38 | Object { 39 | "description": "Adds external helpers to helpers object", 40 | "hook": "bootstrap", 41 | "name": "elderAddExternalHelpers", 42 | "priority": 1, 43 | "run": [Function], 44 | }, 45 | Object { 46 | "description": "An express like middleware so requests can be served by Elder.js", 47 | "hook": "middleware", 48 | "name": "elderExpressLikeMiddleware", 49 | "priority": 1, 50 | "run": [Function], 51 | }, 52 | Object { 53 | "description": "Builds the shortcode parser, parses shortcodes from the html returned by the route's html and appends anything needed to the stacks.", 54 | "hook": "shortcodes", 55 | "name": "elderProcessShortcodes", 56 | "priority": 50, 57 | "run": [Function], 58 | }, 59 | Object { 60 | "description": "Adds to the head.", 61 | "hook": "stacks", 62 | "name": "elderAddMetaCharsetToHead", 63 | "priority": 100, 64 | "run": [Function], 65 | }, 66 | Object { 67 | "description": "Adds to the head.", 68 | "hook": "stacks", 69 | "name": "elderAddMetaViewportToHead", 70 | "priority": 90, 71 | "run": [Function], 72 | }, 73 | Object { 74 | "description": "Adds the css found in the svelte files to the head if 'css' in your 'elder.config.js' file is set to 'file'.", 75 | "hook": "stacks", 76 | "name": "elderAddCssFileToHead", 77 | "priority": 100, 78 | "run": [Function], 79 | }, 80 | Object { 81 | "description": "Creates an HTML string out of the Svelte layout and stacks.", 82 | "hook": "compileHtml", 83 | "name": "elderCompileHtml", 84 | "priority": 50, 85 | "run": [Function], 86 | }, 87 | Object { 88 | "description": "Log any errors to the console.", 89 | "hook": "error", 90 | "name": "elderConsoleLogErrors", 91 | "priority": 1, 92 | "run": [Function], 93 | }, 94 | Object { 95 | "description": "Write the html output to public.", 96 | "hook": "requestComplete", 97 | "name": "elderWriteHtmlFileToPublic", 98 | "priority": 1, 99 | "run": [Function], 100 | }, 101 | Object { 102 | "description": "Page generating timings and logging.", 103 | "hook": "requestComplete", 104 | "name": "elderDisplayRequestTime", 105 | "priority": 50, 106 | "run": [Function], 107 | }, 108 | Object { 109 | "description": "A breakdown of the average times of different stages of the build.", 110 | "hook": "buildComplete", 111 | "name": "elderShowParsedBuildTimes", 112 | "priority": 50, 113 | "run": [Function], 114 | }, 115 | Object { 116 | "description": "Writes out any errors of a build to a JSON file in the ___ELDER___ folder.", 117 | "hook": "buildComplete", 118 | "name": "elderWriteBuildErrors", 119 | "priority": 50, 120 | "run": [Function], 121 | }, 122 | ] 123 | `; 124 | -------------------------------------------------------------------------------- /src/hooks/__tests__/hookEntityDefinitions.spec.ts: -------------------------------------------------------------------------------- 1 | import { hookEntityDefinitions } from '../hookEntityDefinitions'; 2 | import hookInterface from '../hookInterface'; 3 | 4 | test('#hookEntityDefinitions', async () => { 5 | const entities = [...new Set(hookInterface.reduce((out, hook) => [...out, ...hook.props, ...hook.mutable], []))]; 6 | const definitions = Object.keys(hookEntityDefinitions); 7 | entities.forEach((entity) => expect(definitions).toContain(entity)); 8 | }); 9 | -------------------------------------------------------------------------------- /src/hooks/hookEntityDefinitions.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | const hookEntityDefinitions = { 3 | router: `The Elder.js router that handles dynamic requests and explicit requests`, 4 | allRequests: `Every request object collected from all routes during bootstrap. It is important to note that 'allRequests' will be different at the 'request' hook during a build because the requests are split between different processes during build time using the allRequests object.`, 5 | hookInterface: 6 | 'The hook interface is what defines the "contract" for each hook. It includes what properties the hook has access to and which of those properties can be mutated.', 7 | errors: 'An array of errors collected during the build process.', 8 | helpers: 9 | 'An object of helpers loaded from `./src/helpers/index.js` in addition to the Elder.js provided helper functions.', 10 | data: 'An object that is passed to Svelte templates as the "data" prop.', 11 | settings: 'An object representing the elder.config.js and other details about the build.', 12 | routes: 'An object that represents all of the routes registered with Elder.js.', 13 | hooks: 'An array of all of the hooks that have been validated by Elder.js.', 14 | query: 'An object that is initially empty but is reserved for plugins and sites to add database or api access to.', 15 | route: 'An object representing the specific route (similar to a route.js file) for a specific request.', 16 | htmlAttributesStack: 17 | 'A "stack" of attributes to be merged together that are written to the tag.By default, it containt "{lang: "en"}" or an other lang set in your elder.config.js', 18 | bodyAttributesStack: 'A "stack" of attributes to be merged together that are written to the tag.', 19 | headStack: 20 | 'A "stack" of strings to be merged together (along with cssStack) that are written to the tag. If you are looking to customize the head you\'re probably better looking at the "headString."', 21 | cssStack: 22 | 'A "stack" of strings to be merged together to create the the cssString prop. This is mainly uses to collect the css strings emitted by SSR\'d Svelte files.', 23 | styleTag: 'The full tag that is going to be written to the head of the page.', 24 | cssString: 25 | 'The the css string that is wrapped in the styleTag. Added purely for convenience in case users wanted to minify the css.', 26 | htmlAttributesString: 'The complete html attributes as a string just before it is written.', 27 | bodyAttributesString: 'Body attributes as a string just before it is written.', 28 | headString: 'The complete string just before it is written to the head.', 29 | request: 30 | 'An object that represents the parameters required to generate a specific page on a specific route. This object originating from the all() query of a route.js file.', 31 | beforeHydrateStack: 32 | 'A "stack" of generally JS script tags that are required to be loaded before a Svelte component is hydrated. This is only written to the page when a Svelte component needs to be hydrated.', 33 | hydrateStack: 'A "stack" Svelte components that will be hydrated.', 34 | customJsStack: 35 | 'A "stack" of user specific customJs strings that will to be merged together. This is written after the Svelte components.', 36 | footerStack: 'A "stack" of strings to be merged together that will be added to the footer tag.', 37 | htmlString: 'The fully generated html for the page.', 38 | timings: 'An array of collected timings of the system. These are collected using the performance observer.', 39 | req: "The 'req' object from Express or Polka when Elder.js is being used as a server.", 40 | next: "The 'next' object from Express or Polka when Elder.js is being used as a server.", 41 | res: "The 'res' object from Express or Polka when Elder.js is being used as a server.", 42 | templateHtml: "The HTML string returned by the SSR'd Svelte template for the request's route.", 43 | shortcodes: "An array of shortcode definitions that are processed on the 'shortcodes' hook.", 44 | footerString: 'A HTML string that Elder.js will write to the footer.', 45 | layoutHtml: 46 | "The compiled HTML response for a route containing all of the HTML from the Route's layout and template. ", 47 | serverLookupObject: `A key value object where the key is the relative permalink and the object is the 'request' object used by the Elder.js server.`, 48 | runHook: `The function that powers hooks. 'await runhook('hookName', objectContainingProps)`, 49 | perf: `Includes two functions: perf.start('thingToTrack') and perf.end('thingToTrack') which allows easily adding tracking to Elder.js' perf reporting which can be toggled under debug.performance in your elder.config.js file.`, 50 | }; 51 | 52 | // eslint-disable-next-line import/prefer-default-export 53 | export { hookEntityDefinitions }; 54 | -------------------------------------------------------------------------------- /src/hooks/types.ts: -------------------------------------------------------------------------------- 1 | export type Hook = 2 | | 'customizeHooks' 3 | | 'bootstrap' 4 | | 'allRequests' 5 | | 'middleware' 6 | | 'request' 7 | | 'data' 8 | | 'shortcodes' 9 | | 'stacks' 10 | | 'head' 11 | | 'compileHtml' 12 | | 'html' 13 | | 'requestComplete' 14 | | 'error' 15 | | 'buildComplete'; 16 | 17 | export type HookInterface = { 18 | hook: Hook; 19 | props: Array; 20 | mutable: Array; 21 | use: string; 22 | location: string; 23 | context: string; 24 | experimental: boolean; 25 | advanced: boolean; 26 | }; 27 | 28 | interface Run { 29 | (input: any): any | Promise; 30 | } 31 | export type HookOptions = { 32 | hook: Hook; 33 | name: string; 34 | description: string; 35 | priority: Number; 36 | run: Run; 37 | $$meta?: { 38 | type: string; 39 | addedBy: string; 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | export { default as partialHydration, preprocessSvelteContent } from './partialHydration/partialHydration'; 4 | 5 | export { default as esbuildBundler } from './esbuild/esbuildBundler'; 6 | 7 | export { configSchema, hookSchema, routeSchema, pluginSchema, shortcodeSchema } from './utils/validations'; 8 | export { Elder, build } from './Elder'; 9 | export * from './utils/types'; 10 | export * from './utils/index'; 11 | export * from './routes/routes'; 12 | export * from './hooks/types'; 13 | export { hookInterface } from './hooks/hookInterface'; 14 | export { hookEntityDefinitions } from './hooks/hookEntityDefinitions'; 15 | 16 | export { default as getElderConfig } from './utils/getConfig'; 17 | -------------------------------------------------------------------------------- /src/partialHydration/__tests__/inlineSvelteComponent.spec.ts: -------------------------------------------------------------------------------- 1 | import { inlinePreprocessedSvelteComponent, escapeHtml, inlineSvelteComponent } from '../inlineSvelteComponent'; 2 | 3 | test('#escapeHtml', () => { 4 | expect(escapeHtml('')).toEqual(''); 5 | expect(escapeHtml(`'Tom'&"Jerry"`)).toEqual( 6 | '<html>'Tom'&amp;"Jerry"</html>', 7 | ); 8 | }); 9 | 10 | test('#inlinePreprocessedSvelteComponent', () => { 11 | const options = '{"loading":"lazy"}'; 12 | expect( 13 | inlinePreprocessedSvelteComponent({ 14 | name: 'Home', 15 | props: '{welcomeText: "Hello World"}', 16 | options, 17 | }), 18 | ).toMatchInlineSnapshot( 19 | `""`, 20 | ); 21 | expect(inlinePreprocessedSvelteComponent({})).toMatchInlineSnapshot( 22 | `""`, 23 | ); 24 | }); 25 | 26 | test('#inlineSvelteComponent', () => { 27 | const options = { 28 | loading: 'lazy', 29 | }; 30 | expect( 31 | inlineSvelteComponent({ 32 | name: 'Home', 33 | props: { 34 | welcomeText: 'Hello World', 35 | }, 36 | options, 37 | }), 38 | ).toMatchInlineSnapshot( 39 | `"
"`, 40 | ); 41 | expect(inlineSvelteComponent({})).toMatchInlineSnapshot( 42 | `"
"`, 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /src/partialHydration/__tests__/partialHydration.spec.ts: -------------------------------------------------------------------------------- 1 | import partialHydration from '../partialHydration'; 2 | 3 | describe('#partialHydration', () => { 4 | it('replaces as expected', async () => { 5 | expect( 6 | ( 7 | await partialHydration.markup({ 8 | content: '', 9 | }) 10 | ).code, 11 | ).toMatchInlineSnapshot( 12 | `""`, 13 | ); 14 | }); 15 | 16 | it('allow numbers in the tag name', async () => { 17 | expect( 18 | ( 19 | await partialHydration.markup({ 20 | content: '', 21 | }) 22 | ).code, 23 | ).toMatchInlineSnapshot( 24 | `""`, 25 | ); 26 | }); 27 | 28 | it('explicit lazy', async () => { 29 | expect( 30 | ( 31 | await partialHydration.markup({ 32 | content: '', 33 | }) 34 | ).code, 35 | ).toMatchInlineSnapshot( 36 | `""`, 37 | ); 38 | }); 39 | 40 | it('explicit timeout', async () => { 41 | expect( 42 | ( 43 | await partialHydration.markup({ 44 | content: '', 45 | }) 46 | ).code, 47 | ).toMatchInlineSnapshot( 48 | `""`, 49 | ); 50 | }); 51 | 52 | it('eager', async () => { 53 | expect( 54 | ( 55 | await partialHydration.markup({ 56 | content: '', 57 | }) 58 | ).code, 59 | ).toMatchInlineSnapshot( 60 | `""`, 61 | ); 62 | }); 63 | it('eager, root margin, threshold', async () => { 64 | expect( 65 | ( 66 | await partialHydration.markup({ 67 | content: 68 | '', 69 | }) 70 | ).code, 71 | ).toMatchInlineSnapshot( 72 | `""`, 73 | ); 74 | }); 75 | it('open string', async () => { 76 | expect( 77 | ( 78 | await partialHydration.markup({ 79 | content: '"`); 83 | }); 84 | it('text within component', async () => { 85 | await expect(async () => { 86 | await partialHydration.markup({ 87 | content: `Test`, 88 | }); 89 | }).rejects.toThrow(); 90 | }); 91 | it('open bracket after hydrate-client', async () => { 92 | await expect(async () => { 93 | await partialHydration.markup({ 94 | content: ``, 95 | }); 96 | }).rejects.toThrow(); 97 | }); 98 | it('non self closing', async () => { 99 | await expect(async () => { 100 | await partialHydration.markup({ 101 | content: ``, 102 | }); 103 | }).rejects.toThrow(); 104 | }); 105 | it('wrapped poorly', async () => { 106 | await expect(async () => { 107 | await partialHydration.markup({ 108 | content: `Test`, 109 | }); 110 | }).rejects.not.toContain(''); 111 | }); 112 | 113 | it('replaces Ablock, Block, and Clock', async () => { 114 | expect( 115 | ( 116 | await partialHydration.markup({ 117 | content: ``, 118 | }) 119 | ).code, 120 | ).toMatchInlineSnapshot( 121 | `""`, 122 | ); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/partialHydration/__tests__/propCompression.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getType, 3 | walkAndCount, 4 | getName, 5 | prepareSubstitutions, 6 | walkAndSubstitute, 7 | isPrimitive, 8 | } from '../propCompression'; 9 | 10 | const testObj = { 11 | a: 'b', 12 | b: 'c', 13 | c: { a: 'b', b: 'c', c: { a: 'b', b: 'c', c: { apple: false } } }, 14 | }; 15 | 16 | describe('#hydrateComponents', () => { 17 | it('#isPrimitative', () => { 18 | expect(isPrimitive({ foo: true })).toBe(false); 19 | expect(isPrimitive([])).toBe(false); 20 | expect(isPrimitive(1)).toBe(true); 21 | expect(isPrimitive(false)).toBe(true); 22 | expect(isPrimitive('yes')).toBe(true); 23 | }); 24 | it('#getType', () => { 25 | expect(getType({})).toBe('Object'); 26 | expect(getType([])).toBe('Array'); 27 | expect(getType(false)).toBe('Boolean'); 28 | }); 29 | 30 | it('#walkAndCount', () => { 31 | const counts = new Map(); 32 | walkAndCount(testObj, counts); 33 | expect([...counts]).toMatchObject([ 34 | ['b', 6], 35 | ['a', 3], 36 | ['c', 6], 37 | [false, 1], 38 | ['apple', 1], 39 | ]); 40 | }); 41 | 42 | it('#prepareSubstitutions', () => { 43 | const counts = new Map(); 44 | const substitutions = new Map(); 45 | const initialValues = new Map(); 46 | const replacementChars = '$123'; 47 | walkAndCount(testObj, counts); 48 | prepareSubstitutions({ counts, substitutions, initialValues, replacementChars }); 49 | 50 | expect([...counts]).toMatchObject([ 51 | ['b', 6], 52 | ['a', 3], 53 | ['c', 6], 54 | [false, 1], 55 | ['apple', 1], 56 | ]); 57 | expect([...substitutions]).toMatchObject([ 58 | ['b', '$'], 59 | ['c', '1'], 60 | ['a', '2'], 61 | ]); 62 | expect([...initialValues]).toMatchObject([ 63 | ['$', 'b'], 64 | ['1', 'c'], 65 | ['2', 'a'], 66 | ]); 67 | }); 68 | 69 | it('#getName', () => { 70 | const counts = new Map(); 71 | walkAndCount(testObj, counts); 72 | expect(getName(1, counts, '$123')).toBe('1'); 73 | }); 74 | 75 | it('#walkAndSubstitute', () => { 76 | const counts = new Map(); 77 | const substitutions = new Map(); 78 | const initialValues = new Map(); 79 | const replacementChars = '$123'; 80 | walkAndCount(testObj, counts); 81 | prepareSubstitutions({ counts, substitutions, initialValues, replacementChars }); 82 | 83 | expect(walkAndSubstitute(testObj, substitutions)).toMatchObject({ 84 | $: '1', 85 | '1': { $: '1', '1': { $: '1', '1': { apple: false }, '2': '$' }, '2': '$' }, 86 | '2': '$', 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/partialHydration/inlineSvelteComponent.ts: -------------------------------------------------------------------------------- 1 | import { HydrateOptions } from '../utils/types'; 2 | 3 | const defaultHydrationOptions: HydrateOptions = { 4 | loading: 'lazy', 5 | element: 'div', 6 | }; 7 | 8 | export function escapeHtml(text: string): string { 9 | return text 10 | .replace(/&/g, '&') 11 | .replace(//g, '>') 13 | .replace(/"/g, '"') 14 | .replace(/'/g, '''); 15 | } 16 | 17 | type InputParamsInlinePreprocessedSvelteComponent = { 18 | name?: string; 19 | props?: string; 20 | options?: string; 21 | }; 22 | 23 | export function inlinePreprocessedSvelteComponent({ 24 | name = '', 25 | props = '', 26 | options = '', 27 | }: InputParamsInlinePreprocessedSvelteComponent): string { 28 | // FIXME: don't output default options into the component to reduce file size. 29 | const hydrationOptionsString = 30 | options.length > 0 31 | ? `{...${JSON.stringify(defaultHydrationOptions)}, ...${options}}` 32 | : JSON.stringify(defaultHydrationOptions); 33 | 34 | const replacementAttrs = { 35 | class: '"ejs-component"', 36 | 'data-ejs-component': `"${name}"`, 37 | 'data-ejs-props': `{JSON.stringify(${props})}`, 38 | 'data-ejs-options': `{JSON.stringify(${hydrationOptionsString})}`, 39 | }; 40 | const replacementAttrsString = Object.entries(replacementAttrs).reduce( 41 | (out, [key, value]) => `${out} ${key}=${value}`, 42 | '', 43 | ); 44 | return ``; 45 | } 46 | 47 | type InputParamsInlineSvelteComponent = { 48 | name?: string; 49 | props?: any; 50 | options?: { 51 | loading?: string; // todo: enum, can't get it working: 'lazy', 'eager', 'none' 52 | element?: string; // default: 'div' 53 | }; 54 | }; 55 | 56 | export function inlineSvelteComponent({ 57 | name = '', 58 | props = {}, 59 | options = {}, 60 | }: InputParamsInlineSvelteComponent): string { 61 | const hydrationOptions = 62 | Object.keys(options).length > 0 ? { ...defaultHydrationOptions, ...options } : defaultHydrationOptions; 63 | 64 | const replacementAttrs = { 65 | class: '"ejs-component"', 66 | 'data-ejs-component': `"${name}"`, 67 | 'data-ejs-props': `"${escapeHtml(JSON.stringify(props))}"`, 68 | 'data-ejs-options': `"${escapeHtml(JSON.stringify(hydrationOptions))}"`, 69 | }; 70 | const replacementAttrsString = Object.entries(replacementAttrs).reduce( 71 | (out, [key, value]) => `${out} ${key}=${value}`, 72 | '', 73 | ); 74 | 75 | return `<${hydrationOptions.element}${replacementAttrsString}>`; 76 | } 77 | -------------------------------------------------------------------------------- /src/partialHydration/mountComponentsInHtml.ts: -------------------------------------------------------------------------------- 1 | import svelteComponent from '../utils/svelteComponent'; 2 | 3 | export const replaceSpecialCharacters = (str) => 4 | str 5 | .replace(/"/gim, '"') 6 | .replace(/</gim, '<') 7 | .replace(/>/gim, '>') 8 | .replace(/'/gim, "'") 9 | .replace(/'/gim, "'") 10 | .replace(/&/gim, '&'); 11 | 12 | export default function mountComponentsInHtml({ page, html, hydrateOptions }): string { 13 | let outputHtml = html; 14 | // sometimes svelte adds a class to our inlining. 15 | const matches = outputHtml.matchAll( 16 | /<([^<>\s]+) class="ejs-component[^"]*?" data-ejs-component="([A-Za-z]+)" data-ejs-props="({[^"]*?})" data-ejs-options="({[^"]*?})"><\/\1>/gim, 17 | ); 18 | 19 | for (const match of matches) { 20 | const hydrateComponentName = match[2]; 21 | let hydrateComponentProps; 22 | let hydrateComponentOptions; 23 | 24 | try { 25 | hydrateComponentProps = JSON.parse(replaceSpecialCharacters(match[3])); 26 | } catch (e) { 27 | throw new Error(`Failed to JSON.parse props for ${hydrateComponentName} ${replaceSpecialCharacters(match[3])}`); 28 | } 29 | try { 30 | hydrateComponentOptions = JSON.parse(replaceSpecialCharacters(match[4])); 31 | } catch (e) { 32 | throw new Error(`Failed to JSON.parse props for ${hydrateComponentName} ${replaceSpecialCharacters(match[4])}`); 33 | } 34 | 35 | if (hydrateOptions) { 36 | throw new Error( 37 | `Client side hydrated component is attempting to hydrate another sub component "${hydrateComponentName}." This isn't supported. \n 38 | Debug: ${JSON.stringify({ 39 | hydrateOptions, 40 | hydrateComponentName, 41 | hydrateComponentProps, 42 | hydrateComponentOptions, 43 | })} 44 | `, 45 | ); 46 | } 47 | 48 | const hydratedHtml = svelteComponent(hydrateComponentName)({ 49 | page, 50 | props: hydrateComponentProps, 51 | hydrateOptions: hydrateComponentOptions, 52 | }); 53 | 54 | outputHtml = outputHtml.replace(match[0], hydratedHtml); 55 | } 56 | 57 | return outputHtml; 58 | } 59 | -------------------------------------------------------------------------------- /src/partialHydration/partialHydration.ts: -------------------------------------------------------------------------------- 1 | import { inlinePreprocessedSvelteComponent } from './inlineSvelteComponent'; 2 | 3 | const extractHydrateOptions = (htmlString) => { 4 | const hydrateOptionsPattern = /hydrate-options={([^]*?})}/gim; 5 | 6 | const optionsMatch = hydrateOptionsPattern.exec(htmlString); 7 | if (optionsMatch) { 8 | return optionsMatch[1]; 9 | } 10 | return ''; 11 | }; 12 | 13 | const createReplacementString = ({ input, name, props }) => { 14 | const options = extractHydrateOptions(input); 15 | return inlinePreprocessedSvelteComponent({ name, props, options }); 16 | }; 17 | 18 | export const preprocessSvelteContent = (content) => { 19 | // Note: this regex only supports self closing components. 20 | // Slots aren't supported for client hydration either. 21 | const hydrateableComponentPattern = /<([a-zA-Z\d]+)\b[^>]+\bhydrate-client={([^]*?})}[^/>]*\/>/gim; 22 | const matches = [...content.matchAll(hydrateableComponentPattern)]; 23 | 24 | const output = matches.reduce((out, match) => { 25 | const [wholeMatch, name, props] = match; 26 | const replacement = createReplacementString({ input: wholeMatch, name, props }); 27 | return out.replace(wholeMatch, replacement); 28 | }, content); 29 | 30 | const wrappingComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client={([^]*?})}[^/>]*>[^>]*<\/([a-zA-Z]+)>/gim; 31 | // 32 | // 33 | // Foo 34 | 35 | const wrappedComponents = [...output.matchAll(wrappingComponentPattern)]; 36 | 37 | if (wrappedComponents && wrappedComponents.length > 0) { 38 | throw new Error( 39 | `Elder.js only supports self-closing syntax on hydrated components. This means not or Something. Offending component: ${wrappedComponents[0][0]}. Slots and child components aren't supported during hydration as it would result in huge HTML payloads. If you need this functionality try wrapping the offending component in a parent component without slots or child components and hydrate the parent component.`, 40 | ); 41 | } 42 | return output; 43 | }; 44 | 45 | const partialHydration = { 46 | markup: async ({ content }) => { 47 | return { code: preprocessSvelteContent(content) }; 48 | }, 49 | }; 50 | 51 | export default partialHydration; 52 | -------------------------------------------------------------------------------- /src/partialHydration/prepareFindSvelteComponent.ts: -------------------------------------------------------------------------------- 1 | import glob from 'glob'; 2 | import path from 'path'; 3 | import { SvelteComponentFiles } from '../utils/types'; 4 | import windowsPathFix from '../utils/windowsPathFix'; 5 | 6 | export const removeHash = (pathWithHash) => { 7 | const parsed = path.parse(pathWithHash); 8 | const parts = parsed.name.split('.'); 9 | if (parts.length > 1) { 10 | const out = pathWithHash.replace(`.${parts.pop()}`, ''); 11 | return out; 12 | } 13 | return pathWithHash; 14 | }; 15 | 16 | const prepareFindSvelteComponent = ({ ssrFolder, rootDir, clientComponents: clientFolder, distDir, srcDir }) => { 17 | const relSrcDir = windowsPathFix(path.relative(rootDir, srcDir)); 18 | const rootDirFixed = windowsPathFix(rootDir); 19 | const ssrComponents = glob.sync(`${ssrFolder}/**/*.js`).map(windowsPathFix); 20 | const clientComponents = glob 21 | .sync(`${clientFolder}/**/*.js`) 22 | .map((c) => windowsPathFix(`${path.sep}${path.relative(distDir, c)}`)); 23 | 24 | const cache = new Map(); 25 | 26 | const findComponent = (name, folder): SvelteComponentFiles => { 27 | const nameFixed = windowsPathFix(name); 28 | 29 | const cacheKey = JSON.stringify({ name, folder }); 30 | if (cache.has(cacheKey)) return cache.get(cacheKey); 31 | 32 | // abs path first 33 | if (nameFixed.includes(rootDirFixed)) { 34 | const rel = windowsPathFix(path.relative(path.join(rootDirFixed, relSrcDir), name)) 35 | .replace('.svelte', '.js') 36 | .toLowerCase(); 37 | 38 | const parsed = path.parse(rel); 39 | const ssr = ssrComponents.find((c) => c.toLowerCase().endsWith(rel)); 40 | const client = windowsPathFix(clientComponents.find((c) => removeHash(c).toLowerCase().endsWith(rel))); 41 | const iife = windowsPathFix( 42 | clientComponents 43 | .filter((c) => c.includes('iife')) 44 | .find((c) => removeHash(c).toLowerCase().endsWith(parsed.base)), 45 | ); 46 | 47 | const out = { ssr, client, iife }; 48 | cache.set(cacheKey, out); 49 | return out; 50 | } 51 | 52 | // component name and folder only 53 | const ssr = ssrComponents 54 | .filter((c) => c.includes(folder)) 55 | .find((c) => path.parse(c).name.toLowerCase() === name.replace('.svelte', '').toLowerCase()); 56 | const client = windowsPathFix( 57 | clientComponents 58 | .filter((c) => c.includes(folder)) 59 | .find((c) => path.parse(removeHash(c)).name.toLowerCase() === name.replace('.svelte', '').toLowerCase()), 60 | ); 61 | 62 | const iife = windowsPathFix( 63 | clientComponents 64 | .filter((c) => c.includes('iife')) 65 | .find((c) => removeHash(c.toLowerCase()).endsWith(`${name.toLowerCase().replace('.svelte', '')}.js`)), 66 | ); 67 | 68 | const out = { ssr, client, iife }; 69 | cache.set(cacheKey, out); 70 | return out; 71 | }; 72 | return findComponent; 73 | }; 74 | 75 | export default prepareFindSvelteComponent; 76 | -------------------------------------------------------------------------------- /src/partialHydration/propCompression.ts: -------------------------------------------------------------------------------- 1 | const reserved = 2 | /^(?:do|if|in|for|int|let|new|try|var|byte|case|char|else|enum|goto|long|this|void|with|await|break|catch|class|const|final|float|short|super|throw|while|yield|delete|double|export|import|native|return|switch|throws|typeof|boolean|default|extends|finally|package|private|abstract|continue|debugger|function|volatile|interface|protected|transient|implements|instanceof|synchronized)$/; 3 | 4 | export const isPrimitive = (thing: any) => Object(thing) !== thing; 5 | 6 | export const getType = (thing: any) => Object.prototype.toString.call(thing).slice(8, -1); 7 | 8 | const objectProtoOwnPropertyNames = Object.getOwnPropertyNames(Object.prototype).sort().join('\0'); 9 | 10 | export const walkAndCount = (thing, counts: Map) => { 11 | if (typeof thing === 'function') { 12 | throw new Error(`Cannot stringify a function`); 13 | } 14 | 15 | if (counts.has(thing)) { 16 | counts.set(thing, counts.get(thing) + 1); 17 | return; 18 | } 19 | 20 | // eslint-disable-next-line consistent-return 21 | if (isPrimitive(thing)) return counts.set(thing, 1); 22 | 23 | switch (getType(thing)) { 24 | case 'Number': 25 | case 'String': 26 | case 'Boolean': 27 | case 'Array': 28 | thing.forEach((t) => walkAndCount(t, counts)); 29 | break; 30 | default: 31 | // eslint-disable-next-line no-case-declarations 32 | const proto = Object.getPrototypeOf(thing); 33 | if ( 34 | proto !== Object.prototype && 35 | proto !== null && 36 | Object.getOwnPropertyNames(proto).sort().join('\0') !== objectProtoOwnPropertyNames 37 | ) { 38 | throw new Error(`Cannot stringify objects with augmented prototypes`); 39 | } 40 | 41 | if (Object.getOwnPropertySymbols(thing).length > 0) { 42 | throw new Error(`Cannot stringify objects with symbolic keys`); 43 | } 44 | 45 | Object.keys(thing).forEach((key) => { 46 | walkAndCount(thing[key], counts); 47 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 48 | walkAndCount(key, counts); 49 | }); 50 | } 51 | }; 52 | 53 | export const getName = (num: number, counts: Map, replacementChars: string) => { 54 | let name = ''; 55 | const { length } = replacementChars; 56 | do { 57 | name = replacementChars[num % length] + name; 58 | // eslint-disable-next-line no-bitwise 59 | num = ~~(num / length) - 1; 60 | } while (num >= 0); 61 | 62 | if (counts.has(name)) name = `${name}_`; 63 | 64 | return reserved.test(name) ? `${name}_` : name; 65 | }; 66 | 67 | interface IPrepareSubsitutions { 68 | counts: Map; 69 | substitutions: Map; 70 | initialValues: Map; 71 | replacementChars: string; 72 | } 73 | 74 | export const prepareSubstitutions = ({ 75 | counts, 76 | substitutions, 77 | initialValues, 78 | replacementChars, 79 | }: IPrepareSubsitutions) => { 80 | Array.from(counts) 81 | .filter((entry) => entry[1] > 1) 82 | .sort((a, b) => b[1] - a[1]) 83 | .forEach((entry, i) => { 84 | const name = getName(i, counts, replacementChars); 85 | substitutions.set(entry[0], name); 86 | initialValues.set(name, entry[0]); 87 | }); 88 | }; 89 | 90 | export const walkAndSubstitute = (thing, substitutions: Map) => { 91 | if (substitutions.has(thing)) return substitutions.get(thing); 92 | if (Array.isArray(thing)) return thing.map((t) => walkAndSubstitute(t, substitutions)); 93 | if (getType(thing) === 'Object') { 94 | return Object.keys(thing).reduce((out, cv) => { 95 | const key = substitutions.get(cv) || cv; 96 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 97 | out[key] = walkAndSubstitute(thing[cv], substitutions); 98 | return out; 99 | }, {}); 100 | } 101 | return thing; 102 | }; 103 | -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/external/node_modules/test-external-svelte-library/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-external-svelte-library", 3 | "version": "1.0.0", 4 | "svelte": "src/index.js", 5 | "scripts": { 6 | "build": "rollup -c", 7 | "dev": "rollup -c -w", 8 | "start": "sirv public" 9 | }, 10 | "devDependencies": { 11 | "@rollup/plugin-commonjs": "^14.0.0", 12 | "@rollup/plugin-node-resolve": "^8.0.0", 13 | "rollup": "^2.3.4", 14 | "rollup-plugin-livereload": "^2.0.0", 15 | "rollup-plugin-svelte": "^6.0.0", 16 | "rollup-plugin-terser": "^7.0.0", 17 | "svelte": "^3.0.0" 18 | }, 19 | "dependencies": { 20 | "sirv-cli": "^1.0.0" 21 | } 22 | } -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/external/node_modules/test-external-svelte-library/src/components/Button.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/external/node_modules/test-external-svelte-library/src/components/Icon.svelte: -------------------------------------------------------------------------------- 1 |
*
2 | 3 | -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/external/node_modules/test-external-svelte-library/src/components/Unused.svelte: -------------------------------------------------------------------------------- 1 |
This component is not used in this project
2 | 3 | 9 | -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/external/node_modules/test-external-svelte-library/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Button } from './components/Button.svelte' 2 | export { default as Icon } from './components/Icon.svelte' 3 | export { default as Unused } from './components/Unused.svelte' -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/external/src/components/Component.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/external/src/layouts/External.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 11 | 12 |
13 | 14 |
-------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/simple/src/components/One.svelte: -------------------------------------------------------------------------------- 1 | 4 | 20 | 21 | 22 | 23 |
test
-------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/simple/src/layouts/Two.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 |
9 | 10 | 16 | -------------------------------------------------------------------------------- /src/rollup/__tests__/__fixtures__/simple/src/routes/Three.svelte: -------------------------------------------------------------------------------- 1 |
Are good for you
2 | 3 | 9 | -------------------------------------------------------------------------------- /src/rollup/__tests__/getRollupConfig.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | import multiInput from 'rollup-plugin-multi-input'; 3 | import path from 'path'; 4 | import { createBrowserConfig, createSSRConfig } from '../getRollupConfig'; 5 | import getConfig from '../../utils/getConfig'; 6 | 7 | // TODO: test replace 8 | 9 | jest.mock('fs-extra', () => { 10 | return { 11 | ensureDirSync: () => {}, 12 | readdirSync: () => ['svelte-3449427d.css', 'svelte.css-0050caf1.map'], 13 | }; 14 | }); 15 | 16 | describe('#getRollupConfig', () => { 17 | beforeEach(() => { 18 | jest.resetModules(); 19 | }); 20 | 21 | const elderConfig = getConfig(); 22 | 23 | it('createBrowserConfig works', () => { 24 | [true, false].forEach((sourcemap) => { 25 | const { plugins, ...config } = createBrowserConfig({ 26 | input: [`./components/*/*.svelte`], 27 | output: { 28 | dir: './public/dist/svelte/', 29 | entryFileNames: '[name].[hash].js', 30 | sourcemap, 31 | format: 'system', 32 | }, 33 | multiInputConfig: multiInput({ 34 | // TODO: test with false 35 | relative: `./components`, 36 | transformOutputPath: (output) => `${path.basename(output)}`, 37 | }), 38 | svelteConfig: {}, 39 | elderConfig, 40 | }); 41 | expect(config).toEqual( 42 | expect.objectContaining({ 43 | cache: true, 44 | input: ['./components/*/*.svelte'], 45 | output: { 46 | dir: './public/dist/svelte/', 47 | entryFileNames: '[name].[hash].js', 48 | format: 'system', 49 | sourcemap, 50 | }, 51 | treeshake: true, 52 | }), 53 | ); 54 | expect(plugins).toHaveLength(8); 55 | }); 56 | }); 57 | 58 | it('createBrowserConfig multiInputConfig = false', () => { 59 | expect( 60 | createBrowserConfig({ 61 | input: [`./components/*/*.svelte`], 62 | output: { 63 | dir: './public/dist/svelte/', 64 | entryFileNames: 'entry[name]-[hash].js', 65 | sourcemap: true, 66 | format: 'system', 67 | }, 68 | svelteConfig: {}, 69 | multiInputConfig: false, 70 | elderConfig, 71 | }).plugins.map((p) => p.name), 72 | ).toEqual(['replace', 'json', 'rollup-plugin-elder', 'node-resolve', 'commonjs', 'babel', 'terser']); 73 | }); 74 | 75 | it('createBrowserConfig multiInputConfig = false, ie11 = true', () => { 76 | expect( 77 | createBrowserConfig({ 78 | elderConfig, 79 | input: [`./components/*/*.svelte`], 80 | output: { 81 | dir: './public/dist/svelte/', 82 | entryFileNames: 'entry[name]-[hash].js', 83 | sourcemap: true, 84 | format: 'system', 85 | }, 86 | svelteConfig: {}, 87 | multiInputConfig: false, 88 | }).plugins.map((p) => p.name), 89 | ).toEqual(['replace', 'json', 'rollup-plugin-elder', 'node-resolve', 'commonjs', 'babel', 'terser']); 90 | }); 91 | 92 | it('createSSRConfig works', () => { 93 | const { plugins, ...config } = createSSRConfig({ 94 | elderConfig, 95 | input: [`./components/*/*.svelte`], 96 | output: { 97 | dir: './___ELDER___/compiled/', 98 | format: 'cjs', 99 | exports: 'auto', 100 | }, 101 | multiInputConfig: multiInput({ 102 | relative: `./components`, 103 | transformOutputPath: (output) => `${path.basename(output)}`, 104 | }), 105 | svelteConfig: { 106 | preprocess: [ 107 | { 108 | style: ({ content }) => { 109 | return content.toUpperCase(); 110 | }, 111 | }, 112 | ], 113 | }, 114 | }); 115 | expect(config).toEqual( 116 | expect.objectContaining({ 117 | cache: true, 118 | input: ['./components/*/*.svelte'], 119 | output: { 120 | dir: './___ELDER___/compiled/', 121 | exports: 'auto', 122 | format: 'cjs', 123 | }, 124 | treeshake: true, 125 | }), 126 | ); 127 | 128 | expect(plugins).toHaveLength(7); 129 | }); 130 | 131 | it('createSSRConfig multiInputConfig = false', () => { 132 | expect( 133 | createSSRConfig({ 134 | elderConfig, 135 | input: [`./components/*/*.svelte`], 136 | output: { 137 | dir: './___ELDER___/compiled/', 138 | format: 'cjs', 139 | exports: 'auto', 140 | }, 141 | svelteConfig: { 142 | preprocess: [ 143 | { 144 | style: ({ content }) => { 145 | return content.toUpperCase(); 146 | }, 147 | }, 148 | ], 149 | }, 150 | multiInputConfig: false, 151 | }).plugins.map((p) => p.name), 152 | ).toEqual(['replace', 'json', 'rollup-plugin-elder', 'node-resolve', 'commonjs', 'terser']); 153 | }); 154 | 155 | it('getRollupConfig as a whole works - default options', () => { 156 | jest.mock('../../utils/validations.ts', () => ({ 157 | getDefaultRollup: () => ({ 158 | replacements: {}, 159 | dev: { splitComponents: false }, 160 | svelteConfig: {}, 161 | }), 162 | })); 163 | jest.mock('../../utils/getPluginLocations', () => () => ({ 164 | paths: ['/src/plugins/elderjs-plugin-reload/'], 165 | files: [ 166 | '/src/plugins/elderjs-plugin-reload/SimplePlugin.svelte', 167 | '/src/plugins/elderjs-plugin-reload/Test.svelte', 168 | ], 169 | })); 170 | // getElderConfig() mock 171 | jest.mock('../../utils/getConfig', () => () => ({ 172 | $$internal: { 173 | clientComponents: 'test/public/svelte', 174 | ssrComponents: 'test/___ELDER___/compiled', 175 | }, 176 | distDir: './dist', 177 | srcDir: './src', 178 | rootDir: './', 179 | plugins: { 180 | pluginA: {}, 181 | pluginB: {}, 182 | }, 183 | })); 184 | 185 | jest.mock('del'); 186 | jest.mock('fs-extra', () => ({ 187 | existsSync: jest.fn().mockImplementation(() => true), 188 | })); 189 | 190 | const svelteConfig = { 191 | preprocess: [ 192 | { 193 | style: ({ content }) => { 194 | return content.toUpperCase(); 195 | }, 196 | }, 197 | ], 198 | }; 199 | 200 | // would be nice to mock getPluginPaths if it's extracted to separate file 201 | const configs = require('../getRollupConfig').default({ svelteConfig }); 202 | expect(configs).toHaveLength(2); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /src/rollup/__tests__/rollupPlugin.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fsExtra from 'fs-extra'; 3 | import getConfig from '../../utils/getConfig'; 4 | import { createSSRConfig } from '../getRollupConfig'; 5 | 6 | const rollup = require('rollup'); 7 | 8 | describe('#rollupPlugin', () => { 9 | const cfs = fsExtra.copyFileSync; 10 | const rds = fsExtra.readdirSync; 11 | const eds = fsExtra.ensureDirSync; 12 | 13 | // @ts-ignore 14 | fsExtra.copyFileSync = jest.fn(cfs); 15 | // @ts-ignore 16 | fsExtra.copyFileSync.mockImplementation(() => 'copied'); 17 | // @ts-ignore 18 | fsExtra.readdirSync = jest.fn(rds); 19 | // @ts-ignore 20 | fsExtra.readdirSync.mockImplementation(() => ['style.css', 'style.css.map']); 21 | // @ts-ignore 22 | fsExtra.ensureDirSync = jest.fn(eds); 23 | // @ts-ignore 24 | fsExtra.ensureDirSync.mockImplementation(console.log); 25 | // @ts-ignore 26 | 27 | const elderConfig = getConfig(); 28 | 29 | it('SSR: Properly rolls up 3 components including _css and css output', async () => { 30 | const { input, plugins, output } = createSSRConfig({ 31 | input: [ 32 | path.resolve(`./src/rollup/__tests__/__fixtures__/simple/src/components/One.svelte`), 33 | path.resolve(`./src/rollup/__tests__/__fixtures__/simple/src/layouts/Two.svelte`), 34 | path.resolve(`./src/rollup/__tests__/__fixtures__/simple/src/routes/Three.svelte`), 35 | ], 36 | output: { 37 | dir: './___ELDER___/compiled/', 38 | format: 'cjs', 39 | exports: 'auto', 40 | }, 41 | multiInputConfig: false, 42 | svelteConfig: {}, 43 | elderConfig, 44 | }); 45 | 46 | const bundle = await rollup.rollup({ input, plugins }); 47 | 48 | const { output: out } = await bundle.generate({ output }); 49 | 50 | expect(out).toHaveLength(5); 51 | 52 | // properly prioritizes css dependencies with components, routes, layouts in order 53 | const one = out.find((c) => c.facadeModuleId.endsWith('One.svelte')); 54 | expect(one.code).toContain( 55 | '.layout.svelte-1pyy034{background:purple}.route.svelte-plwlu6{background:#f0f8ff}.component.svelte-5m4l82{display:flex;flex-direction:column;font-size:14px}@media(min-width:768px){.component.svelte-5m4l82{flex-direction:row}}', 56 | ); 57 | 58 | const two = out.find((c) => c.facadeModuleId.endsWith('Two.svelte')); 59 | expect(two.code).toContain('.layout.svelte-1pyy034{background:purple}.route.svelte-plwlu6{background:#f0f8ff}'); 60 | 61 | const three = out.find((c) => c.facadeModuleId.endsWith('Three.svelte')); 62 | expect(three.code).toContain('.route.svelte-plwlu6{background:#f0f8ff}'); 63 | 64 | const css = out.find((c) => c.name === 'svelte.css'); 65 | expect(css.source).toContain( 66 | `.layout.svelte-1pyy034{background:purple}.route.svelte-plwlu6{background:#f0f8ff}.component.svelte-5m4l82{display:flex;flex-direction:column;font-size:14px}@media(min-width:768px){.component.svelte-5m4l82{flex-direction:row}}`, 67 | ); 68 | }); 69 | 70 | it('SSR: Properly imports an npm dependency', async () => { 71 | // eslint-disable-next-line prefer-destructuring 72 | const cwd = process.cwd; 73 | 74 | const root = path.resolve('./src/rollup/__tests__/__fixtures__/external'); 75 | 76 | process.cwd = jest.fn(process.cwd).mockImplementation(() => root); 77 | 78 | const { input, plugins, output } = createSSRConfig({ 79 | input: [ 80 | path.resolve(`./src/rollup/__tests__/__fixtures__/external/src/layouts/External.svelte`), 81 | path.resolve(`./src/rollup/__tests__/__fixtures__/external/src/components/Component.svelte`), 82 | ], 83 | output: { 84 | dir: './___ELDER___/compiled/', 85 | format: 'cjs', 86 | exports: 'auto', 87 | }, 88 | multiInputConfig: false, 89 | svelteConfig: {}, 90 | elderConfig, 91 | }); 92 | 93 | const bundle = await rollup.rollup({ input, plugins }); 94 | 95 | const { output: out } = await bundle.generate({ output }); 96 | 97 | const css = out.find((c) => c.name === 'svelte.css'); 98 | expect(css.source).toContain( 99 | `.layout.svelte-1e9whng{content:'we did it.'}.component.svelte-1be6npj{background:orange}`, 100 | ); 101 | 102 | // css with the same priority is non-deterministic in the tests 103 | // node_modules is lowest priority 104 | 105 | expect( 106 | css.source.includes( 107 | `.icon.svelte-1kfpccr{background-color:#fff;border-radius:10px;width:10px;height:10px;color:#000}.button.svelte-11xgp0c{padding:10px 20px;background-color:#f50;color:#fff;font-weight:700}.layout.svelte-1e9whng{content:'we did it.'}.component.svelte-1be6npj{background:orange}`, 108 | ) || 109 | css.source.includes( 110 | `.button.svelte-11xgp0c{padding:10px 20px;background-color:#f50;color:#fff;font-weight:700}.icon.svelte-1kfpccr{background-color:#fff;border-radius:10px;width:10px;height:10px;color:#000}.layout.svelte-1e9whng{content:'we did it.'}.component.svelte-1be6npj{background:orange}`, 111 | ), 112 | ).toBe(true); 113 | 114 | const externalSvelte = out.find((c) => c.facadeModuleId && c.facadeModuleId.endsWith('External.svelte')); 115 | expect(externalSvelte.code).toContain( 116 | '.button.svelte-11xgp0c{padding:10px 20px;background-color:#f50;color:#fff;font-weight:bold}', 117 | ); 118 | 119 | const componentSvelte = out.find((c) => c.facadeModuleId.endsWith('Component.svelte')); 120 | expect(componentSvelte.code).toContain( 121 | '.icon.svelte-1kfpccr{background-color:#fff;border-radius:10px;width:10px;height:10px;color:#000}.component.svelte-1be6npj{background:orange}', 122 | ); 123 | 124 | process.cwd = cwd; 125 | }); 126 | 127 | fsExtra.copyFileSync = cfs; 128 | fsExtra.readdirSync = rds; 129 | fsExtra.ensureDirSync = eds; 130 | }); 131 | -------------------------------------------------------------------------------- /src/rollup/getRollupConfig.ts: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | import babel from 'rollup-plugin-babel'; 5 | import multiInput from 'rollup-plugin-multi-input'; 6 | import replace from '@rollup/plugin-replace'; 7 | import json from '@rollup/plugin-json'; 8 | import glob from 'glob'; 9 | import defaultsDeep from 'lodash.defaultsdeep'; 10 | import { getElderConfig } from '../index'; 11 | import { getDefaultRollup } from '../utils/validations'; 12 | import getPluginLocations from '../utils/getPluginLocations'; 13 | import elderSvelte from './rollupPlugin'; 14 | 15 | const production = process.env.NODE_ENV === 'production' || !process.env.ROLLUP_WATCH; 16 | 17 | export function createBrowserConfig({ 18 | input, 19 | output, 20 | multiInputConfig, 21 | svelteConfig, 22 | replacements = {}, 23 | elderConfig, 24 | startDevServer = false, 25 | }) { 26 | const toReplace = { 27 | 'process.env.componentType': "'browser'", 28 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 29 | preventAssignment: true, 30 | ...replacements, 31 | }; 32 | 33 | const config = { 34 | cache: true, 35 | treeshake: production, 36 | input, 37 | output, 38 | plugins: [ 39 | replace(toReplace), 40 | json(), 41 | elderSvelte({ svelteConfig, type: 'client', elderConfig, startDevServer }), 42 | nodeResolve({ 43 | browser: true, 44 | dedupe: ['svelte'], 45 | preferBuiltins: true, 46 | rootDir: process.cwd(), 47 | }), 48 | commonjs({ sourceMap: !production }), 49 | ], 50 | watch: { 51 | chokidar: { 52 | usePolling: process.platform !== 'darwin', 53 | }, 54 | }, 55 | }; 56 | 57 | // bundle splitting. 58 | if (multiInputConfig) { 59 | config.plugins.unshift(multiInputConfig); 60 | } 61 | 62 | // ie11 babel 63 | 64 | // if is production let's babelify everything and minify it. 65 | if (production) { 66 | config.plugins.push( 67 | babel({ 68 | extensions: ['.js', '.mjs', '.cjs', '.html', '.svelte'], 69 | include: ['node_modules/**', 'src/**'], 70 | exclude: ['node_modules/@babel/**'], 71 | runtimeHelpers: true, 72 | }), 73 | ); 74 | 75 | // terser on prod 76 | config.plugins.push(terser()); 77 | } 78 | 79 | return config; 80 | } 81 | 82 | export function createSSRConfig({ 83 | input, 84 | output, 85 | svelteConfig, 86 | replacements = {}, 87 | multiInputConfig, 88 | elderConfig, 89 | startDevServer = false, 90 | }) { 91 | const toReplace = { 92 | 'process.env.componentType': "'server'", 93 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 94 | ...replacements, 95 | }; 96 | 97 | const config = { 98 | cache: true, 99 | treeshake: production, 100 | input, 101 | output, 102 | plugins: [ 103 | replace(toReplace), 104 | json(), 105 | elderSvelte({ svelteConfig, type: 'ssr', elderConfig, startDevServer }), 106 | nodeResolve({ 107 | browser: false, 108 | dedupe: ['svelte'], 109 | }), 110 | commonjs({ sourceMap: true }), 111 | production && terser(), 112 | ], 113 | watch: { 114 | chokidar: { 115 | usePolling: !/^(win32|darwin)$/.test(process.platform), 116 | }, 117 | }, 118 | }; 119 | // if we are bundle splitting include them. 120 | if (multiInputConfig) { 121 | config.plugins.unshift(multiInputConfig); 122 | } 123 | 124 | return config; 125 | } 126 | 127 | export default function getRollupConfig(options) { 128 | const defaultOptions = getDefaultRollup(); 129 | const { svelteConfig, replacements, startDevServer } = defaultsDeep(options, defaultOptions); 130 | const elderConfig = getElderConfig(); 131 | const relSrcDir = elderConfig.srcDir.replace(elderConfig.rootDir, '').substr(1); 132 | 133 | console.log(`Elder.js using rollup in ${production ? 'production' : 'development'} mode.`); 134 | 135 | const configs = []; 136 | 137 | const { paths: pluginPaths } = getPluginLocations(elderConfig); 138 | const pluginGlobs = pluginPaths.map((plugin) => `${plugin}*.svelte`); 139 | 140 | configs.push( 141 | createSSRConfig({ 142 | input: [ 143 | `${relSrcDir}/layouts/*.svelte`, 144 | `${relSrcDir}/routes/**/*.svelte`, 145 | `${relSrcDir}/components/**/*.svelte`, 146 | ...pluginGlobs, 147 | ], 148 | output: { 149 | dir: elderConfig.$$internal.ssrComponents, 150 | format: 'cjs', 151 | exports: 'auto', 152 | sourcemap: !production ? 'inline' : false, 153 | }, 154 | multiInputConfig: multiInput({ 155 | relative: relSrcDir, 156 | }), 157 | svelteConfig, 158 | replacements, 159 | elderConfig, 160 | startDevServer, 161 | }), 162 | ); 163 | 164 | const clientComponents = [...glob.sync(`${relSrcDir}/components/**/*.svelte`), ...pluginGlobs]; 165 | 166 | if (clientComponents.length > 0) { 167 | // keep things from crashing of there are no components 168 | configs.push( 169 | createBrowserConfig({ 170 | input: [`${relSrcDir}/components/**/*.svelte`, ...pluginGlobs], 171 | output: [ 172 | { 173 | dir: elderConfig.$$internal.clientComponents, 174 | sourcemap: !production ? 'inline' : false, 175 | format: 'esm', 176 | entryFileNames: '[name].[hash].js', 177 | }, 178 | ], 179 | multiInputConfig: multiInput({ 180 | relative: relSrcDir, 181 | }), 182 | svelteConfig, 183 | replacements, 184 | elderConfig, 185 | startDevServer, 186 | }), 187 | ); 188 | } 189 | 190 | return configs; 191 | } 192 | -------------------------------------------------------------------------------- /src/routes/__tests__/makeDynamicPermalinkFn.spec.ts: -------------------------------------------------------------------------------- 1 | import makeDynamicPermalinkFn from '../makeDynamicPermalinkFn'; 2 | 3 | describe('#makeRoutesjsPermalink', () => { 4 | it('works with basic fill in', () => { 5 | expect(makeDynamicPermalinkFn('/blog/:id/')({ request: { id: 'foo' } })).toEqual('/blog/foo/'); 6 | }); 7 | 8 | it('works with multiple fill in', () => { 9 | expect(makeDynamicPermalinkFn('/blog/:id/:comment/')({ request: { id: 'foo', comment: 1 } })).toEqual( 10 | '/blog/foo/1/', 11 | ); 12 | }); 13 | 14 | it('works with static', () => { 15 | expect(makeDynamicPermalinkFn('/blog/')({ request: { id: 'foo', comment: 1 } })).toEqual('/blog/'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/routes/__tests__/prepareRouter.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | extractDynamicRouteParams, 3 | getDynamicRoute, 4 | findPrebuiltRequest, 5 | needsElderRequest, 6 | initialRequestIsWellFormed, 7 | requestFromDynamicRoute, 8 | } from '../prepareRouter'; 9 | 10 | describe('#prepareRouter', () => { 11 | const dynamicRoutes = [ 12 | { 13 | data: {}, 14 | permalink: () => '', 15 | template: 'Reports.svelte', 16 | layout: 'Report.svelte', 17 | name: 'reports', 18 | $$meta: { 19 | type: 'dynamic', 20 | addedBy: 'routes.js', 21 | routeString: '/dev/reports/:report/', 22 | keys: ['report'], 23 | pattern: /^\/dev\/reports\/([^/]+?)\/?$/i, 24 | }, 25 | templateComponent: () => '', 26 | layoutComponent: () => '', 27 | dynamic: true, 28 | }, 29 | { 30 | data: {}, 31 | permalink: () => '', 32 | template: 'Reports.svelte', 33 | layout: 'Report.svelte', 34 | name: 'example', 35 | $$meta: { 36 | type: 'dynamic', 37 | addedBy: 'routes.js', 38 | routeString: '/dev/example/:one/:two/', 39 | keys: ['one', 'two'], 40 | pattern: /^\/dev\/example\/([^/]+?)\/([^/]+?)\/?$/i, 41 | }, 42 | templateComponent: () => '', 43 | layoutComponent: () => '', 44 | dynamic: true, 45 | }, 46 | ]; 47 | describe('#extractDynamicRouteParams', () => { 48 | it('Extracts 1 param', () => { 49 | expect( 50 | extractDynamicRouteParams({ 51 | path: '/dev/reports/test/', 52 | $$meta: { 53 | type: 'dynamic', 54 | addedBy: 'routes.js', 55 | routeString: '/dev/reports/:report/', 56 | keys: ['report'], 57 | pattern: /^\/dev\/reports\/([^/]+?)\/?$/i, 58 | }, 59 | }), 60 | ).toEqual({ report: 'test' }); 61 | }); 62 | it('Extracts 2 params', () => { 63 | expect( 64 | extractDynamicRouteParams({ 65 | path: '/dev/example/hey/yo/', 66 | $$meta: { 67 | type: 'dynamic', 68 | addedBy: 'routes.js', 69 | routeString: '/dev/example/:one/:two/', 70 | keys: ['one', 'two'], 71 | pattern: /^\/dev\/example\/([^/]+?)\/([^/]+?)\/?$/i, 72 | }, 73 | }), 74 | ).toEqual({ one: 'hey', two: 'yo' }); 75 | }); 76 | }); 77 | describe('#getDynamicRoute', () => { 78 | it('Properly identifies dynamic routes', () => { 79 | const r = getDynamicRoute({ 80 | path: `/dev/reports/test/`, 81 | dynamicRoutes, 82 | }); 83 | expect(r && r.name).toEqual('reports'); 84 | 85 | const a = getDynamicRoute({ 86 | path: `/dev/example/test/other/`, 87 | dynamicRoutes, 88 | }); 89 | expect(a && a.name).toEqual('example'); 90 | }); 91 | }); 92 | describe('#findPrebuiltRequest', () => { 93 | const serverLookupObject = { 94 | '/': { name: 'root' }, 95 | '/test/': { name: 'test' }, 96 | }; 97 | 98 | it('Finds root', () => { 99 | expect(findPrebuiltRequest({ req: { path: '/' }, serverLookupObject })).toEqual({ 100 | name: 'root', 101 | req: { path: '/', query: undefined, search: undefined }, 102 | }); 103 | }); 104 | it('Finds finds a request it should', () => { 105 | expect(findPrebuiltRequest({ req: { path: '/test/' }, serverLookupObject })).toEqual({ 106 | name: 'test', 107 | req: { path: '/test/', query: undefined, search: undefined }, 108 | }); 109 | }); 110 | it('Finds finds a request with missing trailing slash', () => { 111 | expect(findPrebuiltRequest({ req: { path: '/test' }, serverLookupObject })).toEqual({ 112 | name: 'test', 113 | req: { path: '/test', query: undefined, search: undefined }, 114 | }); 115 | }); 116 | it('Misses a request it should', () => { 117 | expect(findPrebuiltRequest({ req: { path: '/nope' }, serverLookupObject })).toBeUndefined(); 118 | }); 119 | }); 120 | describe('#needsElderRequest', () => { 121 | it(`Doesn't include a req.path`, () => { 122 | expect(needsElderRequest({ req: {}, prefix: '' })).toBeFalsy(); 123 | }); 124 | it('No prefix Needs an elder response', () => { 125 | expect(needsElderRequest({ req: { path: '/foo' }, prefix: '' })).toBeTruthy(); 126 | }); 127 | it('Includes a prefix and needs a response', () => { 128 | expect(needsElderRequest({ req: { path: '/test/foo' }, prefix: '/test' })).toBeTruthy(); 129 | }); 130 | it('Is a /_elderjs/ request', () => { 131 | expect(needsElderRequest({ req: { path: '/test/_elderjs/svelte/test.123122.js' }, prefix: '/test' })).toBeFalsy(); 132 | }); 133 | it('Is a misc request not within prefix', () => { 134 | expect(needsElderRequest({ req: { path: '/this.123122.js' }, prefix: '/test' })).toBeFalsy(); 135 | }); 136 | }); 137 | describe('#initialRequestIsWellFormed', () => { 138 | it('Is a well formed request', () => { 139 | expect(initialRequestIsWellFormed({ permalink: '/true', type: 'server', route: 'foo' })).toBeTruthy(); 140 | }); 141 | it('Is not a well formed request', () => { 142 | // @ts-ignore 143 | expect(initialRequestIsWellFormed({ permalink: '/true', type: 'server' })).toBeFalsy(); 144 | }); 145 | }); 146 | describe('#requestFromDynamicRoute', () => { 147 | const requestCache = new Map(); 148 | it('Parses a dynamic route into a request properly and populates cache', () => { 149 | expect(requestFromDynamicRoute({ req: { path: '/dev/reports/hereitis/' }, requestCache, dynamicRoutes })).toEqual( 150 | { 151 | permalink: '', 152 | report: 'hereitis', 153 | req: { path: '/dev/reports/hereitis/', query: undefined, search: undefined }, 154 | route: 'reports', 155 | type: 'server', 156 | }, 157 | ); 158 | expect(requestCache.has('/dev/reports/hereitis/')).toBeTruthy(); 159 | }); 160 | it('Parses a dynamic route into a request properly and skips the cache', () => { 161 | expect( 162 | requestFromDynamicRoute({ 163 | req: { path: '/dev/reports/without-cache/' }, 164 | requestCache: undefined, 165 | dynamicRoutes, 166 | }), 167 | ).toEqual({ 168 | permalink: '', 169 | report: 'without-cache', 170 | req: { path: '/dev/reports/without-cache/', query: undefined, search: undefined }, 171 | route: 'reports', 172 | type: 'server', 173 | }); 174 | expect(requestCache.has('/dev/reports/without-cache/')).toBeFalsy(); 175 | }); 176 | it('correctly adds in different search and query params', () => { 177 | expect( 178 | requestFromDynamicRoute({ 179 | req: { path: '/dev/reports/somethingnew/', query: { foo: 'bar' }, search: '?foo=bar' }, 180 | requestCache, 181 | dynamicRoutes, 182 | }), 183 | ).toEqual({ 184 | permalink: '', 185 | report: 'somethingnew', 186 | req: { path: '/dev/reports/somethingnew/', query: { foo: 'bar' }, search: '?foo=bar' }, 187 | route: 'reports', 188 | type: 'server', 189 | }); 190 | expect(requestCache.has('/dev/reports/somethingnew/')).toBeTruthy(); 191 | }); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /src/routes/makeDynamicPermalinkFn.ts: -------------------------------------------------------------------------------- 1 | import regexparam from 'regexparam'; 2 | 3 | export default function makeDynamicPermalinkFn(routeString) { 4 | return function permalink({ request }) { 5 | return regexparam.inject(routeString, request); 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/routes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-cond-assign */ 2 | /* eslint-disable no-param-reassign */ 3 | /* eslint-disable global-require */ 4 | /* eslint-disable import/no-dynamic-require */ 5 | import glob from 'glob'; 6 | import kebabcase from 'lodash.kebabcase'; 7 | import toRegExp from 'regexparam'; 8 | import path from 'path'; 9 | 10 | import { svelteComponent } from '../utils'; 11 | import { SettingsOptions } from '../utils/types'; 12 | import wrapPermalinkFn from '../utils/wrapPermalinkFn'; 13 | import windowsPathFix from '../utils/windowsPathFix'; 14 | import makeDynamicPermalinkFn from './makeDynamicPermalinkFn'; 15 | 16 | const requireFile = (file: string) => { 17 | const dataReq = require(file); 18 | return dataReq.default || dataReq; 19 | }; 20 | 21 | function prepareRoutes(settings: SettingsOptions) { 22 | try { 23 | const { ssrComponents: ssrFolder, serverPrefix = '' } = settings.$$internal; 24 | 25 | const files = glob.sync(`${settings.srcDir}/routes/*/+(*.js|*.svelte)`).map((p) => windowsPathFix(p)); 26 | const routejsFiles = files.filter((f) => f.endsWith('/route.js')); 27 | 28 | const routes = {}; 29 | 30 | /** 31 | * Set Defaults in Route.js files 32 | * Add them to the 'routes' object 33 | */ 34 | 35 | routejsFiles.forEach((routeFile) => { 36 | const routeName = routeFile.replace('/route.js', '').split('/').pop(); 37 | const route = requireFile(routeFile); 38 | route.$$meta = { 39 | type: 'file', 40 | addedBy: routeFile, 41 | }; 42 | 43 | const filesForThisRoute = files 44 | .filter((r) => r.includes(`/routes/${routeName}`)) 45 | .filter((r) => !r.includes('route.js')); 46 | 47 | if (!route.name) { 48 | route.name = routeName; 49 | } 50 | 51 | // handle string based permalinks 52 | if (typeof route.permalink === 'string') { 53 | const routeString = `${serverPrefix}${route.permalink}`; 54 | route.permalink = makeDynamicPermalinkFn(route.permalink); 55 | 56 | route.$$meta = { 57 | ...route.$$meta, 58 | routeString, 59 | ...toRegExp.parse(routeString), 60 | type: route.dynamic ? `dynamic` : 'static', 61 | }; 62 | } 63 | 64 | // set default permalink if it doesn't exist. 65 | if (!route.permalink) { 66 | route.permalink = ({ request }) => (request.slug === '/' ? request.slug : `/${request.slug}/`); 67 | } 68 | route.permalink = wrapPermalinkFn({ permalinkFn: route.permalink, routeName, settings }); 69 | 70 | // set default all() 71 | if (!Array.isArray(route.all) && typeof route.all !== 'function') { 72 | if (routeName.toLowerCase() === 'home') { 73 | route.all = [{ slug: '/' }]; 74 | } else { 75 | route.all = [{ slug: kebabcase(routeName) }]; 76 | } 77 | } 78 | 79 | // set default data as it is optional. 80 | if (!route.data) { 81 | route.data = (page) => { 82 | page.data = {}; 83 | }; 84 | } 85 | 86 | // find svelte template or set default 87 | if (!route.template) { 88 | const defaultLocation = path.resolve(settings.srcDir, `./routes/${routeName}/${routeName}.svelte`); 89 | const svelteFile = filesForThisRoute.find((f) => 90 | f.toLowerCase().endsWith(windowsPathFix(defaultLocation.toLowerCase())), 91 | ); 92 | if (!svelteFile) { 93 | console.error( 94 | `No template for route named "${routeName}". Expected to find it at: ${path.resolve( 95 | settings.srcDir, 96 | defaultLocation, 97 | )}. If you are using a different template please define it in your route.js file.`, 98 | ); 99 | } 100 | 101 | route.template = defaultLocation; 102 | } 103 | 104 | // set default layout 105 | if (!route.layout) { 106 | route.layout = `Layout.svelte`; 107 | } 108 | 109 | routes[routeName] = route; 110 | }); 111 | 112 | const ssrComponents = glob.sync(`${ssrFolder}/**/*.js`).map((p) => windowsPathFix(p)); 113 | Object.keys(routes).forEach((routeName) => { 114 | const ssrTemplate = ssrComponents.find((f) => { 115 | const suffix = routes[routeName].template 116 | .toLowerCase() 117 | .replace('.svelte', '.js') 118 | .replace(settings.srcDir.toLowerCase(), ''); 119 | return f.toLowerCase().endsWith(windowsPathFix(suffix)); 120 | }); 121 | if (!ssrTemplate) { 122 | console.error( 123 | `No SSR template found for ${routeName}. Expected at ${routes[routeName].template.replace( 124 | '.svelte', 125 | '.js', 126 | )}. Make sure rollup finished running.`, 127 | ); 128 | } 129 | 130 | const ssrLayout = ssrComponents.find((f) => { 131 | const suffix = routes[routeName].layout 132 | .toLowerCase() 133 | .replace('.svelte', '.js') 134 | .replace(settings.srcDir.toLowerCase(), ''); 135 | return f.toLowerCase().endsWith(windowsPathFix(suffix)); 136 | }); 137 | if (!ssrLayout) { 138 | console.error( 139 | `No SSR Layout found for ${routeName}. Expected at ${routes[routeName].layout.replace( 140 | '.svelte', 141 | '.js', 142 | )}. Make sure rollup finished running.`, 143 | ); 144 | } 145 | 146 | routes[routeName].templateComponent = svelteComponent(routes[routeName].template, 'routes'); 147 | routes[routeName].layoutComponent = svelteComponent(routes[routeName].layout, 'layouts'); 148 | }); 149 | 150 | return routes; 151 | } catch (e) { 152 | console.error(e); 153 | return {}; 154 | } 155 | } 156 | 157 | export default prepareRoutes; 158 | -------------------------------------------------------------------------------- /src/routes/types.ts: -------------------------------------------------------------------------------- 1 | import type { HookOptions } from '../hooks/types'; 2 | 3 | interface Permalink { 4 | (input?: Object): string; 5 | } 6 | 7 | export type StateSlug = { 8 | slug: string; 9 | id: Number; 10 | }; 11 | 12 | type MetaOptions = { 13 | type: string; 14 | addedBy: string; 15 | pattern?: RegExp; 16 | routeString?: string; 17 | keys?: string[]; 18 | }; 19 | 20 | type RequestObject = { 21 | slug: string; 22 | }; 23 | 24 | // TODO: cleanup to remove ElderGuide.com specific things. 25 | export type RouteOptions = { 26 | template?: string; 27 | templateComponent?: (string) => Object; 28 | layout?: string; 29 | layoutComponent?: (string) => Object; 30 | data?: Object | (() => Object); 31 | permalink: Permalink; 32 | all?: [RequestObject] | ((Object) => [RequestObject] | Promise); 33 | $$meta?: MetaOptions; 34 | name: string; 35 | hooks?: Array; 36 | dynamic?: boolean; 37 | }; 38 | 39 | export type RoutesOptions = { 40 | [name: string]: RouteOptions; 41 | }; 42 | -------------------------------------------------------------------------------- /src/shortcodes/__tests__/shortcodes.spec.ts: -------------------------------------------------------------------------------- 1 | import shortcodes from '..'; 2 | 3 | describe('#shortcodes', () => { 4 | it('contains all shortcodes we want', () => { 5 | expect(shortcodes).toEqual([ 6 | { 7 | shortcode: 'svelteComponent', 8 | run: expect.any(Function), 9 | $$meta: { 10 | addedBy: 'elder', 11 | type: 'elder', 12 | }, 13 | }, 14 | ]); 15 | }); 16 | 17 | it('[svelteComponent] run function behaves as expected', async () => { 18 | // throws error 19 | await expect(() => 20 | shortcodes[0].run({ 21 | props: {}, 22 | helpers: null, 23 | }), 24 | ).rejects.toThrow('svelteComponent shortcode requires a name="" property.'); 25 | // parse nothing 26 | expect( 27 | await shortcodes[0].run({ 28 | props: { 29 | name: 'ParseNothing', 30 | something: 12, 31 | }, 32 | helpers: { 33 | inlineSvelteComponent: ({ name, props, options }) => 34 | `${name}${JSON.stringify(props)}${JSON.stringify(options)}`, 35 | }, 36 | }), 37 | ).toEqual({ 38 | html: 'ParseNothing{}{}', 39 | }); 40 | expect( 41 | await shortcodes[0].run({ 42 | props: { 43 | name: 'ParseProps', 44 | props: '{"foo":"bar", "count":42}', 45 | }, 46 | helpers: { 47 | inlineSvelteComponent: ({ name, props, options }) => 48 | `${name}${JSON.stringify(props)}${JSON.stringify(options)}`, 49 | }, 50 | }), 51 | ).toEqual({ 52 | html: 'ParseProps{"foo":"bar","count":42}{}', 53 | }); 54 | expect( 55 | await shortcodes[0].run({ 56 | props: { 57 | name: 'ParseOptions', 58 | options: '{"foo":"bar", "count":37}', 59 | }, 60 | helpers: { 61 | inlineSvelteComponent: ({ name, props, options }) => 62 | `${name}${JSON.stringify(props)}${JSON.stringify(options)}`, 63 | }, 64 | }), 65 | ).toEqual({ 66 | html: 'ParseOptions{}{"foo":"bar","count":37}', 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/shortcodes/index.ts: -------------------------------------------------------------------------------- 1 | import { ShortcodeDefs } from './types'; 2 | 3 | const shortcodes: ShortcodeDefs = [ 4 | { 5 | shortcode: 'svelteComponent', 6 | run: async ({ props, helpers }) => { 7 | if (!props.name) throw new Error(`svelteComponent shortcode requires a name="" property.`); 8 | 9 | let parsedProps = {}; 10 | try { 11 | parsedProps = JSON.parse(props.props); 12 | } catch { 13 | console.error( 14 | `Can't parse ${props.name} svelteComponent props=${props.props} to JSON. It needs to be serializable.`, 15 | ); 16 | } 17 | 18 | let parsedOptions = {}; 19 | try { 20 | parsedOptions = JSON.parse(props.options); 21 | } catch { 22 | console.error( 23 | `Can't parse ${props.name} svelteComponent options=${props.options} to JSON. It needs to be serializable.`, 24 | ); 25 | } 26 | return { 27 | html: helpers.inlineSvelteComponent({ 28 | name: props.name, 29 | props: parsedProps, 30 | options: parsedOptions, 31 | }), 32 | }; 33 | }, 34 | $$meta: { 35 | addedBy: 'elder', 36 | type: 'elder', 37 | }, 38 | }, 39 | ]; 40 | 41 | export default shortcodes; 42 | -------------------------------------------------------------------------------- /src/shortcodes/types.ts: -------------------------------------------------------------------------------- 1 | export interface ShortcodeResponse { 2 | html?: string; 3 | css?: string; 4 | js?: string; 5 | head?: string; 6 | } 7 | 8 | export interface ShortcodeDef { 9 | shortcode: string; 10 | run: (any) => ShortcodeResponse | Promise; 11 | plugin?: any; // reference to the plugin closure scope. 12 | $$meta: { 13 | addedBy: string; 14 | type: string; 15 | }; 16 | } 17 | 18 | export type ShortcodeDefs = Array; 19 | -------------------------------------------------------------------------------- /src/utils/__tests__/Page.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import Page from '../Page'; 3 | import normalizeSnapshot from '../normalizeSnapshot'; 4 | 5 | jest.mock('../getUniqueId', () => () => 'xxxxxxxxxx'); 6 | jest.mock('../prepareProcessStack', () => (page) => (stackName) => { 7 | const data = { 8 | headStack: 'headStack', 9 | cssStack: 'cssStack', 10 | hydrateStack: 'hydrateStack', 11 | beforeHydrateStack: 'beforeHydrateStack', 12 | customJsStack: 'customJsStack', 13 | footerStack: 'footerStack', 14 | }; 15 | if (!page.hydrateStack || !page.hydrateStack.length) { 16 | // should be done in stacks hook 17 | page.footerStack = ['footerStack']; 18 | page.customJsStack = ['customJsStack']; 19 | page.hydrateStack = ['hydrateStack']; 20 | } 21 | if (data[stackName]) { 22 | return data[stackName]; 23 | } 24 | return ''; 25 | }); 26 | jest.mock('../perf', () => (page) => { 27 | page.perf = { 28 | timings: [], 29 | start: jest.fn(), 30 | end: jest.fn(), 31 | stop: jest.fn(), 32 | prefix: jest.fn(), 33 | }; 34 | }); 35 | 36 | const allRequests = [ 37 | { 38 | slug: 'ash-flat', 39 | random: 94, 40 | state: { 41 | id: 6, 42 | slug: 'arkansas', 43 | }, 44 | route: 'cityNursingHomes', 45 | type: 'build', 46 | permalink: '/arkansas/ash-flat-nursing-homes/', 47 | }, 48 | { 49 | slug: 'albertville', 50 | random: 33, 51 | state: { 52 | id: 4, 53 | slug: 'alabama', 54 | }, 55 | route: 'cityNursingHomes', 56 | type: 'build', 57 | permalink: '/alabama/albertville-nursing-homes/', 58 | }, 59 | ]; 60 | 61 | const request = { 62 | slug: 'ash-flat', 63 | random: 94, 64 | state: { 65 | id: 6, 66 | slug: 'arkansas', 67 | }, 68 | route: 'cityNursingHomes', 69 | type: 'build', 70 | permalink: '/arkansas/ash-flat-nursing-homes/', 71 | }; 72 | 73 | const settings = { 74 | distDir: 'test', 75 | server: false, 76 | build: { 77 | shuffleRequests: false, 78 | numberOfWorkers: -1, 79 | }, 80 | locations: { 81 | public: './public/', 82 | svelte: { 83 | ssrComponents: './___ELDER___/compiled/', 84 | clientComponents: './public/dist/svelte/', 85 | }, 86 | intersectionObserverPoly: '/dist/static/intersection-observer.js', 87 | buildFolder: '', 88 | srcFolder: './src/', 89 | assets: './public/dist/static/', 90 | }, 91 | debug: { 92 | stacks: false, 93 | hooks: true, 94 | performance: false, 95 | build: false, 96 | automagic: false, 97 | }, 98 | props: {}, 99 | hooks: { 100 | disable: [], 101 | }, 102 | plugins: { 103 | 'elder-plugin-upload-s3': { 104 | dataBucket: 'elderguide.com', 105 | htmlBucket: 'elderguide.com', 106 | deployId: '11111111', 107 | }, 108 | }, 109 | typescript: false, 110 | $$internal: { 111 | distElder: 'test/_elderjs', 112 | hashedComponents: { 113 | AutoComplete: 'entryAutoComplete', 114 | Footer: 'entryFooter', 115 | Header: 'entryHeader', 116 | HeaderAutoComplete: 'entryHeaderAutoComplete', 117 | Home: 'entryHomeAutoComplete', 118 | HomeAutoComplete: 'entryHomeAutoComplete', 119 | Modal: 'entryModal', 120 | }, 121 | }, 122 | worker: true, 123 | cacheBustingId: 'XmjyWGYZtV', 124 | }; 125 | 126 | const query = { 127 | db: { 128 | db: {}, 129 | pool: { 130 | _events: {}, 131 | _eventsCount: 0, 132 | options: { 133 | connectionString: 'postgresql://user:user@localhost:5099/db', 134 | max: 10, 135 | idleTimeoutMillis: 10000, 136 | }, 137 | _clients: [], 138 | _idle: [], 139 | _pendingQueue: [], 140 | ending: false, 141 | ended: false, 142 | }, 143 | cnString: { 144 | connectionString: 'postgresql://user:user@localhost:5099/db', 145 | }, 146 | }, 147 | }; 148 | 149 | const helpers = { 150 | permalinks: {}, 151 | metersInAMile: 0.00062137119224, 152 | }; 153 | 154 | const route = { 155 | hooks: [], 156 | template: 'Content.svelte', 157 | data: () => Promise.resolve({ worldPopulation: 7805564950 }), 158 | templateComponent: jest.fn(), 159 | layout: 'Layout.svelte', 160 | layoutComponent: jest.fn(() => '
'), 161 | parent: 'home', 162 | $$meta: { 163 | type: 'route', 164 | addedBy: 'routejs', 165 | }, 166 | }; 167 | 168 | const routes = { 169 | content: { 170 | hooks: [], 171 | template: 'Content.svelte', 172 | $$meta: { 173 | type: 'route', 174 | addedBy: 'routejs', 175 | }, 176 | }, 177 | home: { 178 | hooks: [ 179 | { 180 | hook: 'data', 181 | name: 'testToData', 182 | description: 'Adds test to data object', 183 | priority: 50, 184 | }, 185 | ], 186 | template: 'Home.svelte', 187 | $$meta: { 188 | type: 'route', 189 | addedBy: 'routejs', 190 | }, 191 | }, 192 | }; 193 | 194 | describe('#Page', () => { 195 | const hooks = []; 196 | const runHook = (hookName) => { 197 | hooks.push(hookName); 198 | }; 199 | 200 | const pageInput = { 201 | allRequests, 202 | request, 203 | settings, 204 | query, 205 | helpers, 206 | data: {}, 207 | route, 208 | routes, 209 | errors: [], 210 | runHook, 211 | shortcodes: [], 212 | perf: { 213 | start: () => {}, 214 | stop: () => {}, 215 | }, 216 | }; 217 | 218 | it('initialize and build', async () => { 219 | const page = new Page(pageInput); 220 | expect(normalizeSnapshot(page)).toMatchSnapshot(); 221 | await page.build(); 222 | 223 | expect(hooks).toEqual([ 224 | 'request', 225 | 'data', 226 | 'shortcodes', 227 | 'stacks', 228 | 'head', 229 | 'compileHtml', 230 | 'html', 231 | 'requestComplete', 232 | ]); 233 | expect(normalizeSnapshot(page)).toMatchSnapshot(); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /src/utils/__tests__/__snapshots__/perf.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`#perf works in performance and mocks 1`] = ` 4 | Object { 5 | "htmlString": "", 6 | "perf": Object { 7 | "end": [Function], 8 | "prefix": [Function], 9 | "start": [Function], 10 | "stop": [Function], 11 | "timings": Array [ 12 | Object { 13 | "duration": 0.05, 14 | "name": "Page", 15 | }, 16 | ], 17 | }, 18 | "settings": Object { 19 | "debug": Object { 20 | "performance": true, 21 | }, 22 | }, 23 | "uid": "xxxxxxxx", 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /src/utils/__tests__/asyncForEach.spec.ts: -------------------------------------------------------------------------------- 1 | import asyncForEach from '../asyncForEach'; 2 | 3 | test('#asyncForEach', async () => { 4 | const timeouts = [50, 10, 20]; 5 | const counter = jest.fn(); 6 | const cb = async (_, i) => { 7 | await new Promise((resolve) => setTimeout(resolve, timeouts[i])); 8 | counter(i); 9 | }; 10 | await asyncForEach(['a', 'b', 'c'], cb); 11 | expect(counter.mock.calls).toHaveLength(3); 12 | // The first argument of the first call to the function was 0 13 | expect(counter.mock.calls[0][0]).toBe(0); 14 | // The first argument of the second call to the function was 1 15 | expect(counter.mock.calls[1][0]).toBe(1); 16 | // The first argument of the third call to the function was 2 17 | expect(counter.mock.calls[2][0]).toBe(2); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/__tests__/capitalizeFirstLetter.spec.ts: -------------------------------------------------------------------------------- 1 | import capitalizeFirstLetter from '../capitalizeFirstLetter'; 2 | 3 | test('#capitalizeFirstLetter', async () => { 4 | expect(capitalizeFirstLetter('abcd')).toBe('Abcd'); 5 | expect(capitalizeFirstLetter('Abcd')).toBe('Abcd'); 6 | expect(capitalizeFirstLetter('ABCD')).toBe('ABCD'); 7 | expect(capitalizeFirstLetter(' bcd')).toBe(' bcd'); 8 | expect(capitalizeFirstLetter('.bcd')).toBe('.bcd'); 9 | expect(capitalizeFirstLetter('a b c d')).toBe('A b c d'); 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/__tests__/createReadOnlyProxy.spec.ts: -------------------------------------------------------------------------------- 1 | import createReadOnlyProxy from '../createReadOnlyProxy'; 2 | 3 | test('#createReadOnlyProxy', () => { 4 | const readOnly = { 5 | name: 'I am just a read only object', 6 | doNotMutateMe: 42, 7 | }; 8 | const proxy = createReadOnlyProxy(readOnly, 'readOnly', 'createReadOnlyProxy.spec.ts'); 9 | try { 10 | // @ts-ignore 11 | proxy.doNotMutateMe = 55; 12 | } catch (e) { 13 | // expected 14 | } 15 | // @ts-ignore 16 | expect(proxy.doNotMutateMe).toBe(42); 17 | // @ts-ignore 18 | expect(proxy.name).toBe(readOnly.name); 19 | }); 20 | -------------------------------------------------------------------------------- /src/utils/__tests__/getPluginLocations.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | describe('#getPluginLocations', () => { 4 | const path = require('path'); 5 | it('getPluginPaths works', () => { 6 | jest.mock('glob', () => ({ 7 | sync: jest 8 | .fn() 9 | 10 | .mockImplementationOnce(() => [ 11 | '/src/plugins/elderjs-plugin-reload/SimplePlugin.svelte', 12 | '/src/plugins/elderjs-plugin-reload/Test.svelte', 13 | ]) 14 | .mockImplementationOnce(() => ['/node_modules/@elderjs/plugin-browser-reload/Test.svelte']), 15 | })); 16 | 17 | jest.mock('fs-extra', () => ({ 18 | existsSync: jest 19 | .fn() 20 | .mockImplementationOnce(() => true) // first plugin from src 21 | .mockImplementationOnce(() => false) // 2nd from node modules 22 | .mockImplementationOnce(() => true), 23 | })); 24 | 25 | expect( 26 | // @ts-ignore 27 | require('../getPluginLocations').default({ 28 | srcDir: './src', 29 | rootDir: './', 30 | plugins: { 31 | 'elderjs-plugin-reload': {}, 32 | '@elderjs/plugin-browser-reload': {}, 33 | }, 34 | }), 35 | ).toEqual({ 36 | paths: [ 37 | `${path.resolve('./src/plugins/elderjs-plugin-reload/')}/`, 38 | `${path.resolve('./node_modules/@elderjs/plugin-browser-reload/')}/`, 39 | ], 40 | files: [ 41 | '/src/plugins/elderjs-plugin-reload/SimplePlugin.svelte', 42 | '/src/plugins/elderjs-plugin-reload/Test.svelte', 43 | '/node_modules/@elderjs/plugin-browser-reload/Test.svelte', 44 | ], 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/utils/__tests__/getUniqueId.spec.ts: -------------------------------------------------------------------------------- 1 | import getUniqueId from '../getUniqueId'; 2 | 3 | jest.mock('nanoid/non-secure', () => ({ customAlphabet: (_, len) => () => 'x'.repeat(len) })); 4 | 5 | test('#getUniqueId', () => { 6 | expect(getUniqueId()).toBe('xxxxxxxxxx'); 7 | expect(getUniqueId()).toHaveLength(10); 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '..'; 2 | 3 | test('includes all', () => { 4 | expect(Object.keys(utils)).toEqual([ 5 | 'asyncForEach', 6 | 'capitalizeFirstLetter', 7 | 'svelteComponent', 8 | 'getUniqueId', 9 | 'validateShortcode', 10 | 'Page', 11 | 'parseBuildPerf', 12 | 'perf', 13 | 'permalinks', 14 | 'prepareRunHook', 15 | 'validateHook', 16 | 'validateRoute', 17 | 'validatePlugin', 18 | 'shuffleArray', 19 | 'prepareServer', 20 | 'prepareProcessStack', 21 | 'getConfig', 22 | 'getRollupConfig', 23 | 'prepareInlineShortcode', 24 | ]); 25 | }); 26 | -------------------------------------------------------------------------------- /src/utils/__tests__/normalizePrefix.spec.ts: -------------------------------------------------------------------------------- 1 | import normalizePrefix from '../normalizePrefix'; 2 | 3 | describe('#normalizePrefix', () => { 4 | const correctPrefix = '/testing'; 5 | 6 | it('returns correct prefix providing a prefix without leading and trailing "/"', () => { 7 | const result = normalizePrefix('testing'); 8 | 9 | expect(result).toEqual(correctPrefix); 10 | }); 11 | 12 | it('returns correct prefix providing a prefix with a leading "/"', () => { 13 | const result = normalizePrefix('/testing'); 14 | 15 | expect(result).toEqual(correctPrefix); 16 | }); 17 | 18 | it('returns correct prefix providing a prefix with a trailing "/"', () => { 19 | const result = normalizePrefix('testing/'); 20 | 21 | expect(result).toEqual(correctPrefix); 22 | }); 23 | 24 | it('returns correct prefix providing a prefix with leading and trailing "/"', () => { 25 | const result = normalizePrefix('/testing/'); 26 | 27 | expect(result).toEqual(correctPrefix); 28 | }); 29 | 30 | it('returns correct prefix providing a prefix with multiple trailing "/"', () => { 31 | const result = normalizePrefix('testing//'); 32 | 33 | expect(result).toEqual(correctPrefix); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/utils/__tests__/normalizeSnapshot.spec.ts: -------------------------------------------------------------------------------- 1 | import normalizeSnapshot from '../normalizeSnapshot'; 2 | 3 | describe('#normalizeSnapshot', () => { 4 | const obj = { 5 | arr: ['\\elderjs\\elderjs\\test.js', '\\elderjs\\elderjs\\src.js', '/linuxpath/'], 6 | str: '\\test\\', 7 | obj: { 8 | linux: '/normal/path/here/', 9 | windows: '\\test\\it\\all\\', 10 | windowsArr: ['\\elderjs\\elderjs\\test.js', '\\elderjs\\elderjs\\src.js', '/linuxpath/'], 11 | obj: { 12 | linux: '/normal/path/here/', 13 | windows: '\\test\\it\\all\\', 14 | windowsArr: ['\\elderjs\\elderjs\\test.js', '\\elderjs\\elderjs\\src.js', '/linuxpath/'], 15 | obj: { 16 | linux: '/normal/path/here/', 17 | windows: '\\test\\it\\all\\', 18 | windowsArr: ['\\elderjs\\elderjs\\test.js', '\\elderjs\\elderjs\\src.js', '/linuxpath/'], 19 | obj: { 20 | linux: '/normal/path/here/', 21 | windows: '\\test\\it\\all\\', 22 | windowsArr: ['\\elderjs\\elderjs\\test.js', '\\elderjs\\elderjs\\src.js', '/linuxpath/'], 23 | }, 24 | }, 25 | }, 26 | }, 27 | }; 28 | it('Properly converts windows paths on an object, array, string', () => { 29 | expect(normalizeSnapshot(obj)).toEqual({ 30 | arr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 31 | obj: { 32 | linux: '/normal/path/here/', 33 | obj: { 34 | linux: '/normal/path/here/', 35 | obj: { 36 | linux: '/normal/path/here/', 37 | obj: { 38 | linux: '/normal/path/here/', 39 | windows: '/test/it/all/', 40 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 41 | }, 42 | windows: '/test/it/all/', 43 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 44 | }, 45 | windows: '/test/it/all/', 46 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 47 | }, 48 | windows: '/test/it/all/', 49 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 50 | }, 51 | str: '/test/', 52 | }); 53 | }); 54 | 55 | it('Returns nulls, undefineds, dates, etc', () => { 56 | expect( 57 | normalizeSnapshot({ ...obj, n: null, u: undefined, d: new Date('2020-11-25T14:09:06.448Z'), na: NaN }), 58 | ).toEqual({ 59 | na: NaN, 60 | n: null, 61 | u: undefined, 62 | d: new Date('2020-11-25T14:09:06.448Z'), 63 | arr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 64 | obj: { 65 | linux: '/normal/path/here/', 66 | obj: { 67 | linux: '/normal/path/here/', 68 | obj: { 69 | linux: '/normal/path/here/', 70 | obj: { 71 | linux: '/normal/path/here/', 72 | windows: '/test/it/all/', 73 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 74 | }, 75 | windows: '/test/it/all/', 76 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 77 | }, 78 | windows: '/test/it/all/', 79 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 80 | }, 81 | windows: '/test/it/all/', 82 | windowsArr: ['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/'], 83 | }, 84 | str: '/test/', 85 | }); 86 | }); 87 | 88 | it('Handles fns', () => { 89 | const lamdba = () => 'foo'; 90 | function fn() { 91 | return true; 92 | } 93 | expect( 94 | normalizeSnapshot({ 95 | n: null, 96 | u: undefined, 97 | d: new Date('2020-11-25T14:09:06.448Z'), 98 | na: NaN, 99 | lamdba, 100 | fn, 101 | }), 102 | ).toMatchObject({ 103 | na: NaN, 104 | n: null, 105 | u: undefined, 106 | d: new Date('2020-11-25T14:09:06.448Z'), 107 | lamdba, 108 | fn, 109 | }); 110 | }); 111 | 112 | it('Handles sets and maps', () => { 113 | const map = new Map(); 114 | map.set('\\elderjs\\elderjs\\test.js', ['\\elderjs\\elderjs\\src.js', '/linuxpath/']); 115 | map.set('\\elderjs\\obj\\test.js', { '\\elderjs\\obj\\src.js': ['\\elderjs\\elderjs\\src.js', '/linuxpath/'] }); 116 | 117 | const outMap = new Map(); 118 | outMap.set('/elderjs/elderjs/test.js', ['/elderjs/elderjs/src.js', '/linuxpath/']); 119 | outMap.set('/elderjs/obj/test.js', { '/elderjs/obj/src.js': ['/elderjs/elderjs/src.js', '/linuxpath/'] }); 120 | 121 | expect( 122 | normalizeSnapshot({ 123 | set: new Set(['\\elderjs\\elderjs\\test.js', '\\elderjs\\elderjs\\src.js', '/linuxpath/']), 124 | map, 125 | }), 126 | ).toMatchObject({ 127 | map: outMap, 128 | set: new Set(['/elderjs/elderjs/test.js', '/elderjs/elderjs/src.js', '/linuxpath/']), 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/utils/__tests__/notProduction.spec.ts: -------------------------------------------------------------------------------- 1 | const notProduction = require('../notProduction'); 2 | 3 | describe('#notProduction', () => { 4 | it('false on process.env.NODE_ENV === production', () => { 5 | process.env.NODE_ENV = 'production'; 6 | expect(notProduction.default()).toBe(false); 7 | }); 8 | it('false on process.env.NODE_ENV === PRODUCTION', () => { 9 | process.env.NODE_ENV = 'PRODUCTION'; 10 | expect(notProduction.default()).toBe(false); 11 | }); 12 | it('false on process.env.NODE_ENV === PRODUCtion', () => { 13 | process.env.NODE_ENV = 'PRODUCtion'; 14 | expect(notProduction.default()).toBe(false); 15 | }); 16 | 17 | it('true on process.env.NODE_ENV === dev', () => { 18 | process.env.NODE_ENV = 'dev'; 19 | expect(notProduction.default()).toBe(true); 20 | }); 21 | it('true on process.env.NODE_ENV === any', () => { 22 | process.env.NODE_ENV = 'any'; 23 | expect(notProduction.default()).toBe(true); 24 | }); 25 | it('true on process.env.NODE_ENV === undefined', () => { 26 | process.env.NODE_ENV = undefined; 27 | expect(notProduction.default()).toBe(true); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/utils/__tests__/outputStyles.spec.ts: -------------------------------------------------------------------------------- 1 | const outputStyles = require('../outputStyles'); 2 | 3 | const svelteCss = [ 4 | { css: '.one{}', cssMap: 'one' }, 5 | { css: '.two{}', cssMap: 'two' }, 6 | ]; 7 | 8 | describe('#outputStyles', () => { 9 | it('on production returns css string, then merged svelte strings', () => { 10 | process.env.NODE_ENV = 'production'; 11 | const result = outputStyles.default({ 12 | request: { type: 'server' }, 13 | svelteCss, 14 | cssString: '.cssString{}', 15 | }); 16 | expect(result).toBe(''); 17 | }); 18 | 19 | it('on page.request.type="build" returns css string, then merged svelte strings', () => { 20 | process.env.NODE_ENV = 'dev'; 21 | const result = outputStyles.default({ 22 | request: { type: 'build' }, 23 | svelteCss, 24 | cssString: '.cssString{}', 25 | }); 26 | expect(result).toBe(''); 27 | }); 28 | 29 | it("when process.env.NODE_ENV is not production and isn't build, returns cssString wrapped in a style tag and svelte components in individual style tags", () => { 30 | process.env.NODE_ENV = 'dev'; 31 | const result = outputStyles.default({ 32 | request: { type: 'server' }, 33 | svelteCss, 34 | cssString: '.cssString{}', 35 | }); 36 | expect(result).toBe(''); 37 | }); 38 | 39 | it('when no svelte component css, it just returns the css string.', () => { 40 | process.env.NODE_ENV = 'dev'; 41 | const result = outputStyles.default({ 42 | request: { type: 'server' }, 43 | svelteCss: [], 44 | cssString: '.cssString{}', 45 | }); 46 | expect(result).toBe(''); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/utils/__tests__/perf.spec.ts: -------------------------------------------------------------------------------- 1 | import normalizeSnapshot from '../normalizeSnapshot'; 2 | 3 | class PerformanceObserverMock { 4 | cb: (any) => void; 5 | 6 | constructor(cb) { 7 | this.cb = cb; 8 | } 9 | 10 | observe() { 11 | this.cb({ getEntries: () => [{ name: 'Page-xxxxxxxx', duration: 0.05 }] }); 12 | } 13 | 14 | disconnect() { 15 | this.cb = null; 16 | } 17 | } 18 | 19 | describe('#perf', () => { 20 | it('works in performance and mocks', () => { 21 | function MockPage() { 22 | this.uid = 'xxxxxxxx'; 23 | this.htmlString = ''; 24 | this.settings = { 25 | debug: { 26 | performance: true, 27 | }, 28 | }; 29 | } 30 | const calls = []; 31 | jest.mock('perf_hooks', () => ({ 32 | PerformanceObserver: PerformanceObserverMock, 33 | performance: { 34 | mark: (i) => calls.push(`mark ${i}`), 35 | measure: (i) => calls.push(`measure ${i}`), 36 | clearMarks: (i) => calls.push(`clearMarks ${i}`), 37 | }, 38 | })); 39 | const mockPage = new MockPage(); 40 | // eslint-disable-next-line global-require 41 | const perf = require('../perf').default; 42 | 43 | // mutate 44 | perf(mockPage); 45 | 46 | mockPage.perf.start('test'); 47 | mockPage.perf.end('test'); 48 | 49 | const prefixed = mockPage.perf.prefix('prefix'); 50 | 51 | prefixed.start('prefix'); 52 | prefixed.end('prefix'); 53 | 54 | mockPage.perf.stop(); 55 | 56 | expect(normalizeSnapshot(mockPage)).toMatchSnapshot(); 57 | expect(calls).toEqual([ 58 | 'clearMarks Page-xxxxxxxx', 59 | 'mark test-start-xxxxxxxx', 60 | 'mark test-end-xxxxxxxx', 61 | 'measure test-xxxxxxxx', 62 | 'mark prefix.prefix-start-xxxxxxxx', 63 | 'mark prefix.prefix-end-xxxxxxxx', 64 | 'measure prefix.prefix-xxxxxxxx', 65 | ]); 66 | }); 67 | 68 | it('works in non performance', () => { 69 | function MockPage() { 70 | this.uid = 'xxxxxxxx'; 71 | this.htmlString = ''; 72 | this.settings = { 73 | debug: { 74 | performance: false, 75 | }, 76 | }; 77 | } 78 | const calls = []; 79 | jest.mock('perf_hooks', () => ({ 80 | PerformanceObserver: PerformanceObserverMock, 81 | performance: { 82 | mark: (i) => calls.push(`mark ${i}`), 83 | measure: (i) => calls.push(`measure ${i}`), 84 | clearMarks: (i) => calls.push(`clearMarks ${i}`), 85 | }, 86 | })); 87 | const mockPage = new MockPage(); 88 | // eslint-disable-next-line global-require 89 | const perf = require('../perf').default; 90 | 91 | // mutate 92 | perf(mockPage); 93 | 94 | mockPage.perf.start('test'); 95 | mockPage.perf.end('test'); 96 | mockPage.perf.stop(); 97 | 98 | expect(calls).toEqual([]); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/utils/__tests__/permalinks.spec.ts: -------------------------------------------------------------------------------- 1 | import permalinks from '../permalinks'; 2 | 3 | describe('#permalinks', () => { 4 | const routes = { 5 | home: { 6 | permalink: ({ request, settings }) => 7 | `${!settings.disableInitialSlash ? '/' : ''}${request ? request.query : ''}`, 8 | }, 9 | blog: { 10 | permalink: ({ request, settings }) => 11 | `${!settings.disableInitialSlash ? '/' : ''}blog/${request ? request.query : ''}`, 12 | }, 13 | }; 14 | it('works without prefix', () => { 15 | const settings = {}; 16 | const plinks: any = permalinks({ routes, settings }); 17 | expect(plinks.blog()).toEqual('/blog/'); 18 | expect(plinks.home()).toEqual('/'); 19 | }); 20 | it('works with passing data', () => { 21 | const settings = {}; 22 | const plinks: any = permalinks({ routes, settings }); 23 | expect(plinks.blog({ query: '?a=b' })).toEqual('/blog/?a=b'); 24 | expect(plinks.home({ query: '?c=d' })).toEqual('/?c=d'); 25 | }); 26 | it('works with settings', () => { 27 | const settings = { disableInitialSlash: true }; 28 | const plinks: any = permalinks({ routes, settings }); 29 | expect(plinks.blog()).toEqual('blog/'); 30 | expect(plinks.home({ query: '?c=d' })).toEqual('?c=d'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/utils/__tests__/prepareInlineShortcode.spec.ts: -------------------------------------------------------------------------------- 1 | import prepareInlineShortcode from '../prepareInlineShortcode'; 2 | 3 | describe('#prepareInlineShortcode', () => { 4 | it('works - no content, no props', () => { 5 | const settings = { 6 | shortcodes: { 7 | openPattern: '<12345', 8 | closePattern: '54321>', 9 | }, 10 | }; 11 | const fn = prepareInlineShortcode({ settings }); 12 | // @ts-ignore 13 | expect(() => fn({})).toThrow('helpers.shortcode requires a name prop'); 14 | expect( 15 | fn({ 16 | name: 'Test', 17 | props: {}, 18 | content: '', 19 | }), 20 | ).toEqual('<12345Test/54321>'); 21 | }); 22 | 23 | it('works - with content and props', () => { 24 | const settings = { 25 | shortcodes: { 26 | openPattern: '<', 27 | closePattern: '>', 28 | }, 29 | }; 30 | const fn = prepareInlineShortcode({ settings }); 31 | expect( 32 | fn({ 33 | name: 'Test', 34 | props: { 35 | foo: 'bar', 36 | answer: 42, 37 | nested: { 38 | prop: 'porp', 39 | }, 40 | }, 41 | content: '
Hi, I am content
', 42 | }), 43 | ).toEqual("
Hi, I am content
"); 44 | }); 45 | 46 | it('works - with \\ for escaped regex options', () => { 47 | const settings = { 48 | shortcodes: { 49 | openPattern: '\\[', 50 | closePattern: '\\]', 51 | }, 52 | }; 53 | const fn = prepareInlineShortcode({ settings }); 54 | expect( 55 | fn({ 56 | name: 'Test', 57 | props: { 58 | foo: 'bar', 59 | answer: 42, 60 | nested: { 61 | prop: 'porp', 62 | }, 63 | }, 64 | content: '
Hi, I am content
', 65 | }), 66 | ).toEqual("[Test foo='bar' answer='42' nested='{\"prop\":\"porp\"}']
Hi, I am content
[/Test]"); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/utils/__tests__/prepareProcessStack.spec.ts: -------------------------------------------------------------------------------- 1 | import prepareProcessStack from '../prepareProcessStack'; 2 | 3 | test('#prepareProcessStack', () => { 4 | const page = { 5 | testStack: [ 6 | { 7 | priority: 10, 8 | source: 'prepareProcessStack.spec', // used only for debugging 9 | string: '-10-', 10 | }, 11 | { 12 | priority: 1, 13 | source: 'prepareProcessStack.spec', 14 | string: '-1-', 15 | }, 16 | { 17 | // defaults to prio 50 18 | source: 'prepareProcessStack.spec', 19 | string: '-50-', 20 | }, 21 | ], 22 | perf: { 23 | start: jest.fn(), 24 | end: jest.fn(), 25 | }, 26 | settings: { 27 | debug: { 28 | stacks: true, 29 | }, 30 | }, 31 | }; 32 | const processStackFn = prepareProcessStack(page); 33 | expect(processStackFn('testStack')).toEqual('-50--10--1-'); 34 | }); 35 | -------------------------------------------------------------------------------- /src/utils/__tests__/prepareRunHook.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import prepareRunHook from '../prepareRunHook'; 3 | 4 | const hooks = [ 5 | { 6 | hook: 'bootstrap', 7 | name: 'checkRequiredSettings', 8 | priority: 1, 9 | run: async ({ settings }) => { 10 | await new Promise((resolve) => setTimeout(resolve, 50)); 11 | if (!settings.magicNumber) { 12 | throw new Error(); 13 | } 14 | return {}; 15 | }, 16 | }, 17 | { 18 | hook: 'bootstrap', 19 | name: 'pushError', 20 | priority: 2, 21 | run: async ({ errors }) => { 22 | await new Promise((resolve) => setTimeout(resolve, 10)); 23 | errors.push('something bad happened'); 24 | return { errors }; 25 | }, 26 | }, 27 | { 28 | hook: 'bootstrap', 29 | name: 'attempt to mutate settings', 30 | priority: 3, 31 | run: async ({ settings }) => { 32 | await new Promise((resolve) => setTimeout(resolve, 100)); 33 | settings.injection = 666; 34 | return { settings }; 35 | }, 36 | }, 37 | ]; 38 | 39 | const allSupportedHooks = [ 40 | { 41 | hook: 'bootstrap', 42 | props: ['settings', 'errors'], 43 | mutable: ['errors'], 44 | context: 'Super basic hook', 45 | }, 46 | { 47 | hook: 'bootstrap-custom', 48 | props: ['settings', 'errors', 'customProp'], 49 | mutable: ['errors'], 50 | context: 'Super basic hook', 51 | }, 52 | ]; 53 | 54 | describe('#prepareRunHook', () => { 55 | const settings = { 56 | debug: { 57 | hooks: true, 58 | }, 59 | magicNumber: 42, 60 | }; 61 | const perf = { start: jest.fn(), end: jest.fn(), prefix: () => {} }; 62 | let prepareRunHookFn = prepareRunHook({ hooks: [hooks[0], hooks[1]], allSupportedHooks, settings }); 63 | 64 | it('throws for unknown hook', async () => { 65 | await expect(prepareRunHookFn('unknown')).rejects.toThrow(); 66 | }); 67 | 68 | it('works for bootstrap hook', async () => { 69 | const errors = []; 70 | await expect(await prepareRunHookFn('bootstrap', { settings, errors, perf })).toEqual({ 71 | errors: ['something bad happened'], 72 | }); 73 | expect(errors).toEqual(['something bad happened']); 74 | }); 75 | 76 | it('cannot mutate not mutable prop', async () => { 77 | prepareRunHookFn = prepareRunHook({ hooks, allSupportedHooks, settings }); 78 | const errors = []; 79 | await prepareRunHookFn('bootstrap', { settings, errors, perf }); 80 | expect(errors).toHaveLength(2); 81 | expect(errors[1]).toEqual('something bad happened'); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/utils/__tests__/prepareServer.spec.ts: -------------------------------------------------------------------------------- 1 | import { prepareServer } from '../prepareServer'; 2 | 3 | describe('#prepareServer', () => { 4 | it('works', async () => { 5 | const hooks = []; 6 | const runHook = async (name, props) => { 7 | hooks.push({ name, props }); 8 | }; 9 | const nextMock = jest.fn(); 10 | const prepServer = prepareServer({ 11 | bootstrapComplete: Promise.resolve({ 12 | runHook, 13 | foo: 'bar', 14 | bar: 'foo', 15 | }), 16 | }); 17 | await prepServer( 18 | { 19 | desc: 'req', 20 | }, 21 | { 22 | desc: 'res', 23 | }, 24 | nextMock, 25 | ); 26 | expect(hooks).toEqual([ 27 | { 28 | name: 'middleware', 29 | props: { 30 | runHook, 31 | bar: 'foo', 32 | foo: 'bar', 33 | next: nextMock, 34 | req: { 35 | desc: 'req', 36 | }, 37 | request: { 38 | type: 'server', 39 | }, 40 | res: { 41 | desc: 'res', 42 | }, 43 | }, 44 | }, 45 | ]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/utils/__tests__/prepareShortcodeParser.spec.ts: -------------------------------------------------------------------------------- 1 | import prepareShortcodeParser from '../prepareShortcodeParser'; 2 | 3 | class ShortcodeParser { 4 | opts: any = {}; // just store them so we know what got passed over 5 | 6 | shortcodes: string[] = []; 7 | 8 | constructor(opts) { 9 | this.opts = opts; 10 | } 11 | 12 | add(shortcode: string, fn: (props: any, content: string) => Promise) { 13 | fn({}, 'someContent').then(() => { 14 | this.shortcodes.push(shortcode); 15 | }); 16 | } 17 | } 18 | 19 | jest.mock('@elderjs/shortcodes', () => (opts) => new ShortcodeParser(opts)); 20 | jest.mock('../createReadOnlyProxy'); 21 | 22 | const args = { 23 | perf: { 24 | start: () => '', 25 | end: () => '', 26 | }, 27 | helpers: {}, 28 | data: {}, 29 | request: {}, 30 | query: {}, 31 | allRequests: [], 32 | cssStack: [], 33 | headStack: [], 34 | customJsStack: [], 35 | }; 36 | 37 | describe('#prepareShortcodeParser', () => { 38 | it('works with empty shortcodes', () => { 39 | const shortcodeParser = prepareShortcodeParser({ 40 | ...args, 41 | shortcodes: [], 42 | 43 | settings: { 44 | debug: { 45 | stacks: true, 46 | hooks: true, 47 | build: true, 48 | automagic: true, 49 | shortcodes: true, 50 | }, 51 | shortcodes: { 52 | openPattern: '\\[', 53 | closePattern: '\\]', 54 | }, 55 | }, 56 | }); 57 | expect(shortcodeParser).toBeInstanceOf(ShortcodeParser); 58 | expect(shortcodeParser).toEqual({ 59 | opts: { 60 | openPattern: '\\[', 61 | closePattern: '\\]', 62 | }, 63 | shortcodes: [], 64 | }); 65 | }); 66 | 67 | it('throws errors if you try to add invalid shortcodes', () => { 68 | expect(() => 69 | prepareShortcodeParser({ 70 | ...args, 71 | shortcodes: [ 72 | { 73 | run: jest.fn(), 74 | foo: 'bar', 75 | }, 76 | ], 77 | settings: { 78 | debug: { 79 | stacks: true, 80 | hooks: true, 81 | build: true, 82 | automagic: true, 83 | shortcodes: true, 84 | }, 85 | shortcodes: { 86 | openPattern: '\\<', 87 | closePattern: '\\>', 88 | }, 89 | }, 90 | }), 91 | ).toThrow( 92 | `Shortcodes must have a shortcode property to define their usage. Problem code: ${JSON.stringify({ 93 | run: jest.fn(), 94 | foo: 'bar', 95 | })}`, 96 | ); 97 | expect(() => 98 | prepareShortcodeParser({ 99 | ...args, 100 | shortcodes: [ 101 | { 102 | shortcode: 'svelteComponent', 103 | }, 104 | ], 105 | settings: { 106 | debug: { 107 | stacks: true, 108 | hooks: true, 109 | build: true, 110 | automagic: true, 111 | shortcodes: true, 112 | }, 113 | shortcodes: { 114 | openPattern: '\\<', 115 | closePattern: '\\>', 116 | }, 117 | }, 118 | }), 119 | ).toThrow(`Shortcodes must have a run function. Problem code: ${JSON.stringify({ shortcode: 'svelteComponent' })}`); 120 | }); 121 | 122 | it('works with valid shortcode that returns html but doesnt set anything else', async () => { 123 | const shortcodeParser = prepareShortcodeParser({ 124 | ...args, 125 | shortcodes: [ 126 | { 127 | shortcode: 'sayHi', 128 | run: async () => ({ 129 | html: '
hi
', 130 | }), 131 | }, 132 | ], 133 | settings: { 134 | debug: { 135 | stacks: true, 136 | hooks: true, 137 | build: true, 138 | automagic: true, 139 | shortcodes: true, 140 | }, 141 | shortcodes: { 142 | openPattern: '\\👍', 143 | closePattern: '\\👎', 144 | }, 145 | }, 146 | }); 147 | expect(shortcodeParser).toBeInstanceOf(ShortcodeParser); 148 | // wait for our mock class to run the functions to ensure coverage 149 | // CAUTION: this could turn out into non-deterministic test if the async fn doesn't finish 150 | await new Promise((r) => setTimeout(r, 250)); 151 | expect(shortcodeParser).toEqual({ 152 | opts: { 153 | openPattern: '\\👍', 154 | closePattern: '\\👎', 155 | }, 156 | shortcodes: ['sayHi'], 157 | }); 158 | expect(args.cssStack).toEqual([]); 159 | expect(args.headStack).toEqual([]); 160 | expect(args.customJsStack).toEqual([]); 161 | }); 162 | 163 | it('works with valid shortcode that sets css, head and js', async () => { 164 | const shortcodeParser = prepareShortcodeParser({ 165 | ...args, 166 | shortcodes: [ 167 | { 168 | shortcode: 'svelteComponent', 169 | run: async () => ({ 170 | css: 'body{font-size:1rem;}', 171 | js: 'alert("hello, I am test");', 172 | head: '', 173 | }), 174 | }, 175 | ], 176 | settings: { 177 | debug: { 178 | stacks: true, 179 | hooks: true, 180 | build: true, 181 | automagic: true, 182 | shortcodes: true, 183 | }, 184 | shortcodes: { 185 | openPattern: '\\66', 186 | closePattern: '\\33', 187 | }, 188 | }, 189 | }); 190 | expect(shortcodeParser).toBeInstanceOf(ShortcodeParser); 191 | // wait for our mock class to run the functions to ensure coverage 192 | // CAUTION: this could turn out into non-deterministic test if the async fn doesn't finish 193 | await new Promise((r) => setTimeout(r, 250)); 194 | expect(shortcodeParser).toEqual({ 195 | opts: { 196 | openPattern: '\\66', 197 | closePattern: '\\33', 198 | }, 199 | shortcodes: ['svelteComponent'], 200 | }); 201 | expect(args.cssStack).toEqual([ 202 | { 203 | source: `svelteComponent shortcode`, 204 | string: 'body{font-size:1rem;}', 205 | }, 206 | ]); 207 | expect(args.headStack).toEqual([ 208 | { 209 | source: `svelteComponent shortcode`, 210 | string: '', 211 | }, 212 | ]); 213 | expect(args.customJsStack).toEqual([ 214 | { 215 | source: `svelteComponent shortcode`, 216 | string: 'alert("hello, I am test");', 217 | }, 218 | ]); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /src/utils/__tests__/replaceCircular.spec.ts: -------------------------------------------------------------------------------- 1 | import fixCircularJson from '../fixCircularJson'; 2 | 3 | describe('#fixCircularJson', () => { 4 | it('Handles circular', () => { 5 | const one = { f: 'this-should-work', b: undefined }; 6 | const two = { h: 123, one }; 7 | one.b = two; 8 | 9 | expect(JSON.stringify(fixCircularJson(one))).toBe('{"f":"this-should-work","b":{"h":123,"one":"[Circular]"}}'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/__tests__/shuffleArray.spec.ts: -------------------------------------------------------------------------------- 1 | import shuffleArray from '../shuffleArray'; 2 | 3 | const mockMath = Object.create(global.Math); 4 | 5 | describe('#shuffleArray', () => { 6 | it('works when Math.random returns 0', () => { 7 | mockMath.random = () => 0; 8 | global.Math = mockMath; 9 | expect(shuffleArray([1, 2, 3, 4, 5])).toEqual([2, 3, 4, 5, 1]); 10 | }); 11 | it('works when Math.random returns 0.25', () => { 12 | mockMath.random = () => 0.25; 13 | global.Math = mockMath; 14 | expect(shuffleArray([1, 2, 3])).toEqual([2, 3, 1]); 15 | }); 16 | it('works when Math.random returns 0.5', () => { 17 | mockMath.random = () => 0.5; 18 | global.Math = mockMath; 19 | expect(shuffleArray([1, 2, 3, 4, 5])).toEqual([1, 4, 2, 5, 3]); 20 | expect(shuffleArray([])).toEqual([]); 21 | }); 22 | it('works when Math.random returns 0.9999999', () => { 23 | mockMath.random = () => 0.9999999; 24 | global.Math = mockMath; 25 | expect(shuffleArray([1, 2, 3, 4, 5])).toEqual([1, 2, 3, 4, 5]); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/__tests__/validations.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | validateRoute, 3 | validatePlugin, 4 | validateHook, 5 | getDefaultConfig, 6 | configSchema, 7 | hookSchema, 8 | routeSchema, 9 | pluginSchema, 10 | } from '../validations'; 11 | import normalizeSnapshot from '../normalizeSnapshot'; 12 | 13 | describe('#validations', () => { 14 | const validHook = { 15 | hook: 'customizeHooks', 16 | name: 'test hook', 17 | description: 'just for testing', 18 | run: jest.fn(), 19 | $$meta: { 20 | type: 'hooks.js', 21 | addedBy: 'validations.spec.ts', 22 | }, 23 | }; 24 | 25 | const defaultConfig = { 26 | css: 'file', 27 | build: { 28 | numberOfWorkers: -1, 29 | shuffleRequests: false, 30 | }, 31 | rootDir: 'process.cwd()', 32 | srcDir: 'src', 33 | distDir: 'public', 34 | debug: { 35 | automagic: false, 36 | build: false, 37 | hooks: false, 38 | performance: false, 39 | stacks: false, 40 | shortcodes: false, 41 | props: false, 42 | }, 43 | hooks: { 44 | disable: [], 45 | }, 46 | origin: '', 47 | lang: 'en', 48 | plugins: {}, 49 | prefix: '', 50 | props: { 51 | compress: false, 52 | hydration: 'hybrid', 53 | replacementChars: '$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 54 | }, 55 | server: { 56 | prefix: '', 57 | cacheRequests: true, 58 | dataRoutes: false, 59 | allRequestsRoute: false, 60 | }, 61 | shortcodes: { 62 | closePattern: '}}', 63 | openPattern: '{{', 64 | }, 65 | }; 66 | test('getDefaultConfig', () => { 67 | expect(getDefaultConfig()).toEqual(defaultConfig); 68 | }); 69 | test('validateHook', () => { 70 | expect(validateHook({})).toEqual(false); 71 | expect(validateHook(null)).toEqual(false); 72 | expect(validateHook(validHook)).toEqual({ ...validHook, priority: 50 }); 73 | expect(validateHook({ ...validHook, priority: 10 })).toEqual({ ...validHook, priority: 10 }); 74 | expect(validateHook({ ...validHook, hook: 'invalidHookName' })).toEqual(false); 75 | }); 76 | test('validateRoute', () => { 77 | expect(validateRoute({}, 'invalid')).toEqual(false); 78 | expect(validateRoute(null, 'invalid')).toEqual(false); 79 | expect( 80 | validateRoute( 81 | { 82 | template: 'Home.svelte', 83 | permalink: jest.fn(), 84 | }, 85 | 'invalid', 86 | ), 87 | ).toEqual(false); 88 | const validRoute = { 89 | layout: 'Layout.svelte', 90 | template: 'Home.svelte', 91 | all: jest.fn(), 92 | permalink: jest.fn(), 93 | hooks: [], 94 | data: {}, 95 | name: 'home', 96 | dynamic: false, 97 | }; 98 | expect(validateRoute(validRoute, 'Home')).toEqual(validRoute); 99 | // works with valid hook 100 | expect(validateRoute({ ...validRoute, hooks: [validHook] }, 'Home')).toEqual({ ...validRoute, hooks: [validHook] }); 101 | // but also invalid hook 102 | expect(validateRoute({ ...validRoute, hooks: ['a', 'b', 3] }, 'Home')).toEqual({ 103 | ...validRoute, 104 | hooks: ['a', 'b', 3], // TODO: nested hook validation? 105 | }); 106 | }); 107 | test('validatePlugin', () => { 108 | expect(validatePlugin({})).toEqual(false); 109 | expect(validatePlugin(null)).toEqual(false); 110 | const validPlugin = { 111 | name: 'test plugin', 112 | description: 'just for testing', 113 | init: jest.fn(), // FIXME: init should be required or test should allow not defined 114 | hooks: [1, 2, 3], // TODO: nested hook validation? 115 | shortcodes: [], 116 | }; 117 | expect(validatePlugin(validPlugin)).toEqual({ ...validPlugin, config: {}, routes: {} }); 118 | expect(validatePlugin({ ...validPlugin, config: defaultConfig })).toEqual({ 119 | ...validPlugin, 120 | config: defaultConfig, 121 | routes: {}, 122 | }); 123 | }); 124 | test('configSchema', () => { 125 | expect(normalizeSnapshot(configSchema)).toMatchSnapshot(); 126 | }); 127 | test('hookSchema', () => { 128 | expect(normalizeSnapshot(hookSchema)).toMatchSnapshot(); 129 | }); 130 | test('routeSchema', () => { 131 | expect(normalizeSnapshot(routeSchema)).toMatchSnapshot(); 132 | }); 133 | test('pluginSchema', () => { 134 | expect(normalizeSnapshot(pluginSchema)).toMatchSnapshot(); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/utils/__tests__/windowsPathFix.spec.ts: -------------------------------------------------------------------------------- 1 | import windowsPathFix from '../windowsPathFix'; 2 | 3 | describe('#windowsPathFix', () => { 4 | it('windows path fix works', () => { 5 | expect(windowsPathFix('\\___ELDER___\\compiled\\layouts\\Layout.js')).toBe( 6 | '/___ELDER___/compiled/layouts/Layout.js', 7 | ); 8 | }); 9 | it("windows path doesn't break linux", () => { 10 | expect(windowsPathFix('/___ELDER___/compiled/layouts/Layout.js')).toBe('/___ELDER___/compiled/layouts/Layout.js'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/utils/__tests__/wrapPermalinkFn.spec.ts: -------------------------------------------------------------------------------- 1 | import wrapPermalinkFn from '../wrapPermalinkFn'; 2 | 3 | const payload = { request: { slug: 'test' } }; 4 | const settings = { 5 | debug: { 6 | automagic: false, 7 | }, 8 | }; 9 | 10 | describe('#wrapPermalinkFn', () => { 11 | const warn = jest.fn(); 12 | console.warn = warn; 13 | 14 | it('works on valid permalinks', () => { 15 | const permalinkFn = ({ request }) => `/${request.slug}/`; 16 | const permalink = wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload); 17 | expect(permalink).toEqual('/test/'); 18 | }); 19 | 20 | it('works on homepage permalinks /', () => { 21 | const permalinkFn = () => '/'; 22 | const permalink = wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload); 23 | expect(permalink).toEqual('/'); 24 | }); 25 | 26 | it('adds a beginning slash', () => { 27 | const permalinkFn = ({ request }) => `${request.slug}/`; 28 | const permalink = wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload); 29 | expect(permalink).toEqual('/test/'); 30 | }); 31 | 32 | it('adds a trailing slash', () => { 33 | const permalinkFn = ({ request }) => `/${request.slug}`; 34 | const permalink = wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload); 35 | expect(permalink).toEqual('/test/'); 36 | }); 37 | 38 | it('adds both trailing and beginning', () => { 39 | const permalinkFn = ({ request }) => request.slug; 40 | const permalink = wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload); 41 | expect(permalink).toEqual('/test/'); 42 | }); 43 | 44 | it("throws when permalink fn doesn't return a string.", () => { 45 | const permalinkFn = ({ request }) => request; 46 | expect(() => wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload)).toThrow(); 47 | }); 48 | 49 | it('throws when permalink returns an undefined', () => { 50 | const permalinkFn = () => `//`; 51 | expect(() => wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload)).toThrow(); 52 | }); 53 | 54 | it('warn when permalink returns an undefined due to missing prop', () => { 55 | const permalinkFn = ({ request }) => `/${request.nope}/`; 56 | wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload); 57 | expect(warn).toHaveBeenCalledTimes(1); 58 | }); 59 | 60 | it('warn when permalink returns an null due to missing prop', () => { 61 | const permalinkFn = () => '/null/'; 62 | wrapPermalinkFn({ permalinkFn, routeName: 'test', settings })(payload); 63 | expect(warn).toHaveBeenCalledTimes(2); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/utils/asyncForEach.ts: -------------------------------------------------------------------------------- 1 | // helper function that makes sure the array is indeed processed async 2 | async function asyncForEach(array, callback) { 3 | let index = 0; 4 | const ar = array.length; 5 | for (; index < ar; index += 1) { 6 | // eslint-disable-next-line no-await-in-loop 7 | await callback(array[index], index, array); 8 | } 9 | } 10 | 11 | export default asyncForEach; 12 | -------------------------------------------------------------------------------- /src/utils/capitalizeFirstLetter.ts: -------------------------------------------------------------------------------- 1 | function capitalizeFirstLetter(s) { 2 | return `${s.charAt(0).toUpperCase()}${s.slice(1)}`; 3 | } 4 | 5 | export default capitalizeFirstLetter; 6 | -------------------------------------------------------------------------------- /src/utils/createReadOnlyProxy.ts: -------------------------------------------------------------------------------- 1 | function createReadOnlyProxy(obj: object, objName: string, location: string) { 2 | // proxies only work on objects/arrays. 3 | try { 4 | if (typeof obj !== 'object' && !Array.isArray(obj)) return obj; 5 | return new Proxy(obj, { 6 | set() { 7 | console.log( 8 | `Object ${objName} is not mutable from ${location}. Check the error below for the hook/plugin that is attempting to mutate properties outside of the rules in hookInterface.ts or in other restricted areas.`, 9 | ); 10 | return false; 11 | }, 12 | }); 13 | } catch (e) { 14 | return obj; 15 | } 16 | } 17 | 18 | export default createReadOnlyProxy; 19 | -------------------------------------------------------------------------------- /src/utils/fixCircularJson.ts: -------------------------------------------------------------------------------- 1 | function fixCircularJson(val, cache = undefined) { 2 | cache = cache || new WeakSet(); 3 | 4 | if (val && typeof val === 'object') { 5 | if (cache.has(val)) return '[Circular]'; 6 | 7 | cache.add(val); 8 | 9 | const obj = Array.isArray(val) ? [] : {}; 10 | for (const idx of Object.keys(val)) { 11 | obj[idx] = fixCircularJson(val[idx], cache); 12 | } 13 | 14 | cache.delete(val); 15 | return obj; 16 | } 17 | 18 | return val; 19 | } 20 | export default fixCircularJson; 21 | -------------------------------------------------------------------------------- /src/utils/getConfig.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-dynamic-require */ 2 | import { cosmiconfigSync } from 'cosmiconfig'; 3 | import defaultsDeep from 'lodash.defaultsdeep'; 4 | import path from 'path'; 5 | import fs from 'fs-extra'; 6 | import get from 'lodash.get'; 7 | import { SettingsOptions, InitializationOptions } from './types'; 8 | import { getDefaultConfig } from './validations'; 9 | import prepareFindSvelteComponent from '../partialHydration/prepareFindSvelteComponent'; 10 | import normalizePrefix from './normalizePrefix'; 11 | 12 | function getConfig(initializationOptions: InitializationOptions = {}): SettingsOptions { 13 | let loadedConfig: InitializationOptions = {}; 14 | const explorerSync = cosmiconfigSync('elder'); 15 | const explorerSearch = explorerSync.search(); 16 | if (explorerSearch && explorerSearch.config) { 17 | loadedConfig = explorerSearch.config; 18 | } 19 | const config: SettingsOptions = defaultsDeep(initializationOptions, loadedConfig, getDefaultConfig()); 20 | 21 | const serverPrefix = normalizePrefix(config.prefix || get(config, 'server.prefix', '')); 22 | 23 | const rootDir = config.rootDir === 'process.cwd()' ? process.cwd() : path.resolve(config.rootDir); 24 | config.rootDir = rootDir; 25 | config.srcDir = path.resolve(rootDir, `./${config.srcDir}`); 26 | config.distDir = path.resolve(rootDir, `./${config.distDir}`); 27 | 28 | // eslint-disable-next-line global-require 29 | const pkgJson = require(path.resolve(__dirname, '../../package.json')); 30 | config.version = pkgJson.version.includes('-') ? pkgJson.version.split('-')[0] : pkgJson.version; 31 | 32 | config.context = typeof initializationOptions.context !== 'undefined' ? initializationOptions.context : 'unknown'; 33 | 34 | config.server = initializationOptions.context === 'server' && config.server; 35 | config.build = initializationOptions.context === 'build' && config.build; 36 | config.worker = !!initializationOptions.worker; 37 | config.prefix = serverPrefix; 38 | if (serverPrefix && config.server) { 39 | config.server.prefix = serverPrefix; 40 | } 41 | 42 | const ssrComponents = path.resolve(config.rootDir, './___ELDER___/compiled/'); 43 | const clientComponents = path.resolve(config.distDir, `.${serverPrefix}/_elderjs/svelte/`); 44 | const distElder = path.resolve(config.distDir, `.${serverPrefix}/_elderjs/`); 45 | fs.ensureDirSync(path.resolve(distElder)); 46 | fs.ensureDirSync(path.resolve(clientComponents)); 47 | 48 | config.$$internal = { 49 | ssrComponents, 50 | clientComponents, 51 | distElder, 52 | logPrefix: `[Elder.js]:`, 53 | serverPrefix, 54 | findComponent: prepareFindSvelteComponent({ 55 | ssrFolder: ssrComponents, 56 | rootDir, 57 | clientComponents, 58 | distDir: config.distDir, 59 | srcDir: config.srcDir, 60 | }), 61 | }; 62 | 63 | if (config.css === 'file' || config.css === 'lazy') { 64 | const assetPath = path.resolve(distElder, `.${path.sep}assets`); 65 | fs.ensureDirSync(path.resolve(assetPath)); 66 | const cssFiles = fs.readdirSync(assetPath).filter((f) => f.endsWith('.css')); 67 | if (cssFiles.length > 1) { 68 | throw new Error( 69 | `${config.$$internal.logPrefix} Race condition has caused multiple css files in ${assetPath}. If you keep seeing this delete the _elder and ___ELDER___ folders.`, 70 | ); 71 | } 72 | if (cssFiles[0]) { 73 | config.$$internal.publicCssFile = `${serverPrefix}/_elderjs/assets/${cssFiles[0]}`; 74 | } else { 75 | console.error(`CSS file not found in ${assetPath}`); 76 | } 77 | } 78 | 79 | if (config.origin === '' || config.origin === 'https://example.com') { 80 | console.error( 81 | `WARN: Remember to put a valid "origin" in your elder.config.js. The URL of your site's root, without a trailing slash. This is frequently used by plugins and leaving it blank can cause SEO headaches.`, 82 | ); 83 | } 84 | 85 | return config; 86 | } 87 | 88 | export default getConfig; 89 | -------------------------------------------------------------------------------- /src/utils/getPluginLocations.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import glob from 'glob'; 3 | import path from 'path'; 4 | import fs from 'fs-extra'; 5 | import { SettingsOptions } from '..'; 6 | 7 | export default function getPluginLocations(elderConfig: SettingsOptions) { 8 | const pluginNames = Object.keys(elderConfig.plugins); 9 | 10 | return pluginNames.reduce( 11 | (out, pluginName) => { 12 | const pluginPath = path.resolve(elderConfig.srcDir, `./plugins/${pluginName}`); 13 | const nmPluginPath = path.resolve(elderConfig.rootDir, `./node_modules/${pluginName}`); 14 | 15 | if (fs.existsSync(`${pluginPath}/index.js`)) { 16 | const svelteFiles = glob.sync(`${pluginPath}/*.svelte`); 17 | if (svelteFiles.length > 0) { 18 | out.paths.push(`${pluginPath}/`); 19 | out.files = out.files.concat(svelteFiles); 20 | } 21 | } else if (fs.existsSync(`${nmPluginPath}/package.json`)) { 22 | const svelteFiles = glob.sync(`${nmPluginPath}/*.svelte`); 23 | if (svelteFiles.length > 0) { 24 | out.paths.push(`${nmPluginPath}/`); 25 | out.files = out.files.concat(svelteFiles); 26 | } 27 | } 28 | return out; 29 | }, 30 | { paths: [], files: [] }, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/getUniqueId.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid/non-secure'; 2 | 3 | const nanoid = customAlphabet('bcdfgjklmnpqrstvwxyzVCDFGJKLMNQRSTVWXYZ', 10); 4 | 5 | // generate a 10 digit unique ID 6 | const getUniqueId = (): string => { 7 | return nanoid(); 8 | }; 9 | 10 | export default getUniqueId; 11 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import asyncForEach from './asyncForEach'; 2 | import capitalizeFirstLetter from './capitalizeFirstLetter'; 3 | import getUniqueId from './getUniqueId'; 4 | import Page from './Page'; 5 | import parseBuildPerf from '../build/parseBuildPerf'; 6 | import perf from './perf'; 7 | import permalinks from './permalinks'; 8 | 9 | import svelteComponent from './svelteComponent'; 10 | import prepareRunHook from './prepareRunHook'; 11 | import shuffleArray from './shuffleArray'; 12 | import { prepareServer } from './prepareServer'; 13 | 14 | import { validateHook, validateRoute, validatePlugin, validateShortcode } from './validations'; 15 | import prepareProcessStack from './prepareProcessStack'; 16 | import getConfig from './getConfig'; 17 | import getRollupConfig from '../rollup/getRollupConfig'; 18 | import prepareInlineShortcode from './prepareInlineShortcode'; 19 | 20 | export { 21 | asyncForEach, 22 | capitalizeFirstLetter, 23 | svelteComponent, 24 | getUniqueId, 25 | validateShortcode, 26 | Page, 27 | parseBuildPerf, 28 | perf, 29 | permalinks, 30 | prepareRunHook, 31 | validateHook, 32 | validateRoute, 33 | validatePlugin, 34 | shuffleArray, 35 | prepareServer, 36 | prepareProcessStack, 37 | getConfig, 38 | getRollupConfig, 39 | prepareInlineShortcode, 40 | }; 41 | -------------------------------------------------------------------------------- /src/utils/normalizePrefix.ts: -------------------------------------------------------------------------------- 1 | const normalizePrefix = (prefix: string) => { 2 | if (!prefix) return ''; 3 | 4 | // remove trailing slash 5 | const trimmed = prefix.replace(/\/+$/, ''); 6 | 7 | // add leading slash 8 | return trimmed[0] === '/' ? trimmed : `/${trimmed}`; 9 | }; 10 | 11 | export default normalizePrefix; 12 | -------------------------------------------------------------------------------- /src/utils/normalizeSnapshot.ts: -------------------------------------------------------------------------------- 1 | import windowsPathFix from './windowsPathFix'; 2 | 3 | const normalizeSnapshot = (val) => { 4 | if (Object.prototype.toString.call(val) === '[object String]') { 5 | return windowsPathFix(val); 6 | } 7 | if (Object.prototype.toString.call(val) === '[object Array]') { 8 | return val.map(normalizeSnapshot); 9 | } 10 | if (Object.prototype.toString.call(val) === '[object Object]') { 11 | return Object.keys(val).reduce((out, cv) => { 12 | // eslint-disable-next-line no-param-reassign 13 | out[normalizeSnapshot(cv)] = normalizeSnapshot(val[cv]); 14 | return out; 15 | }, {}); 16 | } 17 | if (Object.prototype.toString.call(val) === '[object Set]') { 18 | const arr = [...val.values()].map(normalizeSnapshot); 19 | return new Set(arr); 20 | } 21 | 22 | if (Object.prototype.toString.call(val) === '[object Map]') { 23 | const map = new Map(); 24 | for (const [k, v] of val.entries()) { 25 | map.set(normalizeSnapshot(k), normalizeSnapshot(v)); 26 | } 27 | return map; 28 | } 29 | return val; 30 | }; 31 | 32 | export default normalizeSnapshot; 33 | -------------------------------------------------------------------------------- /src/utils/notProduction.ts: -------------------------------------------------------------------------------- 1 | const notProduction = () => String(process.env.NODE_ENV).toLowerCase() !== 'production'; 2 | export default notProduction; 3 | -------------------------------------------------------------------------------- /src/utils/outputStyles.ts: -------------------------------------------------------------------------------- 1 | import Page from './Page'; 2 | import notProduction from './notProduction'; 3 | 4 | export default function outputStyles(page: Page): string { 5 | let svelteCssStrings = ''; 6 | if (notProduction() && page.request.type !== 'build') { 7 | svelteCssStrings = page.svelteCss.reduce((out, cv) => `${out}`, ''); 8 | return `${svelteCssStrings}`; 9 | } 10 | svelteCssStrings = page.svelteCss.reduce((out, cv) => `${out}${cv.css}`, ''); 11 | return ``; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/perf.ts: -------------------------------------------------------------------------------- 1 | import { performance, PerformanceObserver } from 'perf_hooks'; 2 | import { Elder } from '..'; 3 | import Page from './Page'; 4 | /** 5 | * A little helper around perf_hooks. 6 | * Returns an object with a start and end function. 7 | * 8 | * This allows you to pass in a page.perf.start('name') and then page.perf.end('name') and the result is stored in a timings array. 9 | * 10 | */ 11 | 12 | export type TPerfTiming = { name: string; duration: number }; 13 | 14 | export type TPerfTimings = TPerfTiming[]; 15 | 16 | const perf = (page: Page | Elder, force = false) => { 17 | if (page.settings.debug.performance || force) { 18 | let obs = new PerformanceObserver((items) => { 19 | items.getEntries().forEach((entry) => { 20 | if (entry.name.includes(page.uid)) { 21 | page.perf.timings.push({ name: entry.name.replace(`-${page.uid}`, ''), duration: entry.duration }); 22 | performance.clearMarks(entry.name); 23 | } 24 | }); 25 | }); 26 | 27 | // eslint-disable-next-line no-param-reassign 28 | page.perf = { 29 | timings: [], 30 | /** 31 | * Marks the performance timeline with {label}-start. 32 | * 33 | * @param {String} label 34 | */ 35 | start: (label: string) => { 36 | performance.mark(`${label}-start-${page.uid}`); 37 | }, 38 | /** 39 | * Marks the performance timeline with {label}-stop 40 | * It then records triggers a recording of the measurement. 41 | * @param {*} label 42 | */ 43 | end: (label) => { 44 | performance.mark(`${label}-end-${page.uid}`); 45 | performance.measure(`${label}-${page.uid}`, `${label}-start-${page.uid}`, `${label}-end-${page.uid}`); 46 | }, 47 | stop: () => { 48 | if (obs) obs.disconnect(); 49 | obs = null; 50 | }, 51 | }; 52 | 53 | obs.observe({ entryTypes: ['measure'] }); 54 | } else { 55 | const placeholder = () => {}; 56 | // eslint-disable-next-line no-param-reassign 57 | page.perf = { 58 | timings: [], 59 | start: placeholder, 60 | end: placeholder, 61 | stop: placeholder, 62 | }; 63 | } 64 | 65 | // eslint-disable-next-line no-param-reassign 66 | page.perf.prefix = (pre) => { 67 | return { start: (name) => page.perf.start(`${pre}.${name}`), end: (name) => page.perf.end(`${pre}.${name}`) }; 68 | }; 69 | }; 70 | 71 | export default perf; 72 | 73 | export const displayPerfTimings = (timings: TPerfTimings) => { 74 | const display = timings.sort((a, b) => a.duration - b.duration).map((t) => ({ ...t, ms: t.duration })); 75 | console.table(display, ['name', 'ms']); 76 | }; 77 | -------------------------------------------------------------------------------- /src/utils/permalinks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function to allow permalinks to be referenced by obj.routeName. 3 | * It also handles adding of the /dev prefix when settings.server is true. 4 | * 5 | * @param {Object} { routes, settings = {} } 6 | * @returns {Object} This object allows for referencing permalinks as obj.routeName() 7 | */ 8 | const permalinks = ({ routes, settings }) => 9 | Object.keys(routes).reduce((out, cv) => { 10 | // eslint-disable-next-line no-param-reassign 11 | out[cv] = (data) => routes[cv].permalink({ request: data, settings }); 12 | return out; 13 | }, {}); 14 | 15 | export default permalinks; 16 | -------------------------------------------------------------------------------- /src/utils/prepareInlineShortcode.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | const prepareInlineShortcode = 3 | ({ settings }) => 4 | ({ name, props = {}, content = '' }) => { 5 | const { openPattern, closePattern } = settings.shortcodes; 6 | const openNoEscape = openPattern.replace('\\', ''); 7 | const closeNoEscape = closePattern.replace('\\', ''); 8 | 9 | if (!name) throw new Error(`helpers.shortcode requires a name prop`); 10 | let shortcode = `${openNoEscape}${name}`; 11 | 12 | shortcode += Object.entries(props).reduce((out, [key, val]) => { 13 | if (typeof val === 'object' || Array.isArray(val)) { 14 | out += ` ${key}='${JSON.stringify(val)}'`; 15 | } else { 16 | out += ` ${key}='${val}'`; 17 | } 18 | 19 | return out; 20 | }, ''); 21 | 22 | if (!content) { 23 | // self closing 24 | shortcode += `/${closeNoEscape}`; 25 | } else { 26 | // close the open shortcode. 27 | shortcode += closeNoEscape; 28 | 29 | // add content 30 | shortcode += content; 31 | 32 | // close the shortcode. 33 | shortcode += `${openNoEscape}/${name}${closeNoEscape}`; 34 | } 35 | 36 | return shortcode; 37 | }; 38 | 39 | export default prepareInlineShortcode; 40 | -------------------------------------------------------------------------------- /src/utils/prepareProcessStack.ts: -------------------------------------------------------------------------------- 1 | function prepareProcessStack(page) { 2 | return function processStack(name) { 3 | page.perf.start(`stack.${name}`); 4 | 5 | // used to check if we've already add this string to the stack. 6 | const seen = new Set(); 7 | 8 | const str = page[name] 9 | .map((s) => ({ ...s, priority: s.priority || 50 })) 10 | .sort((a, b) => b.priority - a.priority) 11 | .reduce((out, cv, i, arr) => { 12 | if (page.settings.debug && page.settings.debug.stacks) { 13 | console.log(`stack.${name}`, arr, i); 14 | console.log(`Adding to ${name} from ${cv.source}`); 15 | console.log(cv); 16 | } 17 | if (cv.string && cv.string.length > 0 && !seen.has(cv.string)) { 18 | // eslint-disable-next-line no-param-reassign 19 | out = `${out}${cv.string}`; 20 | seen.add(cv.string); 21 | } 22 | 23 | return out; 24 | }, ''); 25 | 26 | page.perf.end(`stack.${name}`); 27 | return str; 28 | }; 29 | } 30 | 31 | export default prepareProcessStack; 32 | -------------------------------------------------------------------------------- /src/utils/prepareRunHook.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import createReadOnlyProxy from './createReadOnlyProxy'; 3 | 4 | // TODO: How do we get types to the user when they are writing plugins, etc? 5 | function prepareRunHook({ hooks, allSupportedHooks, settings }) { 6 | // eslint-disable-next-line consistent-return 7 | return async function processHook(hookName, props: any = {}) { 8 | if (props.perf) props.perf.start(`hook.${hookName}`); 9 | 10 | // do we have a contract for the hook 11 | const hookDefinition = allSupportedHooks.find((h) => h.hook === hookName); 12 | if (!hookDefinition) { 13 | throw new Error(`Hook ${hookName} not defined in hookInterface or via plugins.`); 14 | } 15 | 16 | const hookProps = hookDefinition.props.reduce((out, cv) => { 17 | if (cv === 'perf') return out; // perf added and prefixed below 18 | 19 | if (Object.hasOwnProperty.call(props, cv)) { 20 | if (!hookDefinition.mutable.includes(cv)) { 21 | out[cv] = createReadOnlyProxy(props[cv], cv, hookName); 22 | } else { 23 | out[cv] = props[cv]; 24 | } 25 | } else { 26 | console.error( 27 | `Hook named '${hookName}' cannot be run because prop ${cv} is not in scope to pass to the hook. Hook contract broken.`, 28 | ); 29 | } 30 | 31 | return out; 32 | }, {}); 33 | 34 | const theseHooks = hooks.filter((h) => h.hook === hookName); 35 | if (theseHooks && Array.isArray(theseHooks) && theseHooks.length > 0) { 36 | // higher priority is more important. 37 | const hookList = theseHooks.sort((a, b) => b.priority - a.priority); 38 | 39 | if (settings && settings.debug && settings.debug.hooks) { 40 | console.log(`Hooks registered on ${hookName}:`, hookList); 41 | } 42 | 43 | const hookOutput = {}; 44 | 45 | // loop through the hooks, updating the output and the props in order 46 | await hookList.reduce((p, hook) => { 47 | return p.then(async () => { 48 | if (props.perf) props.perf.start(`hook.${hookName}.${hook.name}`); 49 | try { 50 | let hookResponse = await hook.run({ 51 | ...hookProps, 52 | perf: props.perf.prefix(`hook.${hookName}.${hook.name}`), 53 | }); 54 | 55 | if (!hookResponse) hookResponse = {}; 56 | 57 | if (settings && settings.debug && settings.debug.hooks) { 58 | console.log(`${hook.name} ran on ${hookName} and returned`, hookResponse); 59 | } 60 | 61 | Object.keys(hookResponse).forEach((key) => { 62 | if (hookDefinition.mutable && hookDefinition.mutable.includes(key)) { 63 | hookOutput[key] = hookResponse[key]; 64 | hookProps[key] = hookResponse[key]; 65 | } else { 66 | console.error( 67 | `Received attempted mutation on "${hookName}" from "${hook.name}" on the object "${key}". ${key} is not mutable on this hook `, 68 | hook.$$meta, 69 | ); 70 | } 71 | }); 72 | } catch (e) { 73 | console.error(e); 74 | e.message = `Hook: "${hook.name}" threw an error: ${e.message}`; 75 | props.errors.push(e); 76 | if (hookName === 'buildComplete') console.error(e); 77 | } 78 | if (props.perf) props.perf.end(`hook.${hookName}.${hook.name}`); 79 | }); 80 | }, Promise.resolve()); 81 | 82 | // this actually mutates the props. 83 | if ( 84 | Object.keys(hookOutput).length > 0 && 85 | Array.isArray(hookDefinition.mutable) && 86 | hookDefinition.mutable.length > 0 87 | ) { 88 | hookDefinition.mutable.forEach((key) => { 89 | if ({}.hasOwnProperty.call(hookOutput, key)) { 90 | props[key] = hookOutput[key]; 91 | } 92 | }); 93 | } 94 | 95 | if (settings && settings.debug && settings.debug.hooks) console.log(`${hookName} finished`); 96 | 97 | if (props.perf) props.perf.end(`hook.${hookName}`); 98 | return hookOutput; 99 | } 100 | if (settings && settings.debug && settings.debug.hooks) { 101 | console.log(`${hookName} finished without executing any functions`); 102 | } 103 | 104 | if (props.perf) props.perf.end(`hook.${hookName}`); 105 | return props; 106 | }; 107 | } 108 | 109 | export default prepareRunHook; 110 | -------------------------------------------------------------------------------- /src/utils/prepareServer.ts: -------------------------------------------------------------------------------- 1 | function prepareServer({ bootstrapComplete }) { 2 | // eslint-disable-next-line consistent-return 3 | return async function prepServer(req, res, next) { 4 | try { 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | const { runHook, ...bootstrap } = await bootstrapComplete; 7 | await runHook('middleware', { 8 | ...bootstrap, 9 | runHook, 10 | req, 11 | next, 12 | res, 13 | request: { type: 'server' }, 14 | }); 15 | } catch (e) { 16 | console.error(e); 17 | } 18 | }; 19 | } 20 | 21 | // eslint-disable-next-line import/prefer-default-export 22 | export { prepareServer }; 23 | -------------------------------------------------------------------------------- /src/utils/prepareShortcodeParser.ts: -------------------------------------------------------------------------------- 1 | import ShortcodeParser from '@elderjs/shortcodes'; 2 | import createReadOnlyProxy from './createReadOnlyProxy'; 3 | // TODO: Needs TS magic. 4 | 5 | function prepareShortcodeParser({ 6 | shortcodes, 7 | helpers, 8 | data, 9 | settings, 10 | request, 11 | query, 12 | allRequests, 13 | cssStack, 14 | headStack, 15 | customJsStack, 16 | perf, 17 | }) { 18 | const { openPattern, closePattern } = settings.shortcodes; 19 | const shortcodeParser = ShortcodeParser({ openPattern, closePattern }); 20 | 21 | shortcodes.forEach((shortcode) => { 22 | if (typeof shortcode.run !== 'function') 23 | throw new Error(`Shortcodes must have a run function. Problem code: ${JSON.stringify(shortcode)}`); 24 | if (typeof shortcode.shortcode !== 'string') 25 | throw new Error( 26 | `Shortcodes must have a shortcode property to define their usage. Problem code: ${JSON.stringify(shortcode)}`, 27 | ); 28 | 29 | shortcodeParser.add(shortcode.shortcode, async (props, content) => { 30 | perf.start(shortcode.shortcode); 31 | 32 | const shortcodeResponse = await shortcode.run({ 33 | perf, 34 | props, 35 | content, 36 | plugin: shortcode.plugin, 37 | data: createReadOnlyProxy( 38 | data, 39 | 'data', 40 | `${shortcode.shortcode} defined by ${JSON.stringify(shortcode.$$meta)}`, 41 | ), 42 | request: createReadOnlyProxy( 43 | request, 44 | 'request', 45 | `${shortcode.shortcode} defined by ${JSON.stringify(shortcode.$$meta)}`, 46 | ), 47 | query: createReadOnlyProxy( 48 | query, 49 | 'query', 50 | `${shortcode.shortcode} defined by ${JSON.stringify(shortcode.$$meta)}`, 51 | ), 52 | helpers: createReadOnlyProxy( 53 | helpers, 54 | 'helpers', 55 | `${shortcode.shortcode} defined by ${JSON.stringify(shortcode.$$meta)}`, 56 | ), 57 | settings: createReadOnlyProxy( 58 | settings, 59 | 'settings', 60 | `${shortcode.shortcode} defined by ${JSON.stringify(shortcode.$$meta)}`, 61 | ), 62 | allRequests: createReadOnlyProxy( 63 | allRequests, 64 | 'allRequests', 65 | `${shortcode.shortcode} defined by ${JSON.stringify(shortcode.$$meta)}`, 66 | ), 67 | }); 68 | 69 | if (settings.debug.shortcodes) { 70 | console.log(`${shortcode.shortcode} returned`, shortcodeResponse); 71 | } 72 | 73 | if (typeof shortcodeResponse === 'object') { 74 | const { html, css, js, head } = shortcodeResponse; 75 | if (css) { 76 | cssStack.push({ 77 | source: `${shortcode.shortcode} shortcode`, 78 | string: css, 79 | }); 80 | } 81 | if (js) { 82 | customJsStack.push({ 83 | source: `${shortcode.shortcode} shortcode`, 84 | string: js, 85 | }); 86 | } 87 | if (head) { 88 | headStack.push({ 89 | source: `${shortcode.shortcode} shortcode`, 90 | string: head, 91 | }); 92 | } 93 | perf.end(shortcode.shortcode); 94 | return html || ''; 95 | } 96 | 97 | perf.end(shortcode.shortcode); 98 | return shortcodeResponse || ''; 99 | }); 100 | }); 101 | 102 | return shortcodeParser; 103 | } 104 | 105 | export default prepareShortcodeParser; 106 | -------------------------------------------------------------------------------- /src/utils/shuffleArray.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | function shuffleArray(a) { 3 | for (let i = a.length - 1; i > 0; i -= 1) { 4 | const j = Math.floor(Math.random() * (i + 1)); 5 | [a[i], a[j]] = [a[j], a[i]]; 6 | } 7 | return a; 8 | } 9 | 10 | export default shuffleArray; 11 | -------------------------------------------------------------------------------- /src/utils/svelteComponent.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | import { ComponentPayload } from './types'; 3 | import mountComponentsInHtml from '../partialHydration/mountComponentsInHtml'; 4 | import getUniqueId from './getUniqueId'; 5 | 6 | export const getComponentName = (str) => { 7 | let out = str.replace('.svelte', '').replace('.js', ''); 8 | if (out.includes('/')) { 9 | out = out.split('/').pop(); 10 | } 11 | return out; 12 | }; 13 | 14 | const svelteComponent = 15 | (componentName: String, folder: String = 'components') => 16 | ({ page, props, hydrateOptions }: ComponentPayload): string => { 17 | const { ssr, client } = page.settings.$$internal.findComponent(componentName, folder); 18 | 19 | const cleanComponentName = getComponentName(componentName); 20 | 21 | // eslint-disable-next-line import/no-dynamic-require 22 | const ssrReq = require(ssr); 23 | 24 | const { render, _css: css, _cssMap: cssMap } = ssrReq.default || ssrReq; 25 | 26 | try { 27 | const { html: htmlOutput, head } = render(props); 28 | 29 | if (page.settings.css === 'inline') { 30 | if (css && css.length > 0 && page.svelteCss && !hydrateOptions) { 31 | page.svelteCss.push({ css, cssMap }); 32 | } 33 | } 34 | 35 | if (head && page.headStack) { 36 | page.headStack.push({ source: cleanComponentName, priority: 50, string: head }); 37 | } 38 | 39 | const innerHtml = mountComponentsInHtml({ 40 | html: htmlOutput, 41 | page, 42 | hydrateOptions, 43 | }); 44 | 45 | // hydrateOptions.loading=none for server only rendered injected into html 46 | if (!hydrateOptions || hydrateOptions.loading === 'none') { 47 | // if a component isn't hydrated we don't need to wrap it in a unique div. 48 | return innerHtml; 49 | } 50 | 51 | const id = getUniqueId(); 52 | const lowerCaseComponent = componentName.toLowerCase(); 53 | const uniqueComponentName = `${lowerCaseComponent}-ejs-${id}`; 54 | 55 | page.componentsToHydrate.push({ 56 | name: uniqueComponentName, 57 | hydrateOptions, 58 | client, 59 | props: Object.keys(props).length > 0 ? props : false, 60 | prepared: {}, 61 | id, 62 | }); 63 | 64 | return `<${ 65 | hydrateOptions.element 66 | } class="${cleanComponentName.toLowerCase()}-component" id="${uniqueComponentName}">${innerHtml}`; 69 | } catch (e) { 70 | // console.log(e); 71 | page.errors.push(e); 72 | } 73 | return ''; 74 | }; 75 | 76 | export default svelteComponent; 77 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import type { RoutesOptions } from '../routes/types'; 2 | import type { HookOptions } from '../hooks/types'; 3 | import type { ShortcodeDefs } from '../shortcodes/types'; 4 | import Page from './Page'; 5 | 6 | export type ServerOptions = { 7 | prefix: string; 8 | cacheRequests?: boolean; 9 | dataRoutes?: boolean | string; 10 | allRequestsRoute?: boolean | string; 11 | }; 12 | 13 | type BuildOptions = { 14 | numberOfWorkers: number; 15 | shuffleRequests: boolean; 16 | }; 17 | 18 | // type SvelteOptions = { 19 | // ssrComponents: string; 20 | // clientComponents: string; 21 | // }; 22 | 23 | export interface SvelteComponentFiles { 24 | ssr: string | undefined; 25 | client: string | undefined; 26 | iife: string | undefined; 27 | } 28 | 29 | export interface FindSvelteComponent { 30 | (name: any, folder: any): SvelteComponentFiles; 31 | } 32 | 33 | type Internal = { 34 | hashedComponents?: {}; 35 | ssrComponents: string; 36 | clientComponents: string; 37 | distElder: string; 38 | logPrefix: string; 39 | serverPrefix: string; 40 | findComponent: FindSvelteComponent; 41 | publicCssFile?: string; 42 | }; 43 | 44 | type DebugOptions = { 45 | stacks: boolean; 46 | hooks: boolean; 47 | performance: boolean; 48 | build: boolean; 49 | automagic: boolean; 50 | shortcodes: boolean; 51 | props: boolean; 52 | }; 53 | 54 | type PropOptions = { 55 | compress: boolean; 56 | replacementChars: string; 57 | hydration: 'html' | 'hybrid' | 'file'; 58 | }; 59 | 60 | export type InitializationOptions = { 61 | distDir?: string; 62 | srcDir?: string; 63 | rootDir?: string; 64 | origin?: string; 65 | prefix?: string; 66 | lang?: string; 67 | server?: ServerOptions; 68 | build?: BuildOptions; 69 | debug?: DebugOptions; 70 | plugins?: any; 71 | props?: PropOptions; 72 | hooks?: { 73 | disable?: string[]; 74 | }; 75 | shortcodes?: { 76 | openPattern?: string; 77 | closePattern?: string; 78 | }; 79 | context?: string; 80 | worker?: boolean; 81 | }; 82 | 83 | export type SettingsOptions = { 84 | version: string; 85 | prefix: string; 86 | distDir: string; 87 | srcDir: string; 88 | rootDir: string; 89 | origin: string; 90 | lang: string; 91 | server: ServerOptions | false; 92 | build: BuildOptions | false; 93 | debug: DebugOptions; 94 | plugins?: any; 95 | props: PropOptions; 96 | hooks: { 97 | disable?: string[]; 98 | }; 99 | shortcodes: { 100 | openPattern: string; 101 | closePattern: string; 102 | }; 103 | $$internal: Internal; 104 | context?: string; 105 | worker?: boolean; 106 | css: 'none' | 'file' | 'inline' | 'lazy'; 107 | }; 108 | 109 | export type QueryOptions = { 110 | db?: any; 111 | }; 112 | 113 | export type ExternalHelperRequestOptions = { 114 | helpers: []; 115 | query: QueryOptions; 116 | settings: SettingsOptions; 117 | }; 118 | 119 | export type ReqDetails = { 120 | path?: string; 121 | query?: any; 122 | search?: string; 123 | }; 124 | 125 | export type RequestOptions = { 126 | slug?: string; 127 | route: string; 128 | type: string; 129 | permalink: string; 130 | req?: ReqDetails; 131 | }; 132 | 133 | export type RequestsOptions = { 134 | [name: string]: RequestOptions; 135 | }; 136 | 137 | export interface Timing { 138 | name: string; 139 | duration: number; 140 | } 141 | 142 | export interface BuildResult { 143 | timings: Array; 144 | errors: any[]; 145 | } 146 | 147 | export type StackItem = { 148 | source: string; 149 | string: string; 150 | priority: number; 151 | }; 152 | 153 | export type Stack = Array; 154 | 155 | interface Init { 156 | (input: any): any; 157 | } 158 | 159 | export type PluginOptions = { 160 | name: string; 161 | description: string; 162 | init: Init | any; 163 | routes?: RoutesOptions; 164 | hooks: Array; 165 | config?: Object; 166 | shortcodes?: ShortcodeDefs; 167 | minimumElderjsVersion?: string; 168 | }; 169 | 170 | // eslint-disable-next-line no-undef 171 | export type ExcludesFalse = (x: T | false) => x is T; 172 | 173 | export type HydrateOptions = { 174 | loading?: 'lazy' | 'eager' | 'none'; 175 | preload?: boolean; 176 | noPrefetch?: boolean; 177 | threshold?: number; 178 | rootMargin?: string; 179 | element?: string; 180 | }; 181 | 182 | export interface ComponentPayload { 183 | page: Page; 184 | props: any; 185 | hydrateOptions?: HydrateOptions; 186 | } 187 | 188 | export interface RollupDevOptions { 189 | splitComponents: boolean; 190 | } 191 | 192 | export interface RollupSettings { 193 | svelteConfig?: any; 194 | replacements?: Object; 195 | dev?: RollupDevOptions; 196 | } 197 | -------------------------------------------------------------------------------- /src/utils/windowsPathFix.ts: -------------------------------------------------------------------------------- 1 | const windowsPathFix = (filePath: string | undefined): string | undefined => { 2 | if (typeof filePath === 'string') { 3 | return filePath.replace(/\\/gm, '/'); 4 | } 5 | return undefined; 6 | }; 7 | 8 | export default windowsPathFix; 9 | -------------------------------------------------------------------------------- /src/utils/wrapPermalinkFn.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | 3 | const wrapPermalinkFn = 4 | ({ permalinkFn, routeName, settings }) => 5 | (payload) => { 6 | let permalink = permalinkFn(payload); 7 | if (typeof permalink !== 'string') { 8 | throw new Error( 9 | `The permalink function for route: "${routeName}" returned ${JSON.stringify( 10 | permalink, 11 | null, 12 | 2, 13 | )} for request object ${JSON.stringify(payload, null, 2)}. This is not a string which is required.`, 14 | ); 15 | } 16 | 17 | if (permalink !== '/') { 18 | if (permalink[0] !== '/') { 19 | if (settings.debug.automagic) { 20 | console.warn( 21 | `The permalink function for route "${routeName}" does not return a string with a beginning slash. One has been added. To disable this warning, fix the function's return value to include a beginning slash.`, 22 | ); 23 | } 24 | permalink = `/${permalink}`; 25 | } 26 | if (permalink.slice(-1) !== '/') { 27 | if (settings.debug.automagic) { 28 | console.warn( 29 | `The permalink function for route "${routeName}" does not return a string with a ending slash. One has been added. To disable this warning, fix the function's return value to include a ending slash.`, 30 | ); 31 | } 32 | permalink = `${permalink}/`; 33 | } 34 | } 35 | 36 | if (permalink.indexOf('//') !== -1) { 37 | throw new Error( 38 | `Permalink issue. ${permalink} has two slashes. You should adjust the route's permalink function. This usually happens when one of the variables needed by the permalink function is undefined. request: ${JSON.stringify( 39 | payload.request, 40 | )}`, 41 | ); 42 | } 43 | 44 | if (permalink.indexOf('undefined') !== -1) { 45 | console.warn( 46 | `Potential permalink issue. ${permalink} has 'undefined' in it. Valid URLs can sometimes have the word undefined, but this usually happens when one of the variables needed by the permalink function is undefined. request: ${JSON.stringify( 47 | payload.request, 48 | )}`, 49 | ); 50 | } 51 | 52 | if (permalink.indexOf('null') !== -1) { 53 | console.warn( 54 | `Potential permalink issue. ${permalink} has 'null' in it. Valid URLs can sometimes have the word null, but this usually happens when one of the variables needed by the permalink function is undefined. request: ${JSON.stringify( 55 | payload.request, 56 | )}`, 57 | ); 58 | } 59 | 60 | const prefix = get(settings, '$$internal.serverPrefix', ''); 61 | 62 | return prefix ? prefix + permalink : permalink; 63 | }; 64 | 65 | export default wrapPermalinkFn; 66 | -------------------------------------------------------------------------------- /src/workerBuild.ts: -------------------------------------------------------------------------------- 1 | import { asyncForEach, Page } from './utils'; 2 | 3 | async function workerBuild({ bootstrapComplete, workerRequests }) { 4 | const { 5 | settings, 6 | query, 7 | helpers, 8 | data, 9 | runHook, 10 | routes: workerRoutes, 11 | errors, 12 | allRequests, 13 | shortcodes, 14 | } = await bootstrapComplete; 15 | 16 | // potential issue that since builds are split across processes, 17 | // some plugins may need all requests of the same category to be passed at the same time. 18 | 19 | if (process.send) { 20 | process.send(['start', workerRequests.length]); 21 | } 22 | 23 | let i = 0; 24 | let errs = 0; 25 | const bTimes = []; 26 | const bErrors = []; 27 | 28 | await asyncForEach(workerRequests, async (request) => { 29 | const page = new Page({ 30 | allRequests: workerRequests || allRequests, 31 | request, 32 | settings, 33 | query, 34 | helpers, 35 | data, 36 | route: workerRoutes[request.route], 37 | runHook, 38 | routes: workerRoutes, 39 | errors, 40 | shortcodes, 41 | next: () => {}, 42 | }); 43 | i += 1; 44 | const response: any = ['requestComplete', i]; 45 | 46 | // try { 47 | const { errors: buildErrors, timings } = await page.build(); 48 | bTimes.push(timings); 49 | 50 | if (buildErrors && buildErrors.length > 0) { 51 | errs += 1; 52 | response.push(errs); 53 | response.push({ request, errors: buildErrors.map((e) => JSON.stringify(e, Object.getOwnPropertyNames(e))) }); 54 | bErrors.push({ request, errors: buildErrors }); 55 | } else { 56 | response.push(errs); 57 | } 58 | // } catch (e) {} 59 | 60 | if (process.send) { 61 | process.send(response); 62 | } 63 | }); 64 | return { timings: bTimes, errors: bErrors }; 65 | } 66 | 67 | export default workerBuild; 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "module": "commonjs", 6 | "lib": ["es2020"], 7 | "allowJs": true, 8 | 9 | "outDir": "build", 10 | "rootDir": "src", 11 | "strict": false, 12 | // "noImplicitAny": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true, 15 | "declaration": true 16 | }, 17 | "include": ["src"], 18 | "exclude": ["node_modules", "**/__tests__/**", "dist"] 19 | } 20 | --------------------------------------------------------------------------------