├── .github └── ISSUE_TEMPLATE │ └── feedback.md ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .travis.yml ├── LICENSE ├── README.md ├── assets ├── nunjucks-demo.png └── scenario-previews.png ├── demos ├── nunjucks │ ├── .gitignore │ ├── README.md │ ├── fractal.config.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ │ └── about.md │ └── src │ │ ├── assets │ │ ├── main.js │ │ ├── main.scss │ │ └── preview.js │ │ └── components │ │ ├── 01-units │ │ ├── button │ │ │ ├── arrow-left.svg │ │ │ ├── arrow-right.svg │ │ │ ├── button.config.js │ │ │ ├── button.js │ │ │ ├── button.scss │ │ │ └── view.njk │ │ └── figure │ │ │ ├── assets │ │ │ └── placeholder.jpg │ │ │ ├── figure.config.js │ │ │ ├── figure.scss │ │ │ └── view.njk │ │ └── 02-patterns │ │ ├── card-set │ │ ├── card-set.config.js │ │ ├── card-set.scss │ │ └── view.njk │ │ └── card │ │ ├── card.config.js │ │ ├── card.scss │ │ ├── notes.md │ │ └── view.njk └── vue │ ├── .gitignore │ ├── README.md │ ├── fractal.config.js │ ├── package-lock.json │ ├── package.json │ └── src │ └── components │ ├── counter │ ├── counter.config.js │ └── counter.vue │ └── multi-counter │ ├── multi-counter.config.js │ └── multi-counter.vue ├── jest.config.js ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── adapter-nunjucks │ ├── README.md │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── adapter.js │ │ ├── env.js │ │ ├── extension-component.js │ │ └── loader.js ├── adapter-vue │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── src │ │ └── adapter.js ├── app │ ├── README.md │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── app.js │ │ ├── builder.js │ │ ├── include-raw.js │ │ ├── mode.js │ │ ├── resources.js │ │ ├── router.js │ │ ├── serve.js │ │ ├── views-loader.js │ │ └── views.js ├── core │ ├── README.md │ ├── helpers.js │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── compile-components.js │ │ ├── compile-components.test.js │ │ ├── compiler.js │ │ ├── compiler.test.js │ │ ├── component-matcher.js │ │ ├── compose.js │ │ ├── compose.test.js │ │ ├── create-adapter.js │ │ ├── entities │ │ │ ├── component.js │ │ │ ├── entity.js │ │ │ └── file.js │ │ ├── helpers.js │ │ ├── html-adapter.js │ │ ├── middleware │ │ │ ├── config.js │ │ │ ├── files.js │ │ │ ├── index.js │ │ │ ├── label.js │ │ │ ├── name.js │ │ │ └── scenarios.js │ │ ├── read.js │ │ ├── read.test.js │ │ ├── state.js │ │ └── state.test.js │ └── test │ │ ├── .gitignore │ │ ├── fixtures │ │ ├── components │ │ │ ├── @standard │ │ │ │ ├── standard.config.js │ │ │ │ └── view.html │ │ │ └── nested │ │ │ │ └── @yaml │ │ │ │ ├── view.html │ │ │ │ └── yaml.config.yml │ │ ├── custom-matcher │ │ │ └── component-standard │ │ │ │ └── view.html │ │ └── ignored │ │ │ └── @with-node-modules │ │ │ ├── node_modules │ │ │ └── ignored.js │ │ │ └── view.html │ │ └── integration.test.js ├── fractalite │ ├── .babelrc │ ├── .gitignore │ ├── README.md │ ├── assets │ │ ├── app.js │ │ ├── app.scss │ │ ├── css │ │ │ ├── app.scss │ │ │ ├── base.scss │ │ │ ├── brand.scss │ │ │ ├── controls.scss │ │ │ ├── error.scss │ │ │ ├── highlight.scss │ │ │ ├── inspector.scss │ │ │ ├── json-explorer.scss │ │ │ ├── nav.scss │ │ │ ├── page.scss │ │ │ ├── panels.scss │ │ │ ├── preview.scss │ │ │ ├── proptable.scss │ │ │ ├── prose.scss │ │ │ ├── search.scss │ │ │ ├── source-code.scss │ │ │ ├── split.scss │ │ │ ├── tabs.scss │ │ │ └── theme.scss │ │ └── reload.js │ ├── bin │ │ └── fractalite │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── pages │ │ └── index.njk │ ├── src │ │ ├── client │ │ │ ├── app.js │ │ │ ├── components │ │ │ │ ├── app-link.js │ │ │ │ ├── error.js │ │ │ │ ├── inspector.js │ │ │ │ ├── json-explorer.js │ │ │ │ ├── navigation.js │ │ │ │ ├── page.js │ │ │ │ ├── preview.js │ │ │ │ ├── search.js │ │ │ │ ├── source-code.js │ │ │ │ └── split.js │ │ │ ├── events.js │ │ │ ├── router.js │ │ │ └── store.js │ │ └── server │ │ │ ├── inspector.js │ │ │ ├── nav.js │ │ │ ├── pages.js │ │ │ ├── plugins │ │ │ ├── index.js │ │ │ ├── inspector-html.js │ │ │ ├── inspector-info.js │ │ │ └── inspector-props.js │ │ │ ├── preview.js │ │ │ ├── public.js │ │ │ ├── references.js │ │ │ ├── search.js │ │ │ ├── theme.js │ │ │ └── utils │ │ │ ├── highlight.js │ │ │ ├── markdown.js │ │ │ ├── prettify.js │ │ │ └── resolve-asset.js │ └── views │ │ ├── app.njk │ │ ├── error.njk │ │ ├── layout.njk │ │ ├── partials │ │ └── brand.njk │ │ ├── plugins │ │ └── inspector-info.njk │ │ ├── preview.njk │ │ └── vue │ │ ├── error.html │ │ ├── inspector.html │ │ ├── navigation.html │ │ ├── page.html │ │ ├── preview.html │ │ └── search.html ├── plugin-assets-bundler │ ├── README.md │ ├── index.js │ ├── package-lock.json │ └── package.json ├── plugin-notes │ ├── README.md │ ├── index.js │ ├── package-lock.json │ └── package.json └── support │ ├── README.md │ ├── html.js │ ├── index.js │ ├── load-config.js │ ├── package-lock.json │ ├── package.json │ └── utils.js └── test └── helpers └── generate-components.js /.github/ISSUE_TEMPLATE/feedback.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feedback 3 | about: 'Provide feedback on the Fractalite prototype ' 4 | title: 'Feedback ' 5 | labels: Feedback 6 | assignees: allmarkedup 7 | --- 8 | 9 | (Thanks for taking the time to submit feedback! We've supplied some questions 10 | below to try and help standardise responses, but feel free to skip/delete/amend as you see fit.) 11 | 12 | ## Initial thoughts 13 | 14 | #### Did you have any difficulties getting the prototype demo up and running? 15 | 16 | #### Were your initial gut feelings about the prototype direction positive or negative? 17 | 18 | #### Do you think this prototype represents a good development direction for the next version of Fractal? 19 | 20 | ## Components 21 | 22 | #### Did you like the `@name` folder name convention for components? 23 | 24 | #### v1-style 'single file components' are no longer supported. Does that pose any difficulties for you? 25 | 26 | #### Do you have any thoughts on the naming of configuration properties? 27 | 28 | (For example, v1 'variants' and 'context data' config properties have been renamed to 'scenarios' and 'props' respectively. Are those names better/worse/the same?) 29 | 30 | ## Adapters 31 | 32 | #### Which template engines/frameworks would you most like to see integration with? (2 - 3 max) 33 | 34 | - [ ] DustJS 35 | - [ ] HAML 36 | - [ ] Handlebars 37 | - [ ] Marko 38 | - [ ] Mustache 39 | - [ ] Nunjucks 40 | - [ ] Pug 41 | - [ ] React 42 | - [ ] Twig 43 | - [ ] Vue 44 | - [ ] Other - please specify below 45 | 46 | ## For developers... 47 | 48 | #### Does the plugin model make sense to you? Any questions/queries about it? 49 | 50 | #### Are there customisations that you'd like to make that you think might not be possible in the current plugin system implementation? 51 | 52 | #### Does the Adapter system implementation make sense you you? 53 | 54 | #### Do you think the monorepo approach is a good route future Fractal development? 55 | 56 | ## Other 57 | 58 | #### Is there anything missing from the prototype that currently exists in v1 and that you think should be part of the core of any future versions (i.e. not implemented as a plugin)? 59 | 60 | #### Any other comments/suggestions/ideas? 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | lib-cov 7 | coverage 8 | .nyc_output 9 | node_modules/ 10 | .npm 11 | .eslintcache 12 | .node_repl_history 13 | *.tgz 14 | .yarn-integrity 15 | .env 16 | .wip* 17 | /dist 18 | .cache 19 | .wip-packages 20 | .demo-* 21 | test/fixtures/generated 22 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .*rc 2 | README.md 3 | docs 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 200, 3 | singleQuote: true, 4 | bracketSpacing: true, 5 | useTabs: false 6 | }; 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - windows 3 | - linux 4 | - osx 5 | language: node_js 6 | node_js: 7 | - lts/dubnium 8 | - '11.10.1' 9 | sudo: false 10 | before_script: 11 | - npm run bootstrap 12 | script: 13 | - 'npm run test:ci' 14 | cache: 15 | directories: 16 | - node_modules 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mark Perkins 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 | -------------------------------------------------------------------------------- /assets/nunjucks-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frctl/fractalite/8e1dd708265df58921e9061d787e59dd3022d1d6/assets/nunjucks-demo.png -------------------------------------------------------------------------------- /assets/scenario-previews.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frctl/fractalite/8e1dd708265df58921e9061d787e59dd3022d1d6/assets/scenario-previews.png -------------------------------------------------------------------------------- /demos/nunjucks/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | build 3 | -------------------------------------------------------------------------------- /demos/nunjucks/README.md: -------------------------------------------------------------------------------- 1 | ## Nunjucks demo project 2 | 3 | A Fractalite demo using the Nunjucks adapter and the asset bundler plugin. 4 | -------------------------------------------------------------------------------- /demos/nunjucks/fractal.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | 3 | module.exports = { 4 | /* 5 | * Project title used in the UI 6 | */ 7 | title: 'Nunjucks Demo', 8 | 9 | /* 10 | * Template engine adapter 11 | * 12 | * Adapters are responsible for implementing component 13 | * rendering but can also hook into the component compilation 14 | * and UI preview rendering steps if required. 15 | */ 16 | adapter: require('@frctl/fractalite-adapter-nunjucks')({ 17 | // Adapter options here... 18 | }), 19 | 20 | /* 21 | * Basic theme tweaks can be done via config here. 22 | * The `vars` object is a set of CSS variable overrides 23 | * that will be applied to the UI. 24 | */ 25 | theme: { 26 | vars: { 27 | 'link-color': '#0074d9' 28 | } 29 | }, 30 | 31 | /* 32 | * Absolute path to the components directory. 33 | * 34 | * This can also be an array of directory paths and 35 | * can use glob syntax with negation to exclude certain 36 | * files from the compiler. 37 | * 38 | * `node_modules` directories within the components directory 39 | * are always ignored. 40 | */ 41 | components: resolve(__dirname, './src/components'), 42 | 43 | /* 44 | * Absolute path to the pages directory, if required. 45 | * 46 | * Pages are markdown documents that can additionally be 47 | * run through a Nunjucks renderer to give dynamic access 48 | * to component information. 49 | */ 50 | pages: resolve(__dirname, './pages'), 51 | 52 | nav: { 53 | /* 54 | * Optional navigation generator. 55 | * 56 | * If not provided the default navigation structure will be used. 57 | * 58 | * The value of the items property can either be an array of navigation 59 | * items or a function that returns an array of items. The function will 60 | * be called with a set of entities (such as components and pages) as well 61 | * as a toTree utility function for converting flat arrays of entities to 62 | * nav tree structures. 63 | * 64 | * Each item in the tree can have the following properties: 65 | * 66 | * - `label`: The text displayed for the nav item 67 | * - `url`: the URL to link to (if not a directory) 68 | * - `children`: An array of child navigation items 69 | */ 70 | items({ components, pages }, toTree) { 71 | return [ 72 | toTree(pages), 73 | { 74 | label: 'Components', 75 | children: toTree(components), 76 | expanded: true // open by default 77 | }, 78 | { 79 | label: 'External links', 80 | expanded: true, 81 | children: [ 82 | { 83 | label: 'Github repo →', 84 | url: 'http://github.com/frctl/fractalite' 85 | } 86 | ] 87 | } 88 | ]; 89 | } 90 | }, 91 | 92 | /* 93 | * Project plugins registry 94 | * 95 | * Plugins have full access to both the core component compiler 96 | * and the UI generator. 97 | * 98 | * A plugin is just a function that receives the main application 99 | * object. Most plugins will additionally wrap this function in another 100 | * that can accept options for user customisation. 101 | */ 102 | plugins: [ 103 | /* 104 | * The asset bundler plugin uses Parcel for zero-config 105 | * asset bundling and hot module reloading. 106 | * It also automatically serves bundled assets and 107 | * adds them to component previews. 108 | * 109 | * Whilst sufficient for most projects, those with more complex 110 | * asset build requirements may choose to use their own asset 111 | * bundler/development workflow. 112 | */ 113 | require('@frctl/fractalite-plugin-assets-bundler')({ 114 | entryFile: resolve(__dirname, './src/assets/preview.js'), 115 | outFile: resolve(__dirname, './dist/assets/build.js') 116 | }), 117 | 118 | /* 119 | * The notes plugin allows component notes to be 120 | * specified in component config files and/or (optionally) 121 | * read from a markdown file in the component directory. 122 | */ 123 | require('@frctl/fractalite-plugin-notes')({ 124 | notesFile: 'notes.md' 125 | }) 126 | ] 127 | }; 128 | -------------------------------------------------------------------------------- /demos/nunjucks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-demo-nunjucks", 3 | "private": true, 4 | "scripts": { 5 | "start": "fractalite start --port 3030", 6 | "build": "fractalite build --dest ./build --serve --port 4040", 7 | "serve-static": "fractalite serve --dest ./build --port 4040" 8 | }, 9 | "devDependencies": { 10 | "@frctl/fractalite": "^0.0.0", 11 | "@frctl/fractalite-adapter-nunjucks": "^0.0.0", 12 | "@frctl/fractalite-plugin-assets-bundler": "^0.0.0", 13 | "@frctl/fractalite-plugin-notes": "^0.0.0", 14 | "sass": "^1.17.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demos/nunjucks/pages/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | label: Using pages 3 | --- 4 | 5 | You can add as many pages to your styleguide as you like. 6 | 7 | Pages are written in markdown and code samples are highlighted automatically: 8 | 9 | ```html 10 |

Some HTML code here

11 | ``` 12 | 13 | You can use reference tags to link to your components: [Check out the button →]({inspect:button}) 14 | -------------------------------------------------------------------------------- /demos/nunjucks/src/assets/main.js: -------------------------------------------------------------------------------- 1 | import '../components/01-units/button/button.js'; 2 | -------------------------------------------------------------------------------- /demos/nunjucks/src/assets/main.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | font-family: sans-serif; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | body { 9 | padding: 20px; 10 | } 11 | -------------------------------------------------------------------------------- /demos/nunjucks/src/assets/preview.js: -------------------------------------------------------------------------------- 1 | import './main.js'; 2 | 3 | import './main.scss'; 4 | import '../components/**/*.scss'; 5 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/button/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/button/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/button/button.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /* 3 | * Component `scenarios` are very similar to `variants` in 4 | * Fractal v1. They define a set of named component instances 5 | * that are then displayed in the UI. 6 | */ 7 | scenarios: [ 8 | { 9 | name: 'next', 10 | /* 11 | * The `props` object is similar to `context` in Fractal v1 12 | * Properties defined here are passed to the template engine adapter 13 | * when the component is being rendered. 14 | */ 15 | props: { 16 | text: 'Next', 17 | modifier: 'action', 18 | icon: './arrow-right.svg', // Component-relative file paths are automatically converted to URLs 19 | iconPos: 'after' 20 | } 21 | }, 22 | { 23 | name: 'prev', 24 | props: { 25 | text: 'Prev', 26 | modifier: 'action', 27 | icon: './arrow-left.svg', 28 | iconPos: 'before' 29 | }, 30 | /* 31 | * Multiple scenario instances can be rendered in the same preview 32 | * by providing an array of props for the `preview` option. 33 | * Each preview props object will be merged with the default scenario 34 | * props defined above. 35 | */ 36 | preview: [ 37 | { 38 | text: 'Back' 39 | }, 40 | { 41 | text: 'A prev button with a long label' 42 | } 43 | ] 44 | } 45 | ], 46 | 47 | /* 48 | * Configure the component's search behaviour. 49 | * 50 | * The `aliases` array is a list of extra strings to match against. 51 | * `hidden: true` can be used to prevent the component from 52 | * being matched in the search results. 53 | */ 54 | search: { 55 | aliases: ['clicker'] 56 | }, 57 | 58 | /* 59 | * The component preview option lets users customise 60 | * previews on a per-component basis. 61 | */ 62 | preview: { 63 | /* 64 | * The wrap() method allows wrapping of the entire rendered 65 | * output in arbitary HTML. 66 | */ 67 | wrap(html, { scenario, component }) { 68 | return `
69 |

${component.label} / ${scenario.label}

70 | ${html} 71 |
`; 72 | }, 73 | 74 | /* 75 | * The wrapEach() method allows wrapping of each scenario instance 76 | * within the preview window. Only applicable when defining multiple 77 | * scenario preview instances such as in the `prev`scenario above. 78 | */ 79 | wrapEach(html) { 80 | return `
${html}
`; 81 | }, 82 | 83 | /* 84 | * Preview-specific scripts can be added here. 85 | * These can also be specified globally instead of on 86 | * a per-component basis if required. 87 | */ 88 | js: `console.log('This is one way to inject preview-specific JS');`, 89 | 90 | /* 91 | * Preview-specific styles can be added here. 92 | * These can also be specified globally instead of on 93 | * a per-component basis if required. 94 | */ 95 | css: ` 96 | .preview-title { 97 | color: #aaa; 98 | font-weight: normal; 99 | margin-bottom: 10px; 100 | } 101 | .preview-instance { 102 | margin-bottom: 10px; 103 | } 104 | ` 105 | }, 106 | 107 | /* 108 | * The notes property is used by the Notes plugin. Any text here will 109 | * be rendered in a custom notes panel in the inspector. 110 | */ 111 | notes: ` 112 | Some notes on the button component. 113 | 114 | These were added inline in the [button config file]({file:button/button.config.js}) 115 | ` 116 | }; 117 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/button/button.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | const buttons = document.querySelectorAll('.button'); 4 | 5 | function clickLogger(event) { 6 | console.log('button clicked!'); 7 | event.preventDefault(); 8 | } 9 | 10 | for (let i = 0; i < buttons.length; i++) { 11 | buttons[i].addEventListener('click', clickLogger); 12 | } 13 | 14 | // If using the styleguide bundler plugin with 15 | // Hot Module Replacement (HMR) enabled, the code needs to 16 | // handle cleaning up event listeners etc when the module 17 | // contents are replaced. See https://parceljs.org/hmr.html 18 | if (module.hot) { 19 | module.hot.dispose(() => { 20 | for (let i = 0; i < buttons.length; i++) { 21 | buttons[i].removeEventListener('click', clickLogger); 22 | } 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/button/button.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | border: 1px solid #ccc; 3 | padding: 10px 10px; 4 | background: #f4f4f4; 5 | cursor: pointer; 6 | border-radius: 4px; 7 | display: inline-flex; 8 | align-items: center; 9 | color: #333; 10 | text-decoration: none; 11 | 12 | &__text { 13 | font-size: 16px; 14 | } 15 | 16 | &__icon { 17 | width: 14px; 18 | } 19 | 20 | &__text + &__icon { 21 | margin-left: 10px; 22 | } 23 | 24 | &__icon + &__text { 25 | margin-left: 10px; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/button/view.njk: -------------------------------------------------------------------------------- 1 | 2 | {% if icon and iconPos == 'before' %}{% endif %} 3 | {{ text }} 4 | {% if icon and iconPos == 'after' %}{% endif %} 5 | 6 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/figure/assets/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frctl/fractalite/8e1dd708265df58921e9061d787e59dd3022d1d6/demos/nunjucks/src/components/01-units/figure/assets/placeholder.jpg -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/figure/figure.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | scenarios: [ 3 | { 4 | name: 'image', 5 | props: { 6 | type: 'image', 7 | src: './assets/placeholder.jpg', 8 | alt: 'Image alt text' 9 | } 10 | }, 11 | { 12 | name: 'image-with-caption', 13 | props: { 14 | type: 'image', 15 | src: './assets/placeholder.jpg', 16 | alt: 'Image alt text', 17 | caption: 'A caption for the figure' 18 | } 19 | }, 20 | { 21 | name: 'video', 22 | props: { 23 | type: 'video', 24 | src: 'https://www.youtube.com/embed/ScMzIvxBSi4' 25 | } 26 | } 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/figure/figure.scss: -------------------------------------------------------------------------------- 1 | .figure { 2 | max-width: 600px; 3 | 4 | &__image { 5 | display: block; 6 | margin-bottom: 10px; 7 | max-width: 100%; 8 | } 9 | 10 | &__caption { 11 | font-size: 15px; 12 | font-style: italic; 13 | color: #777; 14 | } 15 | 16 | &--video &__container { 17 | position: relative; 18 | padding-bottom: 56.25%; /* 16:9 */ 19 | padding-top: 25px; 20 | height: 0; 21 | } 22 | 23 | &--video &__video { 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | width: 100%; 28 | height: 100%; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/01-units/figure/view.njk: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if type == 'image' %} 4 | {{ alt }} 5 | {% elseif type == 'video' %} 6 | 7 | {% endif %} 8 |
9 | {% if caption %}
{{ caption }}
{% endif %} 10 |
11 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/02-patterns/card-set/card-set.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | scenarios: [ 3 | { 4 | name: 'triplet', 5 | props: { 6 | cardType: 'card/image', 7 | cards: [ 8 | { 9 | content: 'First kitten' 10 | }, 11 | { 12 | content: 'Second kitten' 13 | }, 14 | { 15 | content: 'Third kitten' 16 | } 17 | ] 18 | } 19 | } 20 | ] 21 | }; 22 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/02-patterns/card-set/card-set.scss: -------------------------------------------------------------------------------- 1 | .card-set { 2 | overflow: hidden; 3 | 4 | &__card { 5 | margin-bottom: 15px; 6 | } 7 | 8 | @media all and (min-width: 780px) { 9 | &__cards { 10 | display: flex; 11 | list-style: none; 12 | margin-left: -10px; 13 | margin-right: -10px; 14 | } 15 | 16 | &__card { 17 | padding: 0 10px; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/02-patterns/card-set/view.njk: -------------------------------------------------------------------------------- 1 |
2 | 9 |
10 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/02-patterns/card/card.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | label: 'Card', 3 | 4 | scenarios: [ 5 | { 6 | name: 'basic', 7 | props: { 8 | title: 'A card title', 9 | content: '

Some content for the card.

' 10 | } 11 | }, 12 | { 13 | name: 'cta', 14 | label: 'Call to action', 15 | props: { 16 | title: 'Sign up now', 17 | content: '

Click the button below to do this thing.

', 18 | cta: { 19 | text: 'Click me' 20 | } 21 | } 22 | }, 23 | { 24 | name: 'image', 25 | label: 'With image', 26 | props: { 27 | title: 'A kitten', 28 | content: '

Because every example needs a kitten in there somewhere.

', 29 | image: { 30 | src: 'http://placekitten.com/900/600', 31 | alt: 'Meow' 32 | } 33 | } 34 | } 35 | ] 36 | }; 37 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/02-patterns/card/card.scss: -------------------------------------------------------------------------------- 1 | .card { 2 | border: 1px solid #ddd; 3 | padding: 15px; 4 | max-width: 600px; 5 | box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); 6 | 7 | &__title { 8 | font-size: 20px; 9 | margin-bottom: 20px; 10 | } 11 | 12 | &__content + .button { 13 | margin-top: 20px; 14 | } 15 | 16 | &__image { 17 | width: 100%; 18 | } 19 | 20 | .figure + &__content { 21 | margin-top: 20px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/02-patterns/card/notes.md: -------------------------------------------------------------------------------- 1 | This is a card component. 2 | 3 | It uses the [Button]({component:button}) component inside of it. 4 | 5 | This file is generated from the contents of the [notes.md]({file:card/notes.md}) file. 6 | -------------------------------------------------------------------------------- /demos/nunjucks/src/components/02-patterns/card/view.njk: -------------------------------------------------------------------------------- 1 | {# 2 | # The Nunjucks adapter supports importing of child components 3 | # via the {% component %} tag. 4 | # 5 | # Some examples of how it can be used: 6 | # 7 | # {% component 'component-name' %} 8 | # {% component 'component-name/scenario-name' %} 9 | # {% component 'component-name', props %} 10 | # {% component 'component-name/scenario-name', props %} 11 | # 12 | # Note that `props` above can be a variable or an inline-object of property values. 13 | # If a scenario name *and* props are specified then the two objects are merged. 14 | # 15 | # In the example below, the 'button' component is rendered using the props from 16 | # the 'next' scenario merged with data from the `cta` object. 17 | #} 18 |
19 |

{{ title }}

20 | {% if image %}{% component 'figure/image', image %}{% endif %} 21 |
22 | {{ content | safe }} 23 |
24 | {% if cta %}{% component 'button/next', cta %}{% endif %} 25 |
26 | -------------------------------------------------------------------------------- /demos/vue/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | src/assets/preview.js 3 | build 4 | -------------------------------------------------------------------------------- /demos/vue/README.md: -------------------------------------------------------------------------------- 1 | ## Vue demo project 2 | 3 | A proof-of-concept demo using the Vue adapter and the asset bundler plugin. 4 | -------------------------------------------------------------------------------- /demos/vue/fractal.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const vueAdapter = require('@frctl/fractalite-adapter-vue')({ 3 | // Vue adapter opts here 4 | }); 5 | 6 | module.exports = { 7 | title: 'Vue Demo', 8 | 9 | adapter: vueAdapter, 10 | 11 | components: resolve(__dirname, './src/components'), 12 | 13 | plugins: [ 14 | require('@frctl/fractalite-plugin-assets-bundler')({ 15 | entryBuilder: vueAdapter.entryBuilder, // Add Vue entry builder 16 | entryFile: resolve(__dirname, './src/assets/preview.js'), 17 | outFile: resolve(__dirname, './dist/assets/build.js') 18 | }) 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /demos/vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-demo-vue", 3 | "private": true, 4 | "scripts": { 5 | "start": "fractalite start --port 3031", 6 | "build": "fractalite build --dest ./build --serve --port 4041", 7 | "serve-static": "fractalite serve --dest ./build --port 4041" 8 | }, 9 | "devDependencies": { 10 | "@frctl/fractalite": "^0.0.0", 11 | "@frctl/fractalite-adapter-vue": "^0.0.0", 12 | "@frctl/fractalite-plugin-assets-bundler": "^0.0.0", 13 | "@vue/component-compiler-utils": "^2.5.2", 14 | "vue-template-compiler": "^2.6.6" 15 | }, 16 | "dependencies": { 17 | "vue": "^2.6.6", 18 | "vue-hot-reload-api": "^2.3.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demos/vue/src/components/counter/counter.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | scenarios: [ 3 | { 4 | name: 'Standard', 5 | props: { 6 | label: 'Click here to increment the counter' 7 | } 8 | } 9 | ] 10 | }; 11 | -------------------------------------------------------------------------------- /demos/vue/src/components/counter/counter.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | 24 | 37 | -------------------------------------------------------------------------------- /demos/vue/src/components/multi-counter/multi-counter.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | scenarios: [ 3 | { 4 | name: 'default', 5 | props: { 6 | 'total-label': 'Sum of A and B', 7 | counters: [ 8 | { 9 | label: 'A' 10 | }, 11 | { 12 | label: 'B' 13 | } 14 | ] 15 | } 16 | } 17 | ] 18 | }; 19 | -------------------------------------------------------------------------------- /demos/vue/src/components/multi-counter/multi-counter.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | 46 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: {} 3 | }; 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/**", "demos/**"], 3 | "version": "0.0.0" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "@frctl/eslint-config-frctl": "^0.1.3", 5 | "asyncro": "^3.0.0", 6 | "common-tags": "^1.8.0", 7 | "fs-extra": "^8.1.0", 8 | "jest": "^24.9.0", 9 | "lerna": "^3.20.2", 10 | "xo": "^0.25.3" 11 | }, 12 | "name": "fractalite", 13 | "scripts": { 14 | "bootstrap": "lerna bootstrap", 15 | "lint:fix": "xo --fix", 16 | "test": "xo && jest --coverage", 17 | "test:watch": "xo && jest --watch", 18 | "test:ci": "jest", 19 | "test:generate:components": "node ./test/helpers/generate-components", 20 | "fractalite:build": "cd packages/fractalite && npm run build", 21 | "fractalite:dev": "cd packages/fractalite && npm run dev", 22 | "demo:vue": "npm run bootstrap && cd demos/vue && npm run start", 23 | "demo:nunjucks": "npm run bootstrap && cd demos/nunjucks && npm run start", 24 | "demo:nunjucks:build": "npm run bootstrap && cd demos/nunjucks && npm run build", 25 | "demo": "npm run demo:nunjucks", 26 | "demo:build": "npm run demo:nunjucks:build" 27 | }, 28 | "xo": { 29 | "extends": "@frctl/eslint-config-frctl", 30 | "prettier": true, 31 | "spaces": true, 32 | "rules": { 33 | "unicorn/catch-error-name": [ 34 | "error", 35 | { 36 | "name": "err" 37 | } 38 | ] 39 | }, 40 | "ignores": [ 41 | "**/dist/*", 42 | "**/assets/*", 43 | "**/test/**", 44 | "**/*.test.js", 45 | "./demos/**" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/adapter-nunjucks/README.md: -------------------------------------------------------------------------------- 1 | ## @frctl/fractalite-adapter-nunjucks 2 | 3 | A Nunjucks template engine adapter for the Fractalite prototype. 4 | 5 | ### Installation 6 | 7 | `npm i @frctl/fractalite-adapter-nunjucks` 8 | 9 | Specify as adapter in the project config file: 10 | 11 | ```js 12 | // fractal.config.js 13 | module.exports = { 14 | adapter: require('@frctl/fractalite-adapter-nunjucks')({ 15 | // config options here 16 | }) 17 | }; 18 | ``` 19 | 20 | ### Usage 21 | 22 | A Nunjucks view template should be created per component. This should be called `view.njk` or `{component-name}.view.njk`. 23 | 24 | ``` 25 | @button 26 | ├── button.config.js 27 | └── view.njk 28 | ``` 29 | 30 | ```html 31 | 32 | 33 | {{ text }} 34 | 35 | ``` 36 | 37 | #### Including child components 38 | 39 | The Nunjucks adapter supports importing of child components in templates via the `{% component %}` tag. 40 | 41 | ```html 42 | {% component 'component-name' %} 43 | {% component 'component-name/scenario-name' %} 44 | {% component 'component-name', props %} 45 | {% component 'component-name/scenario-name', props %} 46 | ``` 47 | 48 | Note that `props` above can be a variable or an inline-object of property values. 49 | 50 | If a scenario name *and* props are specified then the two objects are merged. 51 | -------------------------------------------------------------------------------- /packages/adapter-nunjucks/index.js: -------------------------------------------------------------------------------- 1 | const { defaultsDeep } = require('@frctl/fractalite-support/utils'); 2 | const createAdapter = require('./src/adapter'); 3 | 4 | const defaults = { 5 | views: ['view.njk', '{name}.view.njk'] 6 | }; 7 | 8 | module.exports = function(opts = {}) { 9 | opts = defaultsDeep(opts, defaults); 10 | 11 | return function nunjucksAdapter(app, compiler) { 12 | const adapter = createAdapter(compiler, opts); 13 | 14 | // Compiler middleware to identify .njk files as HTML fragments so that 15 | // further pre-processing of templates can take place later if required. 16 | compiler.use(components => { 17 | components.forEach(component => { 18 | const nunjucksFiles = component.matchFiles(['*.njk']); 19 | nunjucksFiles.forEach(file => { 20 | file.isHTMLFragment = true; 21 | }); 22 | }); 23 | }); 24 | 25 | return adapter; 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/adapter-nunjucks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-adapter-nunjucks", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@frctl/fractalite-core": "^0.0.0", 6 | "@frctl/fractalite-support": "^0.0.0", 7 | "nunjucks": "^3.1.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/adapter-nunjucks/src/adapter.js: -------------------------------------------------------------------------------- 1 | const nunjucksEnv = require('./env'); 2 | 3 | module.exports = function(compiler, opts = {}) { 4 | const env = nunjucksEnv(opts); 5 | const { views } = env.loader; 6 | env.state = compiler.getState(); // For use in extensions/filters etc 7 | 8 | compiler.on('finish', state => { 9 | /* 10 | * Empty views array and then replace with 11 | * updated set of view tempates. 12 | */ 13 | views.length = 0; 14 | state.components.forEach(component => { 15 | // Lookup view by handle: {% include 'button' %} 16 | views.push({ 17 | name: component.name, 18 | getContents: () => { 19 | const view = component.matchFiles(opts.views)[0]; 20 | return view ? view.getContents() : null; 21 | } 22 | }); 23 | // Lookup component-relative file by `./filename.ext` path 24 | component.files.forEach(file => { 25 | views.push({ 26 | name: `./${file.componentPath}`, 27 | getContents: () => file.getContents() 28 | }); 29 | }); 30 | }); 31 | }); 32 | 33 | async function render(component, props, state) { 34 | return new Promise((resolve, reject) => { 35 | env.render(component.name, props, (err, result) => { 36 | return err ? reject(err) : resolve(result); 37 | }); 38 | }); 39 | } 40 | 41 | async function getTemplateString(component) { 42 | const view = component.matchFiles(opts.views)[0]; 43 | return view ? view.getContents() : null; 44 | } 45 | 46 | return { render, getTemplateString }; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/adapter-nunjucks/src/env.js: -------------------------------------------------------------------------------- 1 | const { Environment } = require('nunjucks'); 2 | const ComponentExtension = require('./extension-component'); 3 | const Loader = require('./loader'); 4 | 5 | module.exports = function nunjucks(config = {}) { 6 | const loader = new Loader(); 7 | const env = new Environment([loader]); 8 | 9 | env.addExtension('component', new ComponentExtension()); 10 | 11 | Object.keys(config.globals || {}).forEach(key => env.addGlobal(key, config.globals[key])); 12 | Object.keys(config.extensions || {}).forEach(key => env.addExtension(key, config.extensions[key])); 13 | if (Array.isArray(config.filters)) { 14 | config.filters.forEach(filter => env.addFilter(filter.name, filter.filter, filter.async)); 15 | } else { 16 | Object.keys(config.filters || {}).forEach(key => env.addFilter(key, config.filters[key], config.filters[key].async)); 17 | } 18 | 19 | env.loader = loader; 20 | 21 | return env; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/adapter-nunjucks/src/extension-component.js: -------------------------------------------------------------------------------- 1 | const { SafeString } = require('nunjucks').runtime; 2 | const { mergeProps, getComponent, getScenario } = require('@frctl/fractalite-core/helpers'); 3 | 4 | module.exports = class ComponentExtension { 5 | constructor() { 6 | this.tags = ['component']; 7 | } 8 | 9 | parse(parser, nodes) { 10 | const tok = parser.nextToken(); 11 | const args = parser.parseSignature(null, true); 12 | 13 | parser.advanceAfterBlockEnd(tok.value); 14 | return new nodes.CallExtensionAsync(this, 'run', args, null); 15 | } 16 | 17 | run(nunjucksContext, handle, ...args) { 18 | const { env } = nunjucksContext; 19 | const callback = args.pop(); 20 | let props = args.shift() || {}; 21 | const shouldMerge = args.shift(); 22 | const { state } = env; 23 | 24 | const [componentName, scenarioName] = handle.split('/'); 25 | 26 | const component = getComponent(state, componentName, true); 27 | const scenario = getScenario(component, scenarioName, true); 28 | 29 | props = shouldMerge === false ? props : mergeProps(state, scenario.props, props); 30 | 31 | env.render(component.name, props, (err, result) => { 32 | if (err) { 33 | return callback(err); 34 | } 35 | callback(null, new SafeString(result)); 36 | }); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /packages/adapter-nunjucks/src/loader.js: -------------------------------------------------------------------------------- 1 | const { Loader } = require('nunjucks'); 2 | 3 | module.exports = Loader.extend({ 4 | views: [], 5 | async: true, 6 | async getSource(lookup, callback) { 7 | const name = lookup.replace(/^@/, ''); 8 | const view = this.views.find(view => view.name === name); 9 | if (view) { 10 | try { 11 | const contents = await view.getContents(); 12 | return callback(null, { 13 | src: contents, 14 | path: lookup, 15 | noCache: true 16 | }); 17 | } catch (err) { 18 | return callback(err); 19 | } 20 | } 21 | callback(new Error(`Nunjucks adapter: Template '${lookup}' not found`)); 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /packages/adapter-vue/index.js: -------------------------------------------------------------------------------- 1 | const { relative } = require('path'); 2 | const { merge, camelCase } = require('lodash'); 3 | const { source } = require('common-tags'); 4 | const createAdapter = require('./src/adapter'); 5 | 6 | const defaults = { 7 | previewAppId: 'prevue' 8 | }; 9 | 10 | module.exports = function(opts = {}) { 11 | opts = merge(defaults, opts); 12 | const adapter = createAdapter(opts); 13 | 14 | function attacher(app) { 15 | return adapter; 16 | } 17 | 18 | attacher.entryBuilder = function(state, { dir }) { 19 | const imports = state.components.map(component => { 20 | const vueFile = component.files.find(f => f.ext === '.vue'); 21 | const as = camelCase(component.name); 22 | const path = relative(dir, vueFile.path); 23 | return { as, name: component.name, path }; 24 | }); 25 | 26 | return source` 27 | import Vue from 'vue/dist/vue.js'; 28 | 29 | ${opts.setup} 30 | 31 | ${imports.map( 32 | ({ name, as, path }) => source` 33 | import ${as} from './${path}'; 34 | Vue.component('${name}', ${as}); 35 | ` 36 | )} 37 | 38 | new Vue({ el: '#${opts.previewAppId}' }); 39 | `; 40 | }; 41 | 42 | return attacher; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/adapter-vue/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-adapter-vue", 3 | "version": "0.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "common-tags": { 8 | "version": "1.8.0", 9 | "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", 10 | "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==" 11 | }, 12 | "he": { 13 | "version": "1.1.1", 14 | "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", 15 | "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" 16 | }, 17 | "json5": { 18 | "version": "2.1.0", 19 | "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz", 20 | "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==", 21 | "requires": { 22 | "minimist": "^1.2.0" 23 | } 24 | }, 25 | "lodash": { 26 | "version": "4.17.11", 27 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", 28 | "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" 29 | }, 30 | "minimist": { 31 | "version": "1.2.0", 32 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", 33 | "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" 34 | }, 35 | "node-html-parser": { 36 | "version": "1.1.14", 37 | "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.1.14.tgz", 38 | "integrity": "sha512-UcvX5vo3vqlDUpOVvy67Qdp8um0cYc30blTI1yLArF7g8SD3Ci1xomIkPBg1+AKZ8LgrYixvOQ9G2PkOFk2AEg==", 39 | "requires": { 40 | "he": "1.1.1" 41 | } 42 | }, 43 | "strip-indent": { 44 | "version": "2.0.0", 45 | "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", 46 | "integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/adapter-vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-adapter-vue", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "common-tags": "^1.8.0", 6 | "json5": "^2.1.0", 7 | "lodash": "^4.17.10", 8 | "node-html-parser": "^1.1.14", 9 | "strip-indent": "^2.0.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/adapter-vue/src/adapter.js: -------------------------------------------------------------------------------- 1 | const JSON5 = require('json5'); 2 | const stripIndent = require('strip-indent'); 3 | const { parse } = require('node-html-parser'); 4 | const { map, isPlainObject, isString } = require('lodash'); 5 | 6 | module.exports = function(opts = {}) { 7 | return { 8 | async render(component, props, state) { 9 | const attrs = map(props, toVueAttr); 10 | return ` 11 | <${component.name} ${attrs.join('\n')}> 12 | `; 13 | }, 14 | 15 | getPreviewString(content) { 16 | return `
${content}
`; 17 | }, 18 | 19 | async getTemplateString(component) { 20 | const vueFile = component.files.find(f => f.ext === '.vue'); 21 | const contents = vueFile ? await vueFile.getContents() : null; 22 | if (contents) { 23 | const root = parse(contents); 24 | const tpl = root.querySelector('template'); 25 | return tpl ? stripIndent(tpl.innerHTML) : null; 26 | } 27 | return null; 28 | } 29 | }; 30 | }; 31 | 32 | function toVueAttr(value, key) { 33 | if (isPlainObject(value) || Array.isArray(value)) { 34 | return `:${key}="${JSON5.stringify(value, { quote: "'" })}"`; 35 | } 36 | if (isString(value)) { 37 | return `${key}="${value}"`; 38 | } 39 | // TODO: handle Date instances and other objects... 40 | return `:${key}="${value}"`; 41 | } 42 | -------------------------------------------------------------------------------- /packages/app/README.md: -------------------------------------------------------------------------------- 1 | ## @frctl/fractalite-app 2 | 3 | A web framework for building Fractalite-based applications. 4 | 5 | Used to create the main Fractalite styleguide UI. 6 | -------------------------------------------------------------------------------- /packages/app/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const jsonErrors = require('koa-json-error'); 3 | const prettier = require('prettier'); 4 | const cleanStack = require('clean-stacktrace'); 5 | const relativePaths = require('clean-stacktrace-relative-paths'); 6 | const { getComponent, isFile } = require('@frctl/fractalite-core/helpers'); 7 | const serveStatic = require('./src/serve'); 8 | const App = require('./src/app'); 9 | 10 | module.exports = function(compiler, opts = {}) { 11 | const app = new App(compiler, opts); 12 | const { router, views, socket } = app; 13 | 14 | app.use(jsonErrors()); 15 | 16 | app.use(async (ctx, next) => { 17 | if (ctx.path.startsWith('/api')) return next(); 18 | try { 19 | await next(); 20 | const status = ctx.status || 404; 21 | if (status === 404) { 22 | ctx.throw(404, 'Page not found'); 23 | } 24 | } catch (err) { 25 | ctx.error = err; 26 | ctx.state.error = err; 27 | err.path = ctx.path; 28 | ctx.status = err.status || 500; 29 | ctx.app.emit('error', err, ctx); 30 | try { 31 | return ctx.render('error'); 32 | } catch (renderError) { 33 | ctx.body = err; 34 | } 35 | } 36 | }); 37 | 38 | app.use(async (ctx, next) => { 39 | try { 40 | await next(); 41 | } catch (err) { 42 | err.stack = cleanStack(err.stack, relativePaths()); 43 | throw err; 44 | } 45 | }); 46 | 47 | app.on('error', err => 48 | socket.broadcast('err', { 49 | name: err.name, 50 | message: err.message, 51 | stack: cleanStack(err.stack, relativePaths()), 52 | status: err.status 53 | }) 54 | ); 55 | 56 | app.on('state.updated', (...results) => socket.broadcast('state.updated')); 57 | 58 | /* 59 | * View rendering middleware 60 | */ 61 | app.use((ctx, next) => { 62 | ctx.response.render = async function(path, locals = {}, opts) { 63 | const state = { ...ctx.state, ...locals }; 64 | ctx.type = ctx.type || 'text/html'; 65 | ctx.body = await views.renderAsync(path, state, opts); 66 | }; 67 | 68 | ctx.response.renderString = async function(str, locals = {}, opts) { 69 | const state = { ...ctx.state, ...locals }; 70 | ctx.type = ctx.type || 'text/html'; 71 | ctx.body = await views.renderStringAsync(str, state, opts); 72 | }; 73 | 74 | ctx.loadView = path => views.getTemplateAsync(path); 75 | 76 | ctx.render = ctx.response.render; 77 | ctx.renderString = ctx.response.renderString; 78 | 79 | return next(); 80 | }); 81 | 82 | /* 83 | * File sending middleware 84 | */ 85 | app.use((ctx, next) => { 86 | ctx.response.sendFile = async function(file) { 87 | if (!isFile(file)) { 88 | throw new Error('Only Files can be sent'); 89 | } 90 | const contents = await file.getContents(); 91 | ctx.lastModified = file.stats.mtime; 92 | ctx.length = contents.length; 93 | ctx.type = file.ext; 94 | ctx.type = ctx.response.type || 'text/plain'; 95 | if (app.mode === 'develop') { 96 | ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate'); 97 | ctx.set('Pragma', 'no-cache'); 98 | ctx.set('Expires', 0); 99 | } 100 | ctx.body = fs.createReadStream(file.path); 101 | }; 102 | 103 | ctx.sendFile = ctx.response.sendFile; 104 | return next(); 105 | }); 106 | 107 | /* 108 | * Middleware to add useful properties to state object 109 | * so that they will be available to templates. 110 | */ 111 | app.use((ctx, next) => { 112 | ctx.state.mode = ctx.mode; 113 | ctx.state.error = ctx.error; 114 | ctx.state.request = { 115 | params: ctx.params, 116 | path: ctx.path, 117 | url: ctx.url, 118 | route: ctx.route 119 | }; 120 | const compilerState = app.compiler.getState(); 121 | Object.keys(compilerState).forEach(key => { 122 | ctx[key] = compilerState[key]; 123 | ctx.state[key] = ctx[key]; 124 | }); 125 | return next(); 126 | }); 127 | 128 | /* 129 | * Add a route parameter loader for the :component param. 130 | */ 131 | router.param('component', (name, ctx, next) => { 132 | if (!name) return next(); 133 | try { 134 | ctx.component = getComponent(ctx.state, name, true); 135 | } catch (err) { 136 | ctx.throw(404, err); 137 | } 138 | ctx.state.component = ctx.component; 139 | return next(); 140 | }); 141 | 142 | views.addGlobal('url', (name, params) => app.url(name, params)); 143 | views.addGlobal('resourceUrl', (name, path) => app.resourceUrl(name, path)); 144 | 145 | app.addRoute('file', '/src/:file(.+)', ctx => { 146 | const file = ctx.files.find(f => f.relative === ctx.params.file); 147 | if (file) { 148 | return ctx.sendFile(file); 149 | } 150 | ctx.throw(404, 'File not found'); 151 | }); 152 | 153 | app.addBuilder((state, { copy }) => { 154 | state.files.forEach(file => 155 | copy(file.path, { 156 | name: 'file', 157 | params: { file } 158 | }) 159 | ); 160 | }); 161 | 162 | /* 163 | * Compiler middleware to add url properties to files 164 | */ 165 | app.compiler.use(async (components, next, { files }) => { 166 | await next(); 167 | files.forEach(file => { 168 | file.url = app.url('file', { file }); 169 | }); 170 | }); 171 | 172 | app.addRoute('app-css', `/app/assets/bundle.css`, ctx => { 173 | ctx.type = 'text/css'; 174 | ctx.body = prettier.format(app.getCSS(), { parser: 'css' }); 175 | }); 176 | 177 | app.addRoute('app-js', `/app/assets/bundle.js`, ctx => { 178 | ctx.type = 'application/javascript'; 179 | ctx.body = prettier.format(app.getJS(), { parser: 'babel' }); 180 | }); 181 | 182 | app.addBuilder((state, { request }) => request({ name: 'app-css' })); 183 | app.addBuilder((state, { request }) => request({ name: 'app-js' })); 184 | 185 | app.use((ctx, next) => { 186 | ctx.state.scripts = app.getScripts(); 187 | ctx.state.stylesheets = app.getStylesheets(); 188 | if (app.getCSS() !== '') ctx.state.stylesheets.push(app.url('app-css')); 189 | if (app.getJS() !== '') ctx.state.scripts.push(app.url('app-js')); 190 | return next(); 191 | }); 192 | 193 | return app; 194 | }; 195 | 196 | module.exports.App = App; 197 | module.exports.serveStatic = serveStatic; 198 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-app", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@frctl/fractalite-core": "^0.0.0", 6 | "@frctl/fractalite-support": "^0.0.0", 7 | "asyncro": "^3.0.0", 8 | "axios": "^0.18.0", 9 | "clean-stacktrace": "^1.1.0", 10 | "clean-stacktrace-relative-paths": "^1.0.4", 11 | "cp-file": "^6.0.0", 12 | "del": "^3.0.0", 13 | "eventemitter2": "^5.0.1", 14 | "fs-extra": "^7.0.0", 15 | "get-port": "^4.0.0", 16 | "globby": "^8.0.1", 17 | "koa": "^2.5.2", 18 | "koa-compose": "^4.1.0", 19 | "koa-compress": "^3.0.0", 20 | "koa-json-error": "^3.1.2", 21 | "koa-mount": "^3.0.0", 22 | "koa-router": "7.4.0", 23 | "koa-send": "^5.0.0", 24 | "koa-socket-2": "^1.0.17", 25 | "koa-static": "^5.0.0", 26 | "lodash": "^4.17.10", 27 | "nunjucks": "^3.1.3", 28 | "p-throttle": "^3.0.0", 29 | "prettier": "^1.16.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/app/src/builder.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const https = require('https'); 3 | const { outputFile } = require('fs-extra'); 4 | const { map } = require('asyncro'); 5 | const { normalizePath } = require('@frctl/fractalite-support/utils'); 6 | const cpFile = require('cp-file'); 7 | const axios = require('axios'); 8 | const del = require('del'); 9 | const throttle = require('p-throttle'); 10 | 11 | module.exports = function() { 12 | const copyTasks = []; 13 | const requestTasks = []; 14 | const builder = {}; 15 | 16 | builder.addCopyTask = task => { 17 | copyTasks.push(task); 18 | }; 19 | 20 | builder.addRequestTask = task => { 21 | requestTasks.push(task); 22 | }; 23 | 24 | builder.run = async (server, opts = {}) => { 25 | if (!opts.dest) { 26 | throw new Error(`You must specify a build destination`); 27 | } 28 | const dest = normalizePath(opts.dest); 29 | 30 | if (opts.clean === true) { 31 | await del(dest); 32 | } 33 | 34 | const { port } = server.address(); 35 | const protocol = server instanceof https.Server ? 'https' : 'http'; 36 | const address = `${protocol}://${opts.hostname || '127.0.0.1'}:${port}`; 37 | 38 | const copies = map(copyTasks, async task => { 39 | const to = join(dest, task.to); 40 | await cpFile(task.from, to); 41 | return { ...task, to }; 42 | }); 43 | 44 | const throttledRequest = throttle( 45 | async task => { 46 | try { 47 | const to = join(dest, task.to); 48 | const { data } = await axios.get(`${address}${task.from}`); 49 | const contents = typeof data === 'string' ? data : JSON.stringify(data, null, 2); 50 | await outputFile(to, contents); 51 | return { ...task, to }; 52 | } catch (err) { 53 | // TODO: throw more descriptive error with URL 54 | throw err; 55 | } 56 | }, 57 | opts.requestLimit || 100, 58 | 1000 59 | ); 60 | 61 | const requests = map(requestTasks, throttledRequest); 62 | 63 | const [copyResults, requestResults] = await Promise.all([copies, requests]); 64 | 65 | return [...copyResults, ...requestResults]; 66 | }; 67 | 68 | return builder; 69 | }; 70 | -------------------------------------------------------------------------------- /packages/app/src/include-raw.js: -------------------------------------------------------------------------------- 1 | const { SafeString } = require('nunjucks').runtime; 2 | 3 | module.exports = class IncludeRawExtension { 4 | constructor() { 5 | this.tags = ['includeraw']; 6 | } 7 | 8 | parse(parser, nodes) { 9 | const tok = parser.nextToken(); 10 | const args = parser.parseSignature(null, true); 11 | 12 | parser.advanceAfterBlockEnd(tok.value); 13 | return new nodes.CallExtensionAsync(this, 'run', args, null); 14 | } 15 | 16 | run(ctx, name, ...args) { 17 | const { env } = ctx; 18 | const done = args.pop(); 19 | 20 | env.getTemplate(name, false, (err, tpl) => { 21 | if (err) return done(err); 22 | done(null, new SafeString(tpl.tmplStr)); 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /packages/app/src/mode.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('lodash'); 2 | 3 | module.exports = function(opts) { 4 | const modes = [ 5 | { 6 | mode: 'develop', 7 | hostname: 'localhost', 8 | port: 3000, 9 | cache: false, 10 | paths: {}, 11 | permalinks: { 12 | prefix: null, 13 | indexes: false, 14 | ext: false 15 | } 16 | }, 17 | { 18 | mode: 'build', 19 | dest: null, 20 | clean: false, 21 | gitignore: false, 22 | hostname: 'localhost', 23 | cache: true, 24 | paths: {}, 25 | permalinks: { 26 | prefix: null, 27 | indexes: true, 28 | ext: '.html' 29 | } 30 | } 31 | ]; 32 | 33 | opts = typeof opts === 'string' ? { opts } : opts; 34 | const mode = modes.find(m => m.mode === opts.mode); 35 | if (!mode) { 36 | throw new Error(`Invalid mode '${opts.mode}'`); 37 | } 38 | 39 | const modeOpts = merge(mode, opts, { 40 | toString() { 41 | return this.mode; 42 | } 43 | }); 44 | 45 | mode.paths = merge({}, mode.permalinks, mode.paths); 46 | mode.name = mode.mode; 47 | 48 | return modeOpts; 49 | }; 50 | -------------------------------------------------------------------------------- /packages/app/src/resources.js: -------------------------------------------------------------------------------- 1 | const { join, relative } = require('path'); 2 | const { flatten } = require('lodash'); 3 | const serve = require('koa-static'); 4 | const mount = require('koa-mount'); 5 | const compose = require('koa-compose'); 6 | const globby = require('globby'); 7 | const { normalizePath } = require('@frctl/fractalite-support/utils'); 8 | 9 | module.exports = function(opts = {}) { 10 | const sources = []; 11 | 12 | function routes() { 13 | return compose( 14 | sources.map(source => { 15 | const server = serve(source.path); 16 | return source.mount ? mount(source.mount, server) : server; 17 | }) 18 | ); 19 | } 20 | 21 | async function list() { 22 | // TODO: list all assets 23 | return flatten( 24 | await Promise.all( 25 | sources.map(async source => { 26 | const files = await globby([source.path, '!**/node_modules'], { 27 | onlyFiles: true, 28 | gitignore: false 29 | }); 30 | return files.map(file => { 31 | const relPath = relative(source.path, file); 32 | return { 33 | path: file, 34 | url: url(`${source.name}:${relPath}`) 35 | }; 36 | }); 37 | }) 38 | ) 39 | ); 40 | } 41 | 42 | function add(name, path, mount) { 43 | sources.push({ name, path: normalizePath(path), mount }); 44 | } 45 | 46 | function url(path) { 47 | if (path.indexOf('://') !== -1 || path.startsWith('/')) { 48 | return path; // ignore external or absolute paths 49 | } 50 | const [sourceName, relativePath] = path.split(':'); 51 | if (relativePath) { 52 | const source = sources.find(src => src.name === sourceName); 53 | if (!source) { 54 | throw new Error(`'${sourceName}' is not a recognised static asset source`); 55 | } 56 | return join(source.mount || '/', relativePath); 57 | } 58 | return path; 59 | } 60 | 61 | return { add, routes, url, list }; 62 | }; 63 | -------------------------------------------------------------------------------- /packages/app/src/router.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router'); 2 | 3 | module.exports = function() { 4 | const router = new Router(); 5 | 6 | router.add = function(route) { 7 | const { name, url, handler } = route; 8 | 9 | // Decorate the callback 10 | const callback = function(ctx, next) { 11 | ctx.route = route; 12 | return handler ? handler(ctx, next) : next(); 13 | }; 14 | 15 | router.get(name, url, callback); 16 | }; 17 | 18 | return router; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/app/src/serve.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const serve = require('koa-static'); 3 | const send = require('koa-send'); 4 | const { normalizePath } = require('@frctl/fractalite-support/utils'); 5 | 6 | module.exports = function(dir, port = 0) { 7 | const serveOpts = { 8 | root: normalizePath(dir) 9 | }; 10 | 11 | const app = new Koa(); 12 | app.use(serve(serveOpts.root, serveOpts)); 13 | app.use(ctx => send(ctx, 'index.html', serveOpts)); // File not found, serve index 14 | 15 | return new Promise((resolve, reject) => { 16 | const httpServer = app.listen(port, err => (err ? reject(err) : resolve(httpServer))); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/app/src/views-loader.js: -------------------------------------------------------------------------------- 1 | const { extname } = require('path'); 2 | const { FileSystemLoader } = require('nunjucks'); 3 | 4 | class ViewLoader extends FileSystemLoader { 5 | getSource(name) { 6 | if (!extname(name)) { 7 | name += '.njk'; 8 | } 9 | return super.getSource(name); 10 | } 11 | } 12 | 13 | module.exports = ViewLoader; 14 | -------------------------------------------------------------------------------- /packages/app/src/views.js: -------------------------------------------------------------------------------- 1 | const { normalize } = require('path'); 2 | const { Environment } = require('nunjucks'); 3 | const { SafeString } = require('nunjucks').runtime; 4 | const { each, merge, isPlainObject } = require('lodash'); 5 | const ViewLoader = require('./views-loader'); 6 | const IncludeRawExtension = require('./include-raw'); 7 | 8 | const defaults = { 9 | paths: [], 10 | filters: {}, 11 | globals: {}, 12 | extensions: {}, 13 | opts: { 14 | autoescape: true, 15 | throwOnUndefined: false, 16 | trimBlocks: false, 17 | lstripBlocks: false, 18 | noCache: true 19 | } 20 | }; 21 | 22 | module.exports = function(config = {}) { 23 | config = merge({}, defaults, config); 24 | 25 | const loader = new ViewLoader(config.paths, { 26 | noCache: !config.cache 27 | }); 28 | 29 | const njk = new Environment(loader, config.opts); 30 | 31 | const oldAddFilter = njk.addFilter; 32 | njk.addFilter = function(name, filter) { 33 | const wrapped = function(...args) { 34 | const done = args.pop(); 35 | Promise.resolve(filter.bind(this)(...args)) 36 | .then(result => done(null, result)) 37 | .catch(err => done(err)); 38 | }; 39 | oldAddFilter.call(njk, name, wrapped, true); 40 | return njk; 41 | }; 42 | 43 | each(config.filters, (filter, name) => njk.addFilter(name, filter)); 44 | each(config.globals, (val, name) => njk.addGlobal(name, val)); 45 | each(config.extensions, (ext, name) => njk.addExtension(name, ext)); 46 | 47 | njk.loader = loader; 48 | 49 | njk.addPath = function(path) { 50 | if (loader.searchPaths.length === 1 && loader.searchPaths[0] === '.') { 51 | loader.searchPaths[0] = normalize(path); 52 | } else { 53 | loader.searchPaths.unshift(path); 54 | } 55 | }; 56 | 57 | njk.mergeGlobal = function(name, val) { 58 | let current; 59 | try { 60 | current = njk.getGlobal(name); 61 | } catch (err) { 62 | // ignore 63 | } 64 | if (isPlainObject(current)) { 65 | return njk.addGlobal(name, merge({}, current, val)); 66 | } 67 | return njk.addGlobal(name, val); 68 | }; 69 | 70 | ['render', 'renderString', 'getTemplate'].forEach(method => { 71 | njk[`${method}Async`] = function(...args) { 72 | args = args.filter(arg => arg !== undefined); 73 | return new Promise((resolve, reject) => { 74 | njk[method](...args, (err, result) => { 75 | if (err) { 76 | return reject(err); 77 | } 78 | resolve(result); 79 | }); 80 | }); 81 | }; 82 | }); 83 | 84 | njk.addFilter('await', async (promise, catchError) => { 85 | try { 86 | return await promise; 87 | } catch (err) { 88 | if (catchError) { 89 | return err; 90 | } 91 | } 92 | }); 93 | 94 | njk.SafeString = SafeString; 95 | 96 | njk.addExtension('includeraw', new IncludeRawExtension()); 97 | 98 | return njk; 99 | }; 100 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | ## @frctl/fractalite-core 2 | 3 | Core compiler, middleware and renderer modules for the Fractalite prototype. 4 | -------------------------------------------------------------------------------- /packages/core/helpers.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/helpers'); 2 | -------------------------------------------------------------------------------- /packages/core/index.js: -------------------------------------------------------------------------------- 1 | const { get, isString } = require('lodash'); 2 | const { normalizeSrc } = require('@frctl/fractalite-support/utils'); 3 | const createCompiler = require('./src/compiler'); 4 | const coreMiddleware = require('./src/middleware'); 5 | 6 | function init(config = {}) { 7 | if (isString(config)) { 8 | config = { 9 | src: config, 10 | watch: {} 11 | }; 12 | } 13 | 14 | const normalizedConfig = { 15 | src: normalizeSrc(config.src || {}), 16 | watch: normalizeSrc(config.watch || {}), 17 | opts: config.opts || {} 18 | }; 19 | 20 | const compiler = createCompiler(normalizedConfig); 21 | 22 | coreMiddleware.forEach(({ key, handler }) => { 23 | const opts = get(normalizedConfig, `opts.${key}`, {}); 24 | compiler.use(handler(opts)); 25 | }); 26 | 27 | return compiler; 28 | } 29 | 30 | module.exports = init; 31 | module.exports.createCompiler = init; 32 | module.exports.createAdapter = require('./src/create-adapter'); 33 | module.exports.htmlAdapter = require('./src/html-adapter'); 34 | module.exports.File = require('./src/entities/file'); 35 | module.exports.Component = require('./src/entities/component'); 36 | module.exports.Entity = require('./src/entities/entity'); 37 | module.exports.middleware = require('./src/middleware'); 38 | module.exports.read = require('./src/read'); 39 | module.exports.watch = require('chokidar').watch; 40 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-core", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@frctl/fractalite-support": "^0.0.0", 6 | "deep-freeze": "^0.0.1", 7 | "enhanced-require": "^0.5.0-beta6", 8 | "lodash": "^4.17.10", 9 | "common-tags": "^1.8.0", 10 | "chokidar": "^2.0.4", 11 | "fs-extra": "^7.0.0", 12 | "lodash": "^4.17.10", 13 | "multimatch": "^2.1.0", 14 | "pupa": "^1.0.0", 15 | "glob-base": "^0.3.0", 16 | "globby": "^8.0.1", 17 | "slash": "^2.0.0", 18 | "asyncro": "^3.0.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/core/src/compile-components.js: -------------------------------------------------------------------------------- 1 | const { orderBy, difference, compact, clone } = require('lodash'); 2 | const Component = require('./entities/component'); 3 | const defaultComponentMatcher = require('./component-matcher'); 4 | 5 | module.exports = async function(files, opts = {}) { 6 | files = orderBy(files, 'path.length', 'desc'); // Work from deepest to shallowest 7 | const dirs = files.filter(f => f.stats.isDirectory()); 8 | let unusedFiles = files.filter(f => f.stats.isFile()); 9 | 10 | const matcher = opts.matcher || defaultComponentMatcher; 11 | 12 | const components = dirs.map(dir => { 13 | const children = unusedFiles.filter(f => f.path.startsWith(`${dir.path}/`)); 14 | 15 | if (!matcher(dir, children, opts)) { 16 | return null; 17 | } 18 | 19 | unusedFiles = difference(unusedFiles, children); // Remove files from future consideration 20 | 21 | return new Component({ 22 | name: dir.name, 23 | path: dir.path, 24 | relative: dir.relative, 25 | root: clone(dir), 26 | scenarios: [], 27 | files: children 28 | }); 29 | }); 30 | 31 | return compact(components); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/core/src/compile-components.test.js: -------------------------------------------------------------------------------- 1 | const { resolve, join, basename } = require('path'); 2 | const { readdirSync, statSync } = require('fs'); 3 | const readFiles = require('./read'); 4 | const readComponents = require('./compile-components'); 5 | const Component = require('./entities/component'); 6 | 7 | const componentsPath = resolve(__dirname, '../test/fixtures/components'); 8 | 9 | it('Matches all the components in a directory', async () => { 10 | const files = await readFiles(componentsPath); 11 | const components = await readComponents(files); 12 | const componentDirs = recursiveReadDirs(componentsPath).filter(path => basename(path).startsWith('@')); 13 | expect(components.length).toEqual(componentDirs.length); 14 | }); 15 | 16 | it('Retuns an array of Component instances', async () => { 17 | const files = await readFiles(componentsPath); 18 | for (const component of await readComponents(files, componentsPath)) { 19 | expect(component).toBeInstanceOf(Component); 20 | } 21 | }); 22 | 23 | it('Supports supplying custom matchers', async () => { 24 | const matcher = (dir, children) => { 25 | return dir.name.startsWith('component-') && children.length > 0; 26 | }; 27 | const files = await readFiles(resolve(__dirname, '../test/fixtures/custom-matcher')); 28 | const components = await readComponents(files, { matcher }); 29 | expect(components.length).toEqual(1); 30 | }); 31 | 32 | function recursiveReadDirs(path) { 33 | let list = []; 34 | const files = readdirSync(path); 35 | if (!files.length) { 36 | return list; 37 | } 38 | files.forEach(function(file) { 39 | const filePath = join(path, file); 40 | const stats = statSync(filePath); 41 | if (stats.isDirectory()) { 42 | const children = readdirSync(filePath); 43 | if (children.length) { 44 | list.push(filePath); 45 | list = list.concat(recursiveReadDirs(filePath)); 46 | } 47 | } 48 | }); 49 | return list; 50 | } 51 | -------------------------------------------------------------------------------- /packages/core/src/compiler.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const { isFunction, debounce } = require('lodash'); 3 | const { watch } = require('chokidar'); 4 | const { normalizeSrc } = require('@frctl/fractalite-support/utils'); 5 | const compose = require('./compose'); 6 | const createState = require('./state'); 7 | const readFiles = require('./read'); 8 | const compileComponents = require('./compile-components'); 9 | 10 | module.exports = function(config = {}) { 11 | const emitter = new EventEmitter(); 12 | const middlewares = []; 13 | const compiler = {}; 14 | const watchCallbacks = []; 15 | const parseSrc = normalizeSrc(config.src || {}); 16 | const watchSrc = normalizeSrc(config.watch || {}); 17 | 18 | const state = createState(); 19 | 20 | Object.defineProperty(compiler, 'src', { 21 | get() { 22 | return { ...parseSrc, watch: { ...watchSrc } }; 23 | }, 24 | enumerable: true 25 | }); 26 | 27 | compiler.use = function(plugin) { 28 | middlewares.push(plugin); 29 | return compiler; 30 | }; 31 | 32 | compiler.getState = () => { 33 | return state; 34 | }; 35 | 36 | compiler.on = (event, listener) => { 37 | emitter.on(event, listener); 38 | return compiler; 39 | }; 40 | 41 | compiler.run = async function() { 42 | emitter.emit('start'); 43 | const ctx = {}; 44 | const logs = []; 45 | const hrStart = process.hrtime(); 46 | const { paths, opts } = parseSrc; 47 | 48 | let files = await readFiles(paths, { 49 | onlyFiles: false, 50 | gitignore: Boolean(opts.gitignore) 51 | }); 52 | 53 | ctx.files = files; 54 | ctx.log = (message, level) => { 55 | logs.push({ level, message }); 56 | }; 57 | 58 | const components = await compileComponents(files, opts); 59 | files = files.filter(f => f.stats.isFile()); 60 | const applyPlugins = compose( 61 | middlewares, 62 | ctx 63 | ); 64 | await applyPlugins(components); 65 | state.update({ components, files }); 66 | 67 | const info = { 68 | logs, 69 | time: process.hrtime(hrStart) 70 | }; 71 | 72 | emitter.emit('finish', state, info); 73 | return { state, info }; 74 | }; 75 | 76 | compiler.watch = function(callback) { 77 | const watchers = []; 78 | const paths = [...parseSrc.paths, ...watchSrc.paths]; 79 | const opts = Object.assign({ ignoreInitial: true }, watchSrc.opts); 80 | 81 | const watcher = watch(paths, opts) 82 | .on('all', watchHandler(['add', 'unlink', 'change', 'addDir', 'unlinkDir'])) 83 | .on('error', err => watchCallbacks.forEach(cb => cb(err))); 84 | 85 | watchers.push(watcher); 86 | 87 | compiler.watch = function(callback) { 88 | if (isFunction(callback)) { 89 | watchCallbacks.push(callback); 90 | } 91 | }; 92 | 93 | compiler.watch(callback); 94 | return watchers; 95 | }; 96 | 97 | function watchHandler(parseEvents) { 98 | let lastResult = null; 99 | return debounce(async (event, path) => { 100 | try { 101 | // Only re-parse for 'primary' events, otherwise just notify of changes 102 | lastResult = parseEvents.includes(event) ? await compiler.run() : lastResult; 103 | const info = { path, event, ...lastResult.info }; 104 | watchCallbacks.forEach(cb => cb(null, lastResult.state, info)); 105 | } catch (err) { 106 | watchCallbacks.forEach(cb => cb(err)); 107 | } 108 | }, 300); 109 | } 110 | 111 | return compiler; 112 | }; 113 | -------------------------------------------------------------------------------- /packages/core/src/compiler.test.js: -------------------------------------------------------------------------------- 1 | const createCompiler = require('../.'); 2 | 3 | describe('Compiler middleware', () => { 4 | it('Is called when running the compiler', async () => { 5 | let called = false; 6 | const mw = () => { 7 | called = true; 8 | }; 9 | const compiler = createCompiler(); 10 | compiler.use(mw); 11 | await compiler.run(); 12 | expect(called).toBe(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/core/src/component-matcher.js: -------------------------------------------------------------------------------- 1 | module.exports = function(dir, children, opts = {}) { 2 | if (children.length === 0) { 3 | return false; // Components cannot be empty directories 4 | } 5 | const matchFiles = opts.matchFiles || ['package.json', 'view.', 'config.']; 6 | for (const file of children) { 7 | // Check to see if any of the files within the directory 8 | // match any of the key component file names. 9 | for (const matcher of matchFiles) { 10 | if (file.basename.indexOf(matcher) > -1) { 11 | return true; 12 | } 13 | } 14 | } 15 | return false; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/core/src/compose.js: -------------------------------------------------------------------------------- 1 | // Adapted from Koa Compose: https://github.com/koajs/compose 2 | 3 | module.exports = function compose(middleware, ctx = {}) { 4 | if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!'); 5 | for (const fn of middleware) { 6 | if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!'); 7 | } 8 | 9 | return function(context, next) { 10 | // Last called middleware # 11 | let index = -1; 12 | return dispatch(0); 13 | async function dispatch(i) { 14 | if (i <= index) return Promise.reject(new Error('next() called multiple times')); 15 | index = i; 16 | let fn = middleware[i]; 17 | if (i === middleware.length) fn = next; 18 | if (!fn) return Promise.resolve(context); 19 | try { 20 | let called = false; 21 | const trigger = dispatch.bind(null, i + 1); 22 | const wrappedTrigger = () => { 23 | called = true; 24 | return trigger(); 25 | }; 26 | const rawResult = fn(context, wrappedTrigger, ctx); 27 | const result = Array.isArray(rawResult) ? await Promise.all(rawResult) : await Promise.resolve(rawResult); 28 | if (!called) { 29 | await trigger(); 30 | } 31 | return result; 32 | } catch (err) { 33 | return Promise.reject(err); 34 | } 35 | } 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /packages/core/src/create-adapter.js: -------------------------------------------------------------------------------- 1 | const { isFunction } = require('lodash'); 2 | const { map } = require('asyncro'); 3 | const { getComponent } = require('./helpers'); 4 | 5 | module.exports = function(state, adapter) { 6 | adapter = adapter || (() => ''); 7 | const renderComponent = isFunction(adapter) ? adapter : adapter.render; 8 | 9 | if (!isFunction(renderComponent)) { 10 | throw new Error(`Adapter must provide a component render function`); 11 | } 12 | 13 | function render(component, props) { 14 | component = getComponent(state, component, true); 15 | return renderComponent(component, props, { ...state, component }); 16 | } 17 | 18 | function renderToStaticMarkup(component, props) { 19 | if (isFunction(adapter.renderToStaticMarkup)) { 20 | return adapter.renderToStaticMarkup(component, props, { ...state, component }); 21 | } 22 | return render(component, props); 23 | } 24 | 25 | function renderAll(target, props = []) { 26 | if (Array.isArray(props)) { 27 | return map(props, p => render(target, p)); 28 | } 29 | return Promise.all([render(target, props)]); 30 | } 31 | 32 | function renderAllToStaticMarkup(target, props = []) { 33 | if (Array.isArray(props)) { 34 | return map(props, p => renderToStaticMarkup(target, p)); 35 | } 36 | return Promise.all([renderToStaticMarkup(target, props)]); 37 | } 38 | 39 | function getTemplateString(component) { 40 | if (isFunction(adapter.getTemplateString)) { 41 | return adapter.getTemplateString(component); 42 | } 43 | return null; 44 | } 45 | 46 | function getPreviewString(content) { 47 | if (isFunction(adapter.getPreviewString)) { 48 | return adapter.getPreviewString(content, state); 49 | } 50 | return content; 51 | } 52 | 53 | return { render, renderAll, renderToStaticMarkup, renderAllToStaticMarkup, getPreviewString, getTemplateString }; 54 | }; 55 | -------------------------------------------------------------------------------- /packages/core/src/entities/component.js: -------------------------------------------------------------------------------- 1 | const pupa = require('pupa'); 2 | const multimatch = require('multimatch'); 3 | const Entity = require('./entity'); 4 | 5 | class Component extends Entity { 6 | constructor(props) { 7 | if (!props.name) { 8 | throw new Error(`Components must have a 'name' property defined`); 9 | } 10 | super(props); 11 | } 12 | 13 | get handle() { 14 | return this._handle || this.name; 15 | } 16 | 17 | get treePath() { 18 | return this.relative; 19 | } 20 | 21 | get isComponent() { 22 | return true; 23 | } 24 | 25 | matchFiles(matcher) { 26 | matcher = [].concat(matcher).map(match => pupa(match, this)); 27 | return this.files.filter(file => multimatch(file.relative, matcher, { matchBase: true }).length); 28 | } 29 | 30 | static isComponent(item) { 31 | return item instanceof Component; 32 | } 33 | } 34 | 35 | module.exports = Component; 36 | -------------------------------------------------------------------------------- /packages/core/src/entities/entity.js: -------------------------------------------------------------------------------- 1 | const { assign } = require('@frctl/fractalite-support/utils'); 2 | 3 | class Entity { 4 | constructor(props) { 5 | assign(this, props); 6 | } 7 | 8 | set handle(str) { 9 | this._handle = str; 10 | } 11 | 12 | get handle() { 13 | return this._handle; 14 | } 15 | 16 | toString() { 17 | return this.handle; 18 | } 19 | 20 | static isEntity(item) { 21 | return item instanceof Entity; 22 | } 23 | } 24 | 25 | module.exports = Entity; 26 | -------------------------------------------------------------------------------- /packages/core/src/entities/file.js: -------------------------------------------------------------------------------- 1 | const { readFile, readFileSync } = require('fs-extra'); 2 | const Entity = require('./entity'); 3 | 4 | class File extends Entity { 5 | constructor(props) { 6 | // TODO: better validation 7 | if (!props.relative) { 8 | throw new Error(`Files must have a '.relative' property defined`); 9 | } 10 | if (!props.path) { 11 | throw new Error(`Files must have a '.path' property defined`); 12 | } 13 | super(props); 14 | } 15 | 16 | get handle() { 17 | return this._handle || this.relative; 18 | } 19 | 20 | get treePath() { 21 | return this.relative; 22 | } 23 | 24 | get size() { 25 | const suffixes = ['B', 'KB', 'MB', 'GB', 'TB']; 26 | const { size } = this.stats; 27 | const i = Math.floor(Math.log(size) / Math.log(1024)); 28 | const displaySize = Number((size / 1024 ** i).toFixed(2)); 29 | return `${displaySize} ${suffixes[i]}`; 30 | } 31 | 32 | get isFile() { 33 | return true; 34 | } 35 | 36 | setContents(contents) { 37 | this._contents = contents; 38 | } 39 | 40 | getContents() { 41 | return this._contents || readFile(this.path, 'utf-8'); 42 | } 43 | 44 | getContentsSync() { 45 | return this._contents || readFileSync(this.path, 'utf-8'); 46 | } 47 | 48 | toString() { 49 | return this.relative; 50 | } 51 | 52 | static isFile(item) { 53 | return item instanceof File; 54 | } 55 | } 56 | 57 | module.exports = File; 58 | -------------------------------------------------------------------------------- /packages/core/src/helpers.js: -------------------------------------------------------------------------------- 1 | const { find } = require('lodash'); 2 | const { defaultsDeep } = require('@frctl/fractalite-support/utils'); 3 | const Component = require('./entities/component'); 4 | const File = require('./entities/file'); 5 | 6 | function getComponent({ components }, target, throwOnNotFound = false) { 7 | if (isComponent(target)) return target; 8 | const component = find(components, { name: target }); 9 | if (!component && throwOnNotFound) { 10 | throw new Error(`Component '${target}' was not found`); 11 | } 12 | return component; 13 | } 14 | 15 | function getScenario(component, name, throwOnNotFound = false) { 16 | const scenario = find(component.scenarios, { name }); 17 | if (!scenario && throwOnNotFound) { 18 | throw new Error(`Scenario '${name}' for component '${component.name}' was not found`); 19 | } 20 | return scenario; 21 | } 22 | 23 | function getScenarioOrDefault(component, name, throwOnNotFound = false) { 24 | if (!name) { 25 | return component.scenarios[0]; 26 | } 27 | const scenario = getScenario(component, name, throwOnNotFound); 28 | return scenario ? scenario : component.scenarios[0]; 29 | } 30 | 31 | function getFile({ files }, target, throwOnNotFound = false) { 32 | if (isFile(target)) return target; 33 | const file = find(files, { handle: target }); 34 | if (!file && throwOnNotFound) { 35 | throw new Error(`File '${target}' was not found`); 36 | } 37 | return file; 38 | } 39 | 40 | function mergeProps(state, ...args) { 41 | // TODO: allow for resolving of string componet/scenario references? 42 | return defaultsDeep(...args.reverse()); 43 | } 44 | 45 | function isComponent(component) { 46 | return Component.isComponent(component); 47 | } 48 | 49 | function isFile(file) { 50 | return File.isFile(file); 51 | } 52 | 53 | module.exports = { 54 | getComponent, 55 | getScenario, 56 | getScenarioOrDefault, 57 | getFile, 58 | mergeProps, 59 | isComponent, 60 | isFile 61 | }; 62 | -------------------------------------------------------------------------------- /packages/core/src/html-adapter.js: -------------------------------------------------------------------------------- 1 | const pupa = require('pupa'); 2 | 3 | module.exports = function(app, compiler) { 4 | const views = ['view.html', '{name}.view.html']; 5 | 6 | compiler.use(components => { 7 | components.forEach(component => { 8 | const fragments = component.matchFiles(views); 9 | fragments.forEach(file => { 10 | file.isHTMLFragment = true; 11 | }); 12 | }); 13 | }); 14 | 15 | return { 16 | views, 17 | async render(component, props, state) { 18 | const view = component.matchFiles(views)[0]; 19 | if (view) { 20 | return pupa(await view.getContents(), props); 21 | } 22 | throw new Error(`Could not render component - no view file found`); 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/core/src/middleware/config.js: -------------------------------------------------------------------------------- 1 | const { get, isFunction } = require('lodash'); 2 | const deepFreeze = require('deep-freeze'); 3 | const rewire = require('enhanced-require'); 4 | const pupa = require('pupa'); 5 | const { map } = require('asyncro'); 6 | const { defaultsDeep } = require('@frctl/fractalite-support/utils'); 7 | 8 | module.exports = function(opts = {}) { 9 | opts = defaultsDeep(opts, { 10 | defaults: {}, 11 | finder: { 12 | searchPlaces: ['package.json', '{name}.config.js', '{name}.config.json', '{name}.config.yml', 'config.js', 'config.json', 'config.yml'], 13 | packageProp: 'fractal' 14 | }, 15 | resolve: { 16 | '~': process.cwd() 17 | }, 18 | process: config => config 19 | }); 20 | 21 | return async function configMiddleware(components) { 22 | const load = rewire(module, { 23 | recursive: true, 24 | resolve: { 25 | alias: opts.resolve || {} 26 | } 27 | }); 28 | const cosmiconfig = load('cosmiconfig'); 29 | 30 | await map(components, async component => { 31 | const { path, name } = component.root; 32 | const finderOpts = opts.finder || {}; 33 | const cosmiOpts = Object.assign({}, finderOpts, { 34 | stopDir: path, 35 | searchPlaces: get(finderOpts, 'searchPlaces', []).map(path => pupa(path, component.root)) 36 | }); 37 | const finder = cosmiconfig(name, cosmiOpts); 38 | const searchResult = await finder.search(path); 39 | let config = searchResult ? searchResult.config : {}; 40 | if (searchResult) { 41 | component.configFile = component.files.find(file => file.path === searchResult.filepath); 42 | if (isFunction(config)) { 43 | config = await config(components); 44 | } 45 | } else { 46 | // This.debug(`No config file found for component '${component.root.relative}'`); 47 | } 48 | config = defaultsDeep(config, opts.defaults || {}); 49 | component.config = deepFreeze(opts.process(config)); 50 | return component; 51 | }); 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /packages/core/src/middleware/files.js: -------------------------------------------------------------------------------- 1 | const { relative } = require('path'); 2 | const slash = require('slash'); 3 | 4 | module.exports = function() { 5 | return function filesMiddleware(components) { 6 | components.forEach(component => { 7 | component.files = component.files.map(file => { 8 | file.componentPath = slash(relative(component.root.path, file.path)); 9 | Object.defineProperty(file, 'handle', { 10 | get() { 11 | return `${component.name}/${file.componentPath}`; 12 | }, 13 | set(value) { 14 | file._handle = value; 15 | } 16 | }); 17 | return file; 18 | }); 19 | }); 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/core/src/middleware/index.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | key: 'components.opts.files', 4 | handler: require('./files') 5 | }, 6 | { 7 | key: 'components.opts.config', 8 | handler: require('./config') 9 | }, 10 | { 11 | key: 'components.opts.name', 12 | handler: require('./name') 13 | }, 14 | { 15 | key: 'components.opts.label', 16 | handler: require('./label') 17 | }, 18 | { 19 | key: 'components.opts.scenarios', 20 | handler: require('./scenarios') 21 | } 22 | ]; 23 | -------------------------------------------------------------------------------- /packages/core/src/middleware/label.js: -------------------------------------------------------------------------------- 1 | const { titlize } = require('@frctl/fractalite-support/utils'); 2 | 3 | module.exports = function() { 4 | return function labelMiddlware(components) { 5 | components.forEach(component => { 6 | const config = component.config || {}; 7 | component.label = config.label || titlize(config.name || component.name); 8 | }); 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/core/src/middleware/name.js: -------------------------------------------------------------------------------- 1 | const { slugify, normalizeName } = require('@frctl/fractalite-support/utils'); 2 | 3 | module.exports = function() { 4 | return function nameMiddleware(components) { 5 | components.forEach(component => { 6 | const config = component.config || {}; 7 | component.name = slugify(normalizeName(config.name || component.name)); 8 | }); 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/core/src/middleware/scenarios.js: -------------------------------------------------------------------------------- 1 | const { titlize, slugify } = require('@frctl/fractalite-support/utils'); 2 | const deepFreeze = require('deep-freeze'); 3 | 4 | module.exports = function() { 5 | return function scenariosMiddleware(components) { 6 | components.forEach(component => { 7 | const scenarios = component.config.scenarios || [{ name: 'default' }]; 8 | let counter = 0; 9 | component.scenarios = scenarios.map(config => { 10 | counter++; 11 | const name = slugify(config.name || `scenario-${counter}`); 12 | const label = config.label || titlize(name); 13 | const props = config.props || {}; 14 | 15 | return { 16 | name, 17 | label, 18 | props, 19 | // Handle: name, 20 | config: deepFreeze(config) 21 | }; 22 | }); 23 | }); 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/core/src/read.js: -------------------------------------------------------------------------------- 1 | const { parse, dirname, relative } = require('path'); 2 | const { flatten } = require('lodash'); 3 | const globby = require('globby'); 4 | const globBase = require('glob-base'); 5 | const { stat } = require('fs-extra'); 6 | const slash = require('slash'); 7 | const { map } = require('asyncro'); 8 | const { toArray, normalizeName } = require('@frctl/fractalite-support/utils'); 9 | const File = require('./entities/file'); 10 | 11 | module.exports = async function(src = [], opts = {}) { 12 | return flatten( 13 | await map(toArray(src), async src => { 14 | const glob = globBase(src); 15 | const root = slash(glob.isGlob ? glob.base : src); 16 | const paths = await globby([src, '!**/node_modules'], { 17 | onlyFiles: opts.onlyFiles || false, 18 | gitignore: opts.gitignore === true 19 | }); 20 | return map(paths, path => toFile(path, root)); 21 | }) 22 | ); 23 | }; 24 | 25 | async function toFile(path, root) { 26 | const { ext, base, name } = parse(path); 27 | const stats = await stat(path); 28 | path = slash(path); 29 | return new File({ 30 | root, 31 | relative: relative(root, path), 32 | path, 33 | basename: base, 34 | dirname: dirname(path), 35 | extname: ext, 36 | ext: ext.toLowerCase(), 37 | stats, 38 | name: normalizeName(name) 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /packages/core/src/read.test.js: -------------------------------------------------------------------------------- 1 | const { resolve, join, basename } = require('path'); 2 | const { readdirSync, statSync } = require('fs'); 3 | const readFiles = require('./read'); 4 | const File = require('./entities/file'); 5 | 6 | const componentsPath = resolve(__dirname, '../test/fixtures/components'); 7 | const withIgnoredPath = resolve(__dirname, '../test/fixtures/ignored'); 8 | 9 | it('Recursively reads all the files in a directory', async () => { 10 | const files = await readFiles(componentsPath); 11 | const paths = readRecursive(componentsPath); 12 | expect(files.length).toEqual(paths.length); 13 | }); 14 | 15 | it('Ignores node_modules directories', async () => { 16 | const files = await readFiles(withIgnoredPath); 17 | expect(files.filter(file => file.relative.includes('node_modules')).length).toEqual(0); 18 | }); 19 | 20 | it('Retuns an array of File instances', async () => { 21 | for (const file of await readFiles(componentsPath)) { 22 | expect(file).toBeInstanceOf(File); 23 | } 24 | }); 25 | 26 | function readRecursive(path, opts = {}) { 27 | let list = []; 28 | const files = readdirSync(path); 29 | if (!files.length) { 30 | return list; 31 | } 32 | files.forEach(function(file) { 33 | const filePath = join(path, file); 34 | const stats = statSync(filePath); 35 | if (basename(filePath) !== 'node_modules') { 36 | if (stats.isDirectory()) { 37 | list.push(filePath); 38 | list = list.concat(readRecursive(filePath)); 39 | } else { 40 | list.push(filePath); 41 | } 42 | } 43 | }); 44 | return list; 45 | } 46 | -------------------------------------------------------------------------------- /packages/core/src/state.js: -------------------------------------------------------------------------------- 1 | module.exports = function(initial = {}) { 2 | const state = { 3 | components: [], 4 | files: [] 5 | }; 6 | 7 | state.update = props => { 8 | Object.assign(state, props); 9 | return state; 10 | }; 11 | 12 | state.update(initial); 13 | 14 | return state; 15 | }; 16 | -------------------------------------------------------------------------------- /packages/core/src/state.test.js: -------------------------------------------------------------------------------- 1 | const createState = require('./state'); 2 | 3 | test('exports a function', () => { 4 | expect(createState).toBeInstanceOf(Function); 5 | }); 6 | 7 | test('sets initial state from initialiser object', () => { 8 | const components = [{ name: 'foo' }]; 9 | const state = createState({ components }); 10 | expect(state.components).toBe(components); 11 | }); 12 | 13 | describe('state', () => { 14 | test('has components property', () => { 15 | const state = createState(); 16 | expect(state).toHaveProperty('components'); 17 | expect(state.components).toBeInstanceOf(Array); 18 | }); 19 | 20 | test('has files property', () => { 21 | const state = createState(); 22 | expect(state).toHaveProperty('files'); 23 | expect(state.files).toBeInstanceOf(Array); 24 | }); 25 | 26 | test('can be updated', () => { 27 | const state = createState(); 28 | expect(state.components).toHaveLength(0); 29 | const components = [{ name: 'foo' }]; 30 | state.update({ components }); 31 | expect(state.components).toBe(components); 32 | }); 33 | 34 | test('can have additional properties set', () => { 35 | const state = createState(); 36 | const pages = [{ name: 'foo' }]; 37 | state.update({ pages }); 38 | expect(state).toHaveProperty('pages'); 39 | expect(state.pages).toBe(pages); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/core/test/.gitignore: -------------------------------------------------------------------------------- 1 | !node_modules/ 2 | -------------------------------------------------------------------------------- /packages/core/test/fixtures/components/@standard/standard.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | label: 'Standard Component' 3 | }; 4 | -------------------------------------------------------------------------------- /packages/core/test/fixtures/components/@standard/view.html: -------------------------------------------------------------------------------- 1 |
A component
2 | -------------------------------------------------------------------------------- /packages/core/test/fixtures/components/nested/@yaml/view.html: -------------------------------------------------------------------------------- 1 |
A component
2 | -------------------------------------------------------------------------------- /packages/core/test/fixtures/components/nested/@yaml/yaml.config.yml: -------------------------------------------------------------------------------- 1 | label: 'Component with YAML format configuration' 2 | -------------------------------------------------------------------------------- /packages/core/test/fixtures/custom-matcher/component-standard/view.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frctl/fractalite/8e1dd708265df58921e9061d787e59dd3022d1d6/packages/core/test/fixtures/custom-matcher/component-standard/view.html -------------------------------------------------------------------------------- /packages/core/test/fixtures/ignored/@with-node-modules/node_modules/ignored.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frctl/fractalite/8e1dd708265df58921e9061d787e59dd3022d1d6/packages/core/test/fixtures/ignored/@with-node-modules/node_modules/ignored.js -------------------------------------------------------------------------------- /packages/core/test/fixtures/ignored/@with-node-modules/view.html: -------------------------------------------------------------------------------- 1 | view 2 | -------------------------------------------------------------------------------- /packages/core/test/integration.test.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const createCompiler = require('../.'); 3 | const standardConfig = require('./fixtures/components/@standard/standard.config'); 4 | 5 | it('Reads JS config files', async () => { 6 | const compiler = createCompiler(resolve(__dirname, './fixtures/components')); 7 | const { state } = await compiler.run(); 8 | const standard = state.components.find(c => c.name === 'standard'); 9 | expect(standard.config).toEqual(standardConfig); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/fractalite/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": ["last 2 Chrome versions"] 6 | } 7 | }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /packages/fractalite/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/fractalite/README.md: -------------------------------------------------------------------------------- 1 | ## @frctl/fractalite 2 | 3 | A UI for component-driven development. 4 | 5 | ``` 6 | npm i @frctl/fractalite 7 | ``` 8 | -------------------------------------------------------------------------------- /packages/fractalite/assets/app.js: -------------------------------------------------------------------------------- 1 | import './app.scss'; 2 | import '../src/client/app.js'; 3 | -------------------------------------------------------------------------------- /packages/fractalite/assets/app.scss: -------------------------------------------------------------------------------- 1 | @import 'normalize.css/normalize.css'; 2 | 3 | @import './css/theme'; 4 | @import './css/base'; 5 | @import './css/app'; 6 | @import './css/prose'; 7 | @import './css/brand'; 8 | @import './css/nav'; 9 | @import './css/search'; 10 | @import './css/inspector'; 11 | @import './css/preview'; 12 | @import './css/page'; 13 | @import './css/controls'; 14 | @import './css/tabs'; 15 | @import './css/panels'; 16 | @import './css/split'; 17 | @import './css/error'; 18 | @import './css/highlight'; 19 | @import './css/json-explorer'; 20 | @import './css/source-code'; 21 | @import './css/proptable'; 22 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/app.scss: -------------------------------------------------------------------------------- 1 | .fr-app { 2 | height: 100vh; 3 | width: 100vw; 4 | padding: 0; 5 | 6 | &__container { 7 | background-color: var(--bg-color-light); 8 | display: flex; 9 | width: 100vw; 10 | height: 100vh; 11 | overflow: hidden; 12 | display: none; 13 | &.is-ready { 14 | display: flex; 15 | } 16 | } 17 | 18 | &__sidebar { 19 | width: var(--sidebar-width); 20 | background-color: var(--sidebar-bg-color); 21 | color: var(--sidebar-text-color); 22 | height: 100%; 23 | display: flex; 24 | flex-direction: column; 25 | a { 26 | color: var(--sidebar-link-color); 27 | } 28 | } 29 | 30 | &__brand { 31 | flex: none; 32 | } 33 | 34 | &__sidebar-main { 35 | flex: 1 1 50%; 36 | overflow: auto; 37 | -webkit-overflow-scrolling: touch; 38 | } 39 | 40 | &__navigation { 41 | padding-top: 10px; 42 | padding-bottom: 10px; 43 | } 44 | 45 | &__search { 46 | flex: 0 0 auto; 47 | } 48 | 49 | &__controls { 50 | flex: none; 51 | } 52 | 53 | &__main { 54 | width: calc(100% - var(--sidebar-width)); 55 | height: 100vh; 56 | position: relative; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/base.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *:before, 3 | *:after { 4 | box-sizing: border-box; 5 | } 6 | 7 | html { 8 | font-family: var(--font-family); 9 | width: 100vw; 10 | height: 100vh; 11 | color: var(--text-color); 12 | line-height: 1.4; 13 | font-size: var(--font-size); 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | body { 19 | width: 100vw; 20 | height: 100vh; 21 | } 22 | 23 | a { 24 | color: var(--link-color); 25 | text-decoration: var(--link-decoration); 26 | } 27 | 28 | h1, 29 | h2, 30 | h3, 31 | h4, 32 | h5, 33 | h6 { 34 | margin: 0; 35 | } 36 | 37 | ul, 38 | ol { 39 | padding: 0; 40 | margin: 0; 41 | list-style: none; 42 | } 43 | 44 | pre { 45 | margin: 0; 46 | } 47 | 48 | pre, 49 | code { 50 | font-family: var(--font-family); 51 | } 52 | 53 | [data-mode='build'].loading, 54 | [data-mode='build'] .loading { 55 | background-color: var(--bg-color-light); 56 | background-image: var(--bg-icon-loader); 57 | background-position: center; 58 | background-repeat: no-repeat; 59 | } 60 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/brand.scss: -------------------------------------------------------------------------------- 1 | .fr-brand { 2 | padding: 16px 16px; 3 | font-size: 20px; 4 | 5 | a { 6 | color: #fff; 7 | font-weight: bold; 8 | text-decoration: none; 9 | } 10 | 11 | &__brand { 12 | font-weight: bold; 13 | font-size: 20px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/controls.scss: -------------------------------------------------------------------------------- 1 | .fr-controls { 2 | flex: none; 3 | } 4 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/error.scss: -------------------------------------------------------------------------------- 1 | .fr-error { 2 | padding: 20px; 3 | background: var(--error-bg-color); 4 | color: var(--error-text-color); 5 | overflow: auto; 6 | height: 100%; 7 | width: 100%; 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: center; 11 | align-items: center; 12 | text-align: center; 13 | 14 | &__title { 15 | font-size: var(--font-size--xlarge); 16 | } 17 | 18 | &__message { 19 | opacity: 0.9; 20 | font-size: 0.9em; 21 | } 22 | 23 | &__stack { 24 | opacity: 0.8; 25 | font-family: var(--font-family--code); 26 | font-size: var(--font-size--code); 27 | white-space: pre-wrap; 28 | overflow: hidden; 29 | word-break: break-all; 30 | } 31 | 32 | &__content { 33 | max-width: 600px; 34 | } 35 | 36 | &--overlay { 37 | display: none; 38 | position: absolute; 39 | top: 0; 40 | bottom: 0; 41 | left: 0; 42 | right: 0; 43 | width: 100%; 44 | height: 100%; 45 | background: var(--error-bg-color--overlay); 46 | z-index: 10000; 47 | justify-content: start; 48 | align-items: start; 49 | text-align: left; 50 | } 51 | 52 | &--overlay.is-active { 53 | display: block; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/highlight.scss: -------------------------------------------------------------------------------- 1 | .hljs { 2 | font-family: var(--font-family--code); 3 | font-size: var(--font-size--code); 4 | background: var(--bg-color-light-shade); 5 | color: var(--text-color); 6 | display: block; 7 | overflow-x: auto; 8 | padding: 0.5em; 9 | 10 | &-comment, 11 | &-quote { 12 | color: #998; 13 | font-style: italic; 14 | } 15 | 16 | &-keyword, 17 | &-selector-tag, 18 | &-subst { 19 | color: #333; 20 | font-weight: bold; 21 | } 22 | 23 | &-number, 24 | &-literal, 25 | &-variable, 26 | &-template-variable, 27 | &-tag &-attr { 28 | color: #008080; 29 | } 30 | 31 | &-string, 32 | &-doctag { 33 | color: #d14; 34 | } 35 | 36 | &-title, 37 | &-section, 38 | &-selector-id { 39 | color: #900; 40 | font-weight: bold; 41 | } 42 | 43 | &-subst { 44 | font-weight: normal; 45 | } 46 | 47 | &-type, 48 | &-class &-title { 49 | color: #458; 50 | font-weight: bold; 51 | } 52 | 53 | &-tag, 54 | &-name, 55 | &-attribute { 56 | color: #000080; 57 | font-weight: normal; 58 | } 59 | 60 | &-regexp, 61 | &-link { 62 | color: #009926; 63 | } 64 | 65 | &-symbol, 66 | &-bullet { 67 | color: #990073; 68 | } 69 | 70 | &-built_in, 71 | &-builtin-name { 72 | color: #0086b3; 73 | } 74 | 75 | &-meta { 76 | color: #999; 77 | font-weight: bold; 78 | } 79 | 80 | &-deletion { 81 | background: #fdd; 82 | } 83 | 84 | &-addition { 85 | background: #dfd; 86 | } 87 | 88 | &-emphasis { 89 | font-style: italic; 90 | } 91 | 92 | &-strong { 93 | font-weight: bold; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/inspector.scss: -------------------------------------------------------------------------------- 1 | .fr-inspector { 2 | width: 100%; 3 | height: 100vh; 4 | background: var(--bg-color-light-shade); 5 | overflow: hidden; 6 | 7 | &__preview { 8 | background-color: #fff; 9 | position: relative; 10 | } 11 | 12 | &__drawer { 13 | flex: 1; 14 | background-color: #fff; 15 | overflow: hidden; 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | 20 | &__tabs { 21 | flex: none; 22 | } 23 | 24 | &__panels { 25 | flex: 1; 26 | } 27 | 28 | .gutter.gutter-vertical { 29 | border-top: 1px solid var(--divider-color); 30 | background-color: var(--tabs-bg-color); 31 | background-image: none; 32 | } 33 | 34 | .fr-source-code, 35 | .fr-json-explorer { 36 | height: 100%; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/json-explorer.scss: -------------------------------------------------------------------------------- 1 | .fr-json-explorer { 2 | padding: 12px; 3 | background-color: var(--bg-color-light); 4 | 5 | .vjs__tree { 6 | line-height: 1.3; 7 | font-family: var(--font-family--code); 8 | font-size: var(--font-size--code); 9 | color: var(--text-color); 10 | 11 | .vjs__tree__content { 12 | border-left: 1px dotted var(--divider-color); 13 | } 14 | 15 | .vjs__tree__node { 16 | &:hover { 17 | color: #20a0ff; 18 | } 19 | } 20 | 21 | .vjs__value__null { 22 | color: #ff4949; 23 | } 24 | 25 | .vjs__value__number, 26 | .vjs__value__boolean { 27 | color: #1d8ce0; 28 | } 29 | 30 | .vjs__value__string { 31 | color: rgb(170, 17, 17); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/nav.scss: -------------------------------------------------------------------------------- 1 | .fr-nav { 2 | &__items { 3 | list-style: none; 4 | margin: 0; 5 | padding: 0; 6 | font-size: var(--font-size--small); 7 | } 8 | 9 | &__item > &__items { 10 | display: none; 11 | } 12 | 13 | &__item.is-expanded > &__items { 14 | display: block; 15 | } 16 | 17 | &__link, 18 | &__toggle { 19 | user-select: none; 20 | display: block; 21 | padding: 6px 16px; 22 | text-decoration: none; 23 | transition: all 0.15s ease-out; 24 | position: relative; 25 | } 26 | 27 | &__toggle { 28 | color: var(--nav-text-color); 29 | position: relative; 30 | opacity: 0.7; 31 | &.is-collapsable:before { 32 | display: inline-block; 33 | content: '›'; 34 | font-weight: bold; 35 | position: relative; 36 | margin-left: -10px; 37 | opacity: 0.5; 38 | margin-right: 5px; 39 | } 40 | } 41 | 42 | &__item.is-expanded > &__toggle:before { 43 | transform: rotate(90deg); 44 | } 45 | 46 | &__toggle:hover { 47 | cursor: pointer; 48 | &:before { 49 | opacity: 1; 50 | } 51 | } 52 | 53 | &__link { 54 | // font-weight: bold; 55 | color: var(--nav-link-color); 56 | } 57 | 58 | &__link:hover { 59 | background-color: var(--nav-link-bg--hover); 60 | color: var(--nav-link-color--hover); 61 | } 62 | 63 | &__link:not(.fr-nav__toggle).is-active { 64 | background-color: var(--nav-link-bg--active); 65 | color: var(--nav-link-color--active); 66 | } 67 | 68 | @for $i from 1 through 6 { 69 | [data-level='#{$i}'] &__toggle, 70 | [data-level='#{$i}'] &__link { 71 | padding-left: 16px * $i; 72 | } 73 | } 74 | 75 | [data-level='1'] > li > &__toggle { 76 | // font-weight: bold; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/page.scss: -------------------------------------------------------------------------------- 1 | .fr-page { 2 | width: 100%; 3 | height: 100%; 4 | font-family: var(--font-family); 5 | background-color: var(--page-bg-color); 6 | overflow-y: scroll; 7 | -webkit-overflow-scrolling: touch; 8 | 9 | &__container { 10 | padding: 16px 24px; 11 | } 12 | 13 | &__title { 14 | font-size: var(--font-size--xlarge); 15 | border-bottom: 1px solid var(--divider-color); 16 | padding-bottom: 12px; 17 | margin-bottom: 24px; 18 | } 19 | 20 | &__content { 21 | max-width: 900px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/panels.scss: -------------------------------------------------------------------------------- 1 | .fr-panels { 2 | background-color: var(--panel-bg-color); 3 | overflow: hidden; 4 | 5 | &__panel { 6 | overflow: auto; 7 | -webkit-overflow-scrolling: touch; 8 | height: 100%; 9 | width: 100%; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/preview.scss: -------------------------------------------------------------------------------- 1 | .fr-preview { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | background-color: var(--preview-bg-color); 6 | 7 | &__pane { 8 | width: 100%; 9 | height: 100%; 10 | } 11 | 12 | &__wrapper { 13 | height: 100%; 14 | width: 100%; 15 | position: relative; 16 | } 17 | 18 | &__wrapper.loading &__window { 19 | opacity: 0; 20 | } 21 | 22 | &__stats { 23 | background: var(--bg-color-light-shade); 24 | position: absolute; 25 | right: 0; 26 | bottom: 0; 27 | display: inline-block; 28 | font-family: var(--font-family--code); 29 | font-size: 14px; 30 | padding: 3px 4px; 31 | opacity: 0; 32 | pointer-events: none; 33 | transition: opacity 0.2s ease-out; 34 | } 35 | 36 | &__stats.visible { 37 | opacity: 0.9; 38 | } 39 | 40 | &__spacer { 41 | background: var(--bg-color-light-shade); 42 | } 43 | 44 | &__window { 45 | width: 100%; 46 | height: 100%; 47 | border: 0; 48 | position: absolute; 49 | top: 0; 50 | left: 0; 51 | right: 0; 52 | bottom: 0; 53 | } 54 | 55 | .gutter.gutter-horizontal { 56 | background-color: var(--preview-resizer-bg-color); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/proptable.scss: -------------------------------------------------------------------------------- 1 | .fr-proptable { 2 | font-family: var(--font-family); 3 | background-color: var(--bg-color-light); 4 | display: table; 5 | font-size: 15px; 6 | border-collapse: collapse; 7 | 8 | &__row { 9 | display: table-row; 10 | } 11 | 12 | &__cell { 13 | display: table-cell; 14 | border: 1px solid var(--divider-color); 15 | padding: 3px 5px; 16 | } 17 | 18 | &__cell--fit { 19 | width: 1%; 20 | white-space: nowrap; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/prose.scss: -------------------------------------------------------------------------------- 1 | .fr-prose { 2 | font-family: var(--font-family); 3 | 4 | > h2 { 5 | font-size: 22px; 6 | margin-bottom: 1em; 7 | margin-top: 1em; 8 | } 9 | 10 | > h3 { 11 | font-size: 20px; 12 | margin-bottom: 1em; 13 | margin-top: 1em; 14 | } 15 | 16 | > h4 { 17 | font-size: 18px; 18 | margin-bottom: 1em; 19 | margin-top: 1em; 20 | } 21 | 22 | ul, 23 | ol { 24 | list-style: disc; 25 | margin-left: 20px; 26 | } 27 | 28 | li { 29 | margin-bottom: 0.2em; 30 | } 31 | 32 | :first-child { 33 | margin-top: 0; 34 | } 35 | 36 | :last-child { 37 | margin-bottom: 0; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/search.scss: -------------------------------------------------------------------------------- 1 | .fr-search { 2 | padding: 0 0; 3 | background-color: var(--search-bg-color); 4 | 5 | &__input-wrapper { 6 | position: relative; 7 | } 8 | 9 | &__input-wrapper:hover &__cancel { 10 | opacity: 1; 11 | } 12 | 13 | &__cancel { 14 | position: absolute; 15 | top: 50%; 16 | transform: translateY(-50%); 17 | right: 4px; 18 | color: #fff; 19 | z-index: 10; 20 | display: inline-block; 21 | padding: 5px 10px; 22 | line-height: 1; 23 | cursor: pointer; 24 | opacity: 0.7; 25 | } 26 | 27 | &__input { 28 | width: calc(100% - 32px); 29 | width: 100%; 30 | border: 0; 31 | background-color: transparent; 32 | padding: 12px 40px 12px 16px; 33 | color: #fff; 34 | font-size: 14px; 35 | &:focus { 36 | outline: none; 37 | } 38 | } 39 | 40 | &__results { 41 | padding-bottom: 10px; 42 | font-size: var(--font-size--small); 43 | } 44 | 45 | &__none { 46 | padding: 6px 16px 0 16px; 47 | font-style: italic; 48 | opacity: 0.5; 49 | } 50 | 51 | &__label, 52 | &__link { 53 | user-select: none; 54 | display: block; 55 | padding: 6px 16px; 56 | text-decoration: none; 57 | transition: all 0.15s ease-out; 58 | position: relative; 59 | .highlight { 60 | font-weight: bold; 61 | } 62 | } 63 | 64 | &__label { 65 | opacity: 0.7; 66 | color: var(--nav-text-color); 67 | position: relative; 68 | } 69 | 70 | &__link { 71 | // font-weight: bold; 72 | color: var(--nav-link-color); 73 | padding-left: 32px; 74 | } 75 | 76 | &__link:hover { 77 | background-color: var(--nav-link-bg--hover); 78 | color: var(--nav-link-color--hover); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/source-code.scss: -------------------------------------------------------------------------------- 1 | @import 'codemirror/lib/codemirror.css'; 2 | 3 | .fr-source-code { 4 | font-family: var(--font-family--code) !important; 5 | font-size: var(--font-size--code); 6 | background-color: var(--bg-color-light); 7 | 8 | .vue-codemirror { 9 | height: 100%; 10 | } 11 | 12 | .CodeMirror { 13 | height: 100%; 14 | } 15 | 16 | .CodeMirror-scroll { 17 | margin-bottom: 0 !important; 18 | } 19 | 20 | .CodeMirror-gutters { 21 | border-right: 1px solid var(--divider-color); 22 | background-color: var(--bg-color-light-shade); 23 | } 24 | 25 | // TODO: extract colours to CSS variables 26 | 27 | .cm-s-default .cm-header { 28 | color: blue; 29 | } 30 | .cm-s-default .cm-quote { 31 | color: #090; 32 | } 33 | .cm-negative { 34 | color: #d44; 35 | } 36 | .cm-positive { 37 | color: #292; 38 | } 39 | .cm-header, 40 | .cm-strong { 41 | font-weight: bold; 42 | } 43 | .cm-em { 44 | font-style: italic; 45 | } 46 | .cm-link { 47 | text-decoration: underline; 48 | } 49 | .cm-strikethrough { 50 | text-decoration: line-through; 51 | } 52 | 53 | .cm-s-default .cm-keyword { 54 | color: #708; 55 | } 56 | .cm-s-default .cm-atom { 57 | color: #219; 58 | } 59 | .cm-s-default .cm-number { 60 | color: #164; 61 | } 62 | .cm-s-default .cm-def { 63 | color: #00f; 64 | } 65 | .cm-s-default .cm-variable, 66 | .cm-s-default .cm-punctuation, 67 | .cm-s-default .cm-property, 68 | .cm-s-default .cm-operator { 69 | } 70 | .cm-s-default .cm-variable-2 { 71 | color: #05a; 72 | } 73 | .cm-s-default .cm-variable-3, 74 | .cm-s-default .cm-type { 75 | color: #085; 76 | } 77 | .cm-s-default .cm-comment { 78 | color: #a50; 79 | } 80 | .cm-s-default .cm-string { 81 | color: #a11; 82 | } 83 | .cm-s-default .cm-string-2 { 84 | color: #f50; 85 | } 86 | .cm-s-default .cm-meta { 87 | color: #555; 88 | } 89 | .cm-s-default .cm-qualifier { 90 | color: #555; 91 | } 92 | .cm-s-default .cm-builtin { 93 | color: #30a; 94 | } 95 | .cm-s-default .cm-bracket { 96 | color: #997; 97 | } 98 | .cm-s-default .cm-tag { 99 | color: #170; 100 | } 101 | .cm-s-default .cm-attribute { 102 | color: #00c; 103 | } 104 | .cm-s-default .cm-hr { 105 | color: #999; 106 | } 107 | .cm-s-default .cm-link { 108 | color: #00c; 109 | } 110 | 111 | .cm-s-default .cm-error { 112 | color: #f00; 113 | } 114 | .cm-invalidchar { 115 | color: #f00; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/split.scss: -------------------------------------------------------------------------------- 1 | .split-container { 2 | height: 100%; 3 | 4 | & > * { 5 | position: relative; 6 | overflow-y: hidden; 7 | } 8 | 9 | &--horizontal { 10 | display: flex; 11 | } 12 | 13 | .gutter { 14 | background-color: #eee; 15 | background-repeat: no-repeat; 16 | background-position: 50%; 17 | } 18 | 19 | .gutter.gutter-vertical { 20 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='); 21 | cursor: ns-resize; 22 | } 23 | 24 | .gutter.gutter-horizontal { 25 | float: left; 26 | cursor: ew-resize; 27 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=='); 28 | } 29 | } 30 | 31 | .split { 32 | overflow-y: auto; 33 | overflow-x: hidden; 34 | } 35 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/tabs.scss: -------------------------------------------------------------------------------- 1 | .fr-tabs { 2 | background-color: var(--tabs-bg-color); 3 | display: flex; 4 | width: 100%; 5 | border-bottom: 1px solid var(--divider-color); 6 | align-items: stretch; 7 | user-select: none; 8 | 9 | &__tab { 10 | flex: 1; 11 | } 12 | 13 | &__tab a { 14 | color: var(--text-color); 15 | display: block; 16 | font-size: 14px; 17 | padding: 2px 0 10px 0; 18 | text-align: center; 19 | text-decoration: none; 20 | margin-bottom: -1px; 21 | position: relative; 22 | } 23 | 24 | &__tab:hover a { 25 | border-bottom: 2px solid var(--tabs-highlight-color--hover); 26 | } 27 | 28 | &__tab.is-current a { 29 | font-weight: bold; 30 | border-bottom: 2px solid var(--tabs-highlight-color--active); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/fractalite/assets/css/theme.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-size: 16px; 3 | --font-size--small: 14px; 4 | --font-size--large: 20px; 5 | --font-size--xlarge: 24px; 6 | 7 | --font-size--code: var(--font-size--small); 8 | 9 | --font-family: 'system-ui', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 10 | --font-family--code: monospace; 11 | 12 | --text-color: #333; 13 | --link-color: #0074d9; 14 | --link-decoration: underline; 15 | 16 | --bg-color-light: #fff; 17 | --bg-color-light-shade: #f8f8f8; 18 | --bg-color-dark: #333; 19 | --bg-icon-loader: url("data:image/svg+xml,%3Csvg width='44' height='44' xmlns='http://www.w3.org/2000/svg' class='loader__icon'%3E%3Cg fill='none' stroke='%23333' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='1.8s' values='1; 20' calcMode='spline' keyTimes='0; 1' keySplines='0.165, 0.84, 0.44, 1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='1.8s' values='1; 0' calcMode='spline' keyTimes='0; 1' keySplines='0.3, 0.61, 0.355, 1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='1.8s' values='1; 20' calcMode='spline' keyTimes='0; 1' keySplines='0.165, 0.84, 0.44, 1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='1.8s' values='1; 0' calcMode='spline' keyTimes='0; 1' keySplines='0.3, 0.61, 0.355, 1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); 20 | 21 | --divider-color: #ddd; 22 | 23 | --sidebar-width: 260px; 24 | --sidebar-bg-color: var(--bg-color-dark); 25 | --sidebar-text-color: #fff; 26 | --sidebar-link-color: rgba(#fff, 0.7); 27 | 28 | --nav-text-color: var(--sidebar-text-color); 29 | --nav-link-color: var(--sidebar-link-color); 30 | --nav-link-color--hover: var(--nav-link-color); 31 | --nav-link-bg--hover: #444; 32 | --nav-link-color--active: var(--sidebar-link-color); 33 | --nav-link-bg--active: #555; 34 | 35 | --search-bg-color: rgba(0, 0, 0, 0.5); 36 | 37 | --page-bg-color: var(--bg-color-light); 38 | --panel-bg-color: var(--bg-color-light); 39 | 40 | --error-bg-color: rgba(154, 0, 0, 1); 41 | --error-bg-color--overlay: rgba(154, 0, 0, 0.95); 42 | --error-text-color: #fff; 43 | 44 | --tabs-bg-color: var(--bg-color-light-shade); 45 | --tabs-highlight-color--active: #333; 46 | --tabs-highlight-color--hover: #ccc; 47 | 48 | --preview-bg-color: var(--bg-color-light); 49 | --preview-resizer-bg-color: #eee; 50 | } 51 | -------------------------------------------------------------------------------- /packages/fractalite/assets/reload.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client'; 2 | 3 | function debounce(func, wait, immediate) { 4 | var timeout; 5 | return function() { 6 | var context = this, 7 | args = arguments; 8 | var later = function() { 9 | timeout = null; 10 | if (!immediate) func.apply(context, args); 11 | }; 12 | var callNow = immediate && !timeout; 13 | clearTimeout(timeout); 14 | timeout = setTimeout(later, wait); 15 | if (callNow) func.apply(context, args); 16 | }; 17 | } 18 | 19 | const socket = io(); 20 | const reload = debounce(state => window.location.reload(), 500, true); 21 | socket.on('state.updated', reload); 22 | -------------------------------------------------------------------------------- /packages/fractalite/bin/fractalite: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --no-warnings 2 | 3 | const { relative, resolve, dirname } = require('path'); 4 | const mri = require('mri'); 5 | const { cyan, dim, red } = require('kleur'); 6 | const { Signale } = require('signale'); 7 | const { stripIndent, source } = require('common-tags'); 8 | const { loadConfig } = require('@frctl/fractalite-support'); 9 | const { serveStatic } = require('@frctl/fractalite-app'); 10 | const ip = require('ip'); 11 | const fractalite = require('../.'); 12 | const { assign } = Object; 13 | 14 | const logger = new Signale({ 15 | types: { 16 | debug: { 17 | badge: '✎', 18 | color: 'gray' 19 | } 20 | } 21 | }); 22 | 23 | const cwd = process.cwd(); 24 | const args = mri(process.argv.slice(2)); 25 | 26 | (async () => { 27 | let loaded; 28 | 29 | try { 30 | loaded = await loadConfig({ cwd, path: args.config, name: 'fractal' }); 31 | const rootPath = dirname(loaded.path); 32 | if (cwd !== rootPath) { 33 | process.chdir(rootPath); 34 | } 35 | logger.info(`Using config file ${cyan(`./${relative(cwd, loaded.path)}`)}`); 36 | } catch(err) { 37 | if (err.type === 'CONFIG_NOT_FOUND') { 38 | // TODO: run interactive config creator? 39 | loaded = { config: {} }; 40 | logger.warn(`Config file not found. Continuing without config.`); 41 | } else { 42 | logger.error(err); 43 | process.exit(1); 44 | } 45 | } 46 | 47 | try { 48 | const command = args._[0] || 'start'; 49 | const { config } = loaded; 50 | 51 | if (command === 'start') { 52 | 53 | const mode = assign({ mode: 'develop' }, config.develop || {}, args); 54 | const app = fractalite({ mode, ...config }); 55 | 56 | app.on('error', function onError(err) { 57 | if (err.message.indexOf('ECONNRESET') >= 0) { 58 | return; 59 | } 60 | if (err.status === 404) { 61 | logger.warn(`${err.message} ${dim(`[${err.path}]`)}`); 62 | } else { 63 | logger.error(err); 64 | } 65 | }); 66 | 67 | app.on('state.pending', () => logger.info(`Source change detected`)); 68 | app.on('state.updated', (state, info) => { 69 | for (const log of info.logs || []) { 70 | log.message instanceof Error ? logger.error(log.message) : logger[log.level || 'info'](log.message); 71 | } 72 | logger.success(`Compiled in %d.%ds`, info.time[0], Math.round(info.time[1] / 1000000)) 73 | logger.log('') 74 | }); 75 | 76 | const server = await app.run(); 77 | const port = server.address().port; 78 | 79 | serverStarted({ 80 | server, 81 | startedMessage: `Fractalite server started`, 82 | stoppedMessage: `Fractalite server stopped` 83 | }); 84 | 85 | } else if (command === 'build') { 86 | 87 | const mode = assign({ mode: 'build' }, config.build || {}, args); 88 | const app = fractalite({ mode, ...config }); 89 | 90 | const interactiveLogger = new Signale({interactive: true}); 91 | 92 | app.on('build.start', () => { 93 | interactiveLogger.await(`Building static export...`); 94 | }); 95 | 96 | app.on('build.end', results => { 97 | interactiveLogger.success(`Fractalite static build complete. ${results.length} files generated.`); 98 | }); 99 | 100 | await app.run(); 101 | 102 | if (mode.serve) { 103 | const server = await serveStatic(mode.dest, mode.port); 104 | serverStarted({ 105 | server, 106 | startedMessage: `Serving static build`, 107 | stoppedMessage: `Fractalite static build server stopped` 108 | }); 109 | } 110 | 111 | } else if (command === 'serve') { 112 | 113 | const opts = assign(config.build || {}, args); 114 | const server = await serveStatic(opts.dest, opts.port); 115 | 116 | serverStarted({ server }); 117 | 118 | } else { 119 | throw new Error(`Command '${command}' not recognised`); 120 | } 121 | 122 | } catch(err) { 123 | logger.error(err); 124 | process.exit(1); 125 | } 126 | 127 | function serverStarted({ server, startedMessage, stoppedMessage }){ 128 | const port = server.address().port; 129 | 130 | logger.success(startedMessage || 'Server started'); 131 | logger.log(''); 132 | logger.log(stripIndent` 133 | --- 134 | Local: ${cyan(`http://localhost:${port}`)} 135 | Network: ${cyan(`http://${ip.address()}:${port}`)} 136 | --- 137 | ${dim(`Use ^C to stop`)} 138 | `); 139 | logger.log(''); 140 | 141 | process.on('SIGINT', () => { 142 | server.close(); 143 | logger.log(''); 144 | logger.info(stoppedMessage || 'Server stopped'); 145 | process.exit(0); 146 | }); 147 | } 148 | 149 | })(); 150 | -------------------------------------------------------------------------------- /packages/fractalite/index.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const { forEach, get, isFunction, isBoolean } = require('lodash'); 3 | const { defaultsDeep } = require('@frctl/fractalite-support/utils'); 4 | const createApp = require('@frctl/fractalite-app'); 5 | const { createCompiler, createAdapter } = require('@frctl/fractalite-core'); 6 | const { htmlAdapter } = require('@frctl/fractalite-core'); 7 | const highlight = require('./src/server/utils/highlight'); 8 | const markdown = require('./src/server/utils/markdown'); 9 | const prettify = require('./src/server/utils/prettify'); 10 | const createAssetResolver = require('./src/server/utils/resolve-asset'); 11 | const plugins = require('./src/server/plugins'); 12 | 13 | const configDefaults = { 14 | plugins: [] 15 | }; 16 | 17 | module.exports = function({ components, adapter, mode = {}, ...config }) { 18 | config = defaultsDeep(config, configDefaults); 19 | 20 | /* 21 | * App is (mostly) client side rendered app so need to make sure that 22 | * the permalink URLs are generated in the right format in both develop 23 | * and build modes. 24 | */ 25 | mode.paths = mode.paths || {}; 26 | mode.permalinks = mode.permalinks || {}; 27 | mode.paths.ext = mode.paths.ext || '.html'; 28 | mode.paths.indexes = isBoolean(mode.paths.indexes) ? mode.paths.indexes : true; 29 | mode.paths.prefix = false; 30 | mode.permalinks.indexes = false; 31 | mode.permalinks.ext = false; 32 | mode.permalinks.prefix = false; 33 | 34 | const compiler = createCompiler(components); 35 | const app = createApp(compiler, mode); 36 | 37 | adapter = adapter || htmlAdapter; 38 | adapter = isFunction(adapter) ? adapter(app, compiler) : adapter; 39 | adapter = createAdapter(compiler.getState(), adapter); 40 | 41 | app.addViewPath(resolve(__dirname, './views')); 42 | 43 | app.addStaticDir('app', resolve(__dirname, './dist'), '/app/assets'); 44 | app.addStylesheet('app:app.css'); 45 | app.addScript('app:app.js'); 46 | 47 | app.addRoute('overview', '/', (ctx, next) => ctx.render('app')); 48 | app.addBuilder((state, { request }) => request({ name: 'overview' })); 49 | 50 | app.addRoute('api.component', '/api/components/:component.json', (ctx, next) => { 51 | ctx.body = ctx.component; 52 | return next(); 53 | }); 54 | app.addBuilder((state, { request }) => { 55 | state.components.forEach(component => { 56 | request({ name: 'api.component', params: { component } }); 57 | }); 58 | }); 59 | 60 | /* 61 | * Add utilities 62 | */ 63 | app.utils.highlightCode = highlight(get(config, 'opts.highlight')); 64 | app.utils.renderMarkdown = markdown({ 65 | highlight: app.utils.highlightCode, 66 | ...get(config, 'opts.markdown') 67 | }); 68 | app.utils.prettify = prettify(get(config, 'opts.prettify')); 69 | app.utils.resolveAsset = createAssetResolver(app); 70 | 71 | ['references', 'public', 'preview', 'pages', 'nav', 'inspector', 'theme', 'search'].forEach(name => { 72 | require(`./src/server/${name}`)(app, compiler, adapter, get(config, name)); 73 | }); 74 | 75 | /* 76 | * Load all core plugins, initialised with opts 77 | */ 78 | plugins.forEach(({ key, handler }) => { 79 | const opts = get(config, key, {}); 80 | const plugin = handler(opts); 81 | plugin(app, compiler, adapter); 82 | }); 83 | 84 | /* 85 | * Load all user-defined plugins 86 | */ 87 | forEach(config.plugins, plugin => plugin(app, compiler, adapter)); 88 | 89 | /* 90 | * Add global props 91 | */ 92 | app.addViewGlobal('app', { 93 | title: config.title || 'Fractalite', 94 | meta: defaultsDeep(config.meta || {}, { 95 | title: config.title || 'Fractalite', 96 | lang: 'en', 97 | dir: 'ltr', 98 | charset: 'utf-8', 99 | viewport: 'width=device-width, initial-scale=1.0' 100 | }) 101 | }); 102 | 103 | return app; 104 | }; 105 | -------------------------------------------------------------------------------- /packages/fractalite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@frctl/fractalite-app": "^0.0.0", 6 | "@frctl/fractalite-core": "^0.0.0", 7 | "@frctl/fractalite-support": "^0.0.0", 8 | "asyncro": "^3.0.0", 9 | "axios": "^0.18.0", 10 | "common-tags": "^1.8.0", 11 | "escape-html": "^1.0.3", 12 | "flat": "^4.1.0", 13 | "gray-matter": "^4.0.1", 14 | "highlight.js": "^9.14.2", 15 | "ip": "^1.1.5", 16 | "kleur": "^2.0.2", 17 | "lodash": "^4.17.10", 18 | "markdown-it": "^8.4.2", 19 | "mri": "^1.1.1", 20 | "prettier": "^1.16.4", 21 | "signale": "^1.2.1", 22 | "socket.io-client": "^2.2.0" 23 | }, 24 | "devDependencies": { 25 | "babel-core": "^6.26.3", 26 | "babel-preset-env": "^1.7.0", 27 | "codemirror": "^5.43.0", 28 | "normalize.css": "^8.0.0", 29 | "parcel-bundler": "^1.10.1", 30 | "sass": "^1.14.1", 31 | "split.js": "^1.5.10", 32 | "vue": "^2.6.8", 33 | "vue-awesome": "^3.3.1", 34 | "vue-codemirror": "^4.0.6", 35 | "vue-hot-reload-api": "^2.3.1", 36 | "vue-json-pretty": "1.4.1", 37 | "vue-resize-directive": "^1.1.4", 38 | "vue-router": "^3.0.2", 39 | "vue-socket.io-extended": "^3.2.1", 40 | "vuex": "^3.1.0", 41 | "vuex-persist": "^2.0.0" 42 | }, 43 | "scripts": { 44 | "prepublish": "parcel build ./assets/app.js ./assets/reload.js --no-source-maps --log-level 2", 45 | "build": "parcel build ./assets/app.js ./assets/reload.js --no-source-maps", 46 | "dev": "parcel watch ./assets/app.js ./assets/reload.js --no-source-maps" 47 | }, 48 | "bin": { 49 | "fractalite": "./bin/fractalite" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/fractalite/pages/index.njk: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | --- 4 | 5 |

This is the landing page. You can override this page to customise your styleguide.

6 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/app.js: -------------------------------------------------------------------------------- 1 | /* global window, document */ 2 | 3 | import Vue from 'vue/dist/vue'; 4 | import VueSocketio from 'vue-socket.io-extended'; 5 | import io from 'socket.io-client'; 6 | import Navigation from './components/navigation'; 7 | import Search from './components/search'; 8 | import Error from './components/error'; 9 | import JSONExplorer from './components/json-explorer'; 10 | import SourceCode from './components/source-code'; 11 | import AppLink from './components/app-link'; 12 | import Split from './components/split'; 13 | import router from './router'; 14 | import store from './store'; 15 | import eventBus from './events'; 16 | 17 | Vue.use(VueSocketio, io(), { store }); 18 | 19 | Vue.component('split-pane', Split); 20 | Vue.component('app-link', AppLink); 21 | Vue.component('json-explorer', JSONExplorer); 22 | Vue.component('source-code', SourceCode); 23 | 24 | window.app = new Vue({ 25 | el: '#app', 26 | props: { 27 | loading: false 28 | }, 29 | components: { 30 | Error, 31 | Navigation, 32 | Search 33 | }, 34 | router, 35 | store, 36 | methods: { 37 | resetSearch() { 38 | this.$refs.search.resetSearch(); 39 | } 40 | }, 41 | computed: { 42 | navItems() { 43 | return this.$store.state.nav.items; 44 | }, 45 | searchTargets() { 46 | return this.$store.state.search.targets; 47 | } 48 | }, 49 | async mounted() { 50 | document.querySelector('body').classList.remove('loading'); 51 | 52 | eventBus.$on('loading.start', () => { 53 | this.loading = true; 54 | }); 55 | 56 | eventBus.$on('loading.stop', () => { 57 | this.loading = false; 58 | }); 59 | 60 | await this.$store.dispatch('updateState'); 61 | 62 | /* 63 | * Capture clicks in outside of Vue code and determine 64 | * whether to re-route them via vue-router. 65 | */ 66 | window.addEventListener('click', event => { 67 | const { target } = event; 68 | if (target && target.matches("a:not([href*='://'])") && target.href) { 69 | if (target.matches('[href^="/preview/"]')) return; 70 | const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } = event; 71 | if (metaKey || altKey || ctrlKey || shiftKey) return; 72 | if (defaultPrevented) return; 73 | if (button !== undefined && button !== 0) return; 74 | if (target && target.getAttribute) { 75 | const linkTarget = target.getAttribute('target'); 76 | if (/\b_blank\b/i.test(linkTarget)) return; 77 | } 78 | const url = new URL(target.href); 79 | const to = url.pathname; 80 | const parts = target.href.split('/'); 81 | if (window.location.pathname === to) { 82 | event.preventDefault(); 83 | } 84 | if (parts[parts.length - 1].split('.').length === 1 && event.preventDefault) { 85 | event.preventDefault(); 86 | this.$router.push(to); 87 | } 88 | } 89 | }); 90 | } 91 | }); 92 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/app-link.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'app-link', 3 | template: ``, 4 | props: { 5 | to: { 6 | type: String, 7 | required: true 8 | } 9 | }, 10 | methods: { 11 | linkProps(url) { 12 | if (url.match(/^(http(s)?|ftp):\/\//)) { 13 | return { is: 'a', href: url, target: '_blank', rel: 'noopener' }; 14 | } 15 | return { is: 'router-link', to: url }; 16 | } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/error.js: -------------------------------------------------------------------------------- 1 | export default { 2 | template: '#error', 3 | props: ['error'], 4 | computed: { 5 | name() { 6 | return this.error.name; 7 | }, 8 | message() { 9 | return this.error.message; 10 | }, 11 | stack() { 12 | return this.error.stack; 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/inspector.js: -------------------------------------------------------------------------------- 1 | import eventBus from '../events'; 2 | import Preview from './preview'; 3 | 4 | export default { 5 | template: '#inspector', 6 | props: ['componentName', 'scenarioName'], 7 | components: { Preview }, 8 | sockets: { 9 | async 'state.updated'() { 10 | await this.load(); 11 | }, 12 | refresh() { 13 | this.$refs.preview.reload(); 14 | } 15 | }, 16 | data() { 17 | return { 18 | component: null, 19 | scenario: {}, 20 | preview: '', 21 | panels: [], 22 | currentTab: 0, 23 | loaded: false 24 | }; 25 | }, 26 | computed: { 27 | split() { 28 | return this.$store.state.inspector.split; 29 | } 30 | }, 31 | methods: { 32 | async load() { 33 | this.loaded = false; 34 | this.preview = ''; 35 | this.scenario = {}; 36 | if (this.componentName) { 37 | try { 38 | const [component, inspectorData] = await Promise.all([ 39 | await this.$store.dispatch('fetchComponent', this.componentName), 40 | await this.$store.dispatch('fetchInspectorData', { 41 | component: this.componentName, 42 | scenario: this.scenarioName 43 | }) 44 | ]); 45 | this.component = component; 46 | const scenario = this.component.scenarios.find(scenario => scenario.name === this.scenarioName); 47 | if (!scenario) { 48 | const scenario = this.component.scenarios[0]; 49 | this.$router.push(`/inspect/${this.component.name}/${scenario.name}`); 50 | return; 51 | } 52 | this.scenario = scenario; 53 | this.panels = inspectorData.panels; 54 | this.preview = inspectorData.preview; 55 | this.loaded = true; 56 | eventBus.$emit('loading.stop'); 57 | } catch (err) { 58 | this.$store.commit('setError', err); 59 | } 60 | } 61 | }, 62 | selectTab(i) { 63 | this.currentTab = i; 64 | }, 65 | onDrawerResize(sizes) { 66 | this.$store.commit('setInspectorSplit', sizes); 67 | } 68 | }, 69 | async mounted() { 70 | await this.load(); 71 | }, 72 | watch: { 73 | componentName() { 74 | return this.load(); 75 | }, 76 | scenarioName() { 77 | return this.load(); 78 | } 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/json-explorer.js: -------------------------------------------------------------------------------- 1 | import VueJsonPretty from 'vue-json-pretty'; 2 | 3 | export default { 4 | template: ` 5 |
6 | 7 |
8 | `, 9 | props: ['data'], 10 | components: { 11 | VueJsonPretty 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/navigation.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue/dist/vue'; 2 | 3 | export default { 4 | name: 'navigation', 5 | template: '#navigation', 6 | props: { 7 | items: Array, 8 | depth: { 9 | default: 1 10 | } 11 | }, 12 | async mounted() { 13 | if (this.depth === 1) { 14 | const expandItems = items => { 15 | items.forEach(item => { 16 | if (item.expanded === true) { 17 | this.expand(item); 18 | } 19 | if (item.children) { 20 | expandItems(item.children); 21 | } 22 | }); 23 | }; 24 | expandItems(this.items); 25 | } 26 | this.$on('child-item-active', activeItem => { 27 | for (const item of this.items) { 28 | if (Array.isArray(item.children) && item.children.includes(activeItem)) { 29 | if (!this.isExpanded(item)) { 30 | this.toggleExpanded(item); 31 | } 32 | if (this.depth > 1) { 33 | this.$parent.$emit('child-item-active', item); 34 | } 35 | break; 36 | } 37 | } 38 | }); 39 | for (const item of this.items) { 40 | if (item.url === this.$route.path) { 41 | this.$parent.$emit('child-item-active', item); 42 | break; 43 | } 44 | } 45 | }, 46 | methods: { 47 | expand(item) { 48 | if (!this.isExpanded(item)) { 49 | this.$store.commit('toggleExpanded', item); 50 | } 51 | }, 52 | toggleExpanded(item) { 53 | if (item.collapsable) { 54 | this.$store.commit('toggleExpanded', item); 55 | } 56 | }, 57 | isExpanded(item) { 58 | if (item.collapsable) { 59 | return this.$store.getters.isExpanded(item); 60 | } 61 | return true; 62 | }, 63 | handleClick(item) { 64 | if (item.url && (!item.children || (item.children && !this.isExpanded(item)))) { 65 | if (item.url.match(/^(http(s)?|ftp):\/\//)) { 66 | this.toggleExpanded(item); 67 | window.open(item.url, '_blank'); 68 | } else { 69 | this.$router.push(item.url); 70 | } 71 | } 72 | if (item.children) { 73 | this.toggleExpanded(item); 74 | } 75 | } 76 | }, 77 | watch: { 78 | $route(route) { 79 | for (const item of this.items) { 80 | if (item.url === route.path) { 81 | this.$parent.$emit('child-item-active', item); 82 | break; 83 | } 84 | } 85 | } 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/page.js: -------------------------------------------------------------------------------- 1 | import eventBus from '../events'; 2 | 3 | export default { 4 | template: '#page', 5 | data() { 6 | return { 7 | page: null, 8 | content: null, 9 | loaded: false 10 | }; 11 | }, 12 | props: ['path'], 13 | methods: { 14 | async load() { 15 | if (this.path) { 16 | try { 17 | const { page, content } = await this.$store.dispatch('fetchPage', this.path); 18 | this.page = page; 19 | this.content = content; 20 | this.loaded = true; 21 | eventBus.$emit('loading.stop'); 22 | } catch (err) { 23 | this.$store.commit('setError', err); 24 | } 25 | } 26 | } 27 | }, 28 | async mounted() { 29 | await this.load(); 30 | }, 31 | watch: { 32 | async path() { 33 | this.loaded = false; 34 | await this.load(); 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/preview.js: -------------------------------------------------------------------------------- 1 | import resize from 'vue-resize-directive'; 2 | 3 | let statsTimer = null; 4 | 5 | export default { 6 | template: '#preview', 7 | props: ['src', 'srcdoc'], 8 | data() { 9 | return { 10 | loaded: false, 11 | previewWidth: 0, 12 | previewHeight: 0, 13 | statsVisible: false 14 | }; 15 | }, 16 | computed: { 17 | split() { 18 | return this.$store.state.preview.split || [100, 0]; 19 | } 20 | }, 21 | directives: { resize }, 22 | methods: { 23 | reload: debounce( 24 | function() { 25 | this.loaded = false; 26 | this.$refs.window.contentWindow.location.reload(); 27 | }, 28 | 500, 29 | true 30 | ), 31 | onPreviewResize(el) { 32 | this.previewWidth = el.clientWidth; 33 | this.previewHeight = el.clientHeight; 34 | this.statsVisible = true; 35 | if (statsTimer) { 36 | clearTimeout(statsTimer); 37 | } 38 | statsTimer = setTimeout(() => { 39 | this.statsVisible = false; 40 | }, 1000); 41 | }, 42 | onDrawerResize(sizes) { 43 | this.$store.commit('setPreviewSplit', sizes); 44 | } 45 | }, 46 | mounted() { 47 | this.loaded = false; 48 | this.previewWidth = this.$refs['preview-wrapper'].clientWidth; 49 | this.previewHeight = this.$refs['preview-wrapper'].clientHeight; 50 | this.$refs.window.addEventListener('load', () => { 51 | if (this.srcdoc) { 52 | this.loaded = true; 53 | } 54 | }); 55 | }, 56 | watch: { 57 | srcdoc() { 58 | this.loaded = false; 59 | }, 60 | src() { 61 | this.loaded = false; 62 | } 63 | } 64 | }; 65 | 66 | function debounce(func, wait, immediate) { 67 | let timeout; 68 | return function(...args) { 69 | const context = this; 70 | const later = function() { 71 | timeout = null; 72 | if (!immediate) func.apply(context, args); 73 | }; 74 | const callNow = immediate && !timeout; 75 | clearTimeout(timeout); 76 | timeout = setTimeout(later, wait); 77 | if (callNow) func.apply(context, args); 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/search.js: -------------------------------------------------------------------------------- 1 | /* eslint array-callback-return:"off", no-labels: "off" */ 2 | 3 | export default { 4 | name: 'search', 5 | template: '#search', 6 | props: ['components'], 7 | data() { 8 | return { 9 | searchTerm: '' 10 | }; 11 | }, 12 | computed: { 13 | isActive() { 14 | return this.term !== ''; 15 | }, 16 | term() { 17 | return this.searchTerm.trim().toLowerCase(); 18 | }, 19 | results() { 20 | if (this.isActive) { 21 | return this.components 22 | .map(component => { 23 | const scenarios = component.scenarios 24 | .map(scenario => { 25 | const matches = fuzzysearch(this.term, scenario.label.toLowerCase()); 26 | if (matches) { 27 | return { 28 | ...scenario, 29 | label: highlight(scenario.label, matches) 30 | }; 31 | } 32 | }) 33 | .filter(scenario => scenario); 34 | 35 | const labelMatches = fuzzysearch(this.term, component.label.toLowerCase()); 36 | 37 | let aliasMatches = []; 38 | if (component.aliases.length > 0) { 39 | aliasMatches = component.aliases 40 | .map(alias => { 41 | const matches = fuzzysearch(this.term, alias); 42 | if (matches) { 43 | return highlight(alias, matches); 44 | } 45 | }) 46 | .filter(alias => alias); 47 | } 48 | 49 | if (labelMatches || aliasMatches.length > 0 || scenarios.length > 0) { 50 | return { 51 | scenarios: scenarios.length > 0 ? scenarios : component.scenarios, 52 | label: labelMatches ? highlight(component.label, labelMatches) : component.label, 53 | aliases: aliasMatches.join(', ') 54 | }; 55 | } 56 | }) 57 | .filter(component => component); 58 | } 59 | return []; 60 | } 61 | }, 62 | methods: { 63 | resetSearch() { 64 | this.searchTerm = ''; 65 | } 66 | } 67 | }; 68 | 69 | // Adapted from https://github.com/bevacqua/fuzzysearch/blob/master/index.js 70 | function fuzzysearch(needle, haystack) { 71 | const hlen = haystack.length; 72 | const nlen = needle.length; 73 | const indexes = []; 74 | if (nlen > hlen) { 75 | return false; 76 | } 77 | if (nlen === hlen) { 78 | if (needle === haystack) { 79 | return Array.from({ length: needle.length }, (_, k) => k); 80 | } 81 | return false; 82 | } 83 | const pos = haystack.indexOf(needle); 84 | if (pos > -1) { 85 | return Array.from({ length: needle.length }, (_, k) => k + pos); 86 | } 87 | outer: for (let i = 0, j = 0; i < nlen; i++) { 88 | const nch = needle.charCodeAt(i); 89 | while (j < hlen) { 90 | if (haystack.charCodeAt(j++) === nch) { 91 | indexes.push(j - 1); 92 | continue outer; 93 | } 94 | } 95 | return false; 96 | } 97 | return indexes; 98 | } 99 | 100 | // Adapted from https://github.com/farzher/fuzzysort/blob/master/fuzzysort.js 101 | function highlight(target, indexes, hOpen, hClose) { 102 | if (hOpen === undefined) hOpen = ''; 103 | if (hClose === undefined) hClose = ''; 104 | let highlighted = ''; 105 | let matchesIndex = 0; 106 | let opened = false; 107 | const targetLen = target.length; 108 | for (let i = 0; i < targetLen; ++i) { 109 | const char = target[i]; 110 | if (indexes[matchesIndex] === i) { 111 | ++matchesIndex; 112 | if (!opened) { 113 | opened = true; 114 | highlighted += hOpen; 115 | } 116 | if (matchesIndex === indexes.length) { 117 | highlighted += char + hClose + target.substr(i + 1); 118 | break; 119 | } 120 | } else if (opened) { 121 | opened = false; 122 | highlighted += hClose; 123 | } 124 | highlighted += char; 125 | } 126 | return highlighted; 127 | } 128 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/source-code.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-unassigned-import: "off" */ 2 | 3 | import { codemirror } from 'vue-codemirror'; 4 | 5 | import 'codemirror/mode/css/css'; 6 | import 'codemirror/mode/sass/sass'; 7 | import 'codemirror/mode/yaml/yaml'; 8 | import 'codemirror/mode/javascript/javascript'; 9 | import 'codemirror/mode/htmlmixed/htmlmixed'; 10 | import 'codemirror/addon/display/autorefresh'; 11 | 12 | const defaultOpts = { 13 | lineNumbers: true, 14 | readOnly: true, 15 | lineWrapping: true, 16 | autoRefresh: true 17 | }; 18 | 19 | export default { 20 | template: ` 21 |
22 | 23 |
24 | `, 25 | props: ['options', 'value'], 26 | computed: { 27 | opts() { 28 | return { ...defaultOpts, ...this.options }; 29 | } 30 | }, 31 | components: { codemirror } 32 | }; 33 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/components/split.js: -------------------------------------------------------------------------------- 1 | import split from 'split.js'; 2 | 3 | export default { 4 | template: ` 5 |
7 | 8 |
9 | `, 10 | props: { 11 | elements: { 12 | // Array of target split element ids 13 | type: Array, 14 | required: true, 15 | validator(value) { 16 | // Must be array of strings 17 | const isValid = value.every(i => typeof i === 'string'); 18 | if (!isValid) { 19 | console.error(`VueSplitJs: Invalid elements - "${value}". Must be array of strings`); 20 | } 21 | return isValid; 22 | } 23 | }, 24 | direction: { 25 | // Direction to split: horizontal or vertical. 26 | type: String, 27 | default: 'horizontal', 28 | validator(value) { 29 | const allowedValues = ['horizontal', 'vertical']; 30 | const isValid = allowedValues.includes(value); 31 | if (!isValid) { 32 | console.error(`VueSplitJs: Invalid direction - "${value}". Possible values are "horizontal" or "vertical"`); 33 | } 34 | return isValid; 35 | } 36 | }, 37 | sizes: { 38 | // Initial sizes of each element in percents. 39 | type: Array, 40 | default: null 41 | }, 42 | minSize: { 43 | // Minimum size of each element in pixels. 44 | type: [Number, Array], 45 | default: 100 46 | }, 47 | gutterSize: { 48 | // Gutter size in pixels. 49 | type: Number, 50 | default: 10 51 | }, 52 | snapOffset: { 53 | // Snap to minimum size offset. 54 | type: Number, 55 | default: 30 56 | }, 57 | cursor: { 58 | // Cursor to display while dragging. 59 | type: String, 60 | default: 'grabbing' 61 | } 62 | }, 63 | mounted() { 64 | split(this.elements, { 65 | direction: this.direction, 66 | sizes: this.sizes, 67 | minSize: this.minSize, 68 | gutterSize: this.gutterSize, 69 | snapOffset: this.snapOffset, 70 | cursor: this.cursor, 71 | onDrag: this.onDrag, 72 | onDragStart: this.onDragStart, 73 | onDragEnd: this.onDragEnd 74 | }); 75 | }, 76 | methods: { 77 | onDrag() { 78 | this.$emit('onDrag'); 79 | }, 80 | onDragEnd(sizes) { 81 | this.$emit('onDragEnd', sizes); 82 | }, 83 | onDragStart() { 84 | this.$emit('onDragStart'); 85 | } 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/events.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | export default new Vue(); 4 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue/dist/vue'; 2 | import VueRouter from 'vue-router'; 3 | import Inspector from './components/inspector'; 4 | import Page from './components/page'; 5 | import eventBus from './events'; 6 | 7 | Vue.use(VueRouter); 8 | 9 | const router = new VueRouter({ 10 | mode: 'history', 11 | routes: [ 12 | { 13 | name: 'inspect', 14 | path: '/inspect/:componentName/:scenarioName?', 15 | component: Inspector, 16 | props: true 17 | }, 18 | { 19 | name: 'index', 20 | path: '/', 21 | component: Page, 22 | props: { path: 'index' } 23 | }, 24 | { 25 | name: 'page', 26 | path: '/:path(.+)', 27 | component: Page, 28 | props: true 29 | } 30 | ] 31 | }); 32 | 33 | router.beforeEach((to, from, next) => { 34 | eventBus.$emit('loading.start'); 35 | next(); 36 | }); 37 | 38 | export default router; 39 | -------------------------------------------------------------------------------- /packages/fractalite/src/client/store.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase:"off" */ 2 | 3 | import Vue from 'vue/dist/vue'; 4 | import Vuex from 'vuex'; 5 | import axios from 'axios'; 6 | import VuexPersistence from 'vuex-persist'; 7 | 8 | Vue.use(Vuex); 9 | 10 | const persist = new VuexPersistence({ 11 | reducer: state => ({ 12 | nav: { 13 | expandedIds: state.nav.expandedIds 14 | }, 15 | preview: { 16 | split: state.preview.split 17 | }, 18 | inspector: { 19 | split: state.inspector.split 20 | } 21 | }) 22 | }); 23 | 24 | export default new Vuex.Store({ 25 | plugins: [persist.plugin], 26 | 27 | state: { 28 | components: [], 29 | pages: [], 30 | inspector: { 31 | data: [], 32 | split: null 33 | }, 34 | nav: { 35 | items: [], 36 | expandedIds: [] 37 | }, 38 | search: { 39 | targets: [] 40 | }, 41 | preview: { 42 | split: null 43 | }, 44 | error: null, 45 | initialised: false 46 | }, 47 | 48 | mutations: { 49 | setNavigationItems(state, items) { 50 | state.nav.items = items; 51 | }, 52 | 53 | toggleExpanded(state, item) { 54 | const { id } = item; 55 | const index = state.nav.expandedIds.indexOf(id); 56 | if (index > -1) { 57 | state.nav.expandedIds.splice(index, 1); 58 | } else { 59 | state.nav.expandedIds.push(id); 60 | } 61 | }, 62 | 63 | setSearchTargets(state, items) { 64 | state.search.targets = items; 65 | }, 66 | 67 | clearComponentCache(state) { 68 | state.components = []; 69 | }, 70 | 71 | clearPageCache(state) { 72 | state.pages = []; 73 | }, 74 | 75 | clearInspectorDataCache(state) { 76 | state.inspector.data = []; 77 | }, 78 | 79 | setComponent(state, component) { 80 | state.components.push(component); 81 | }, 82 | 83 | setPage(state, page) { 84 | state.pages.push(page); 85 | }, 86 | 87 | setInspectorData(state, inspectorData) { 88 | state.inspector.data.push(inspectorData); 89 | }, 90 | 91 | setInspectorSplit(state, sizes) { 92 | state.inspector.split = sizes; 93 | }, 94 | 95 | setPreviewSplit(state, sizes) { 96 | state.preview.split = sizes; 97 | }, 98 | 99 | setError(state, err) { 100 | if (err.status === 404 || err.response) { 101 | return; 102 | } 103 | state.error = err; 104 | }, 105 | 106 | clearError(state) { 107 | state.error = null; 108 | }, 109 | 110 | initialised(state) { 111 | state.initialised = true; 112 | } 113 | }, 114 | 115 | actions: { 116 | async socket_stateUpdated({ commit, dispatch }) { 117 | return dispatch('updateState'); 118 | }, 119 | 120 | socket_err({ commit }, err) { 121 | commit('setError', err[0]); 122 | }, 123 | 124 | async updateState({ commit }) { 125 | try { 126 | commit('clearComponentCache'); 127 | commit('clearPageCache'); 128 | commit('clearInspectorDataCache'); 129 | const [navigation, search] = await Promise.all([await axios.get('/api/navigation.json'), await axios.get('/api/search.json')]); 130 | commit('setNavigationItems', navigation.data.items); 131 | commit('setSearchTargets', search.data.components); 132 | commit('clearError'); 133 | commit('initialised', true); 134 | } catch (err) { 135 | commit('setError', err); 136 | throw err; 137 | } 138 | }, 139 | 140 | async fetchComponent({ commit, getters }, componentName) { 141 | try { 142 | const fetchedComponent = getters.getComponent(componentName); 143 | if (fetchedComponent) { 144 | return fetchedComponent; 145 | } 146 | const request = await axios.get(`/api/components/${componentName}.json`); 147 | const component = request.data; 148 | commit('setComponent', component); 149 | return component; 150 | } catch (err) { 151 | commit('setError', err); 152 | throw err; 153 | } 154 | }, 155 | 156 | async fetchPage({ commit, getters }, path) { 157 | try { 158 | const fetchedPage = getters.getPage(path); 159 | if (fetchedPage) { 160 | return fetchedPage; 161 | } 162 | const request = await axios.get(`/api/pages/${path}.json`); 163 | const page = request.data; 164 | commit('setPage', page); 165 | return page; 166 | } catch (err) { 167 | commit('setError', err); 168 | throw err; 169 | } 170 | }, 171 | 172 | async fetchInspectorData({ commit, getters }, { component, scenario }) { 173 | try { 174 | const fetchedInspectorData = getters.getInspectorData(component, scenario); 175 | if (fetchedInspectorData) { 176 | return fetchedInspectorData; 177 | } 178 | const request = await axios.get(`/api/inspect/${component}/${scenario}.json`); 179 | const data = { 180 | id: `${component}/${scenario}`, 181 | ...request.data 182 | }; 183 | commit('setInspectorData', data); 184 | return data; 185 | } catch (err) { 186 | commit('setError', err); 187 | throw err; 188 | } 189 | } 190 | }, 191 | 192 | getters: { 193 | isExpanded: state => item => { 194 | return state.nav.expandedIds.indexOf(item.id) > -1; 195 | }, 196 | 197 | getComponent: state => name => { 198 | return state.components.find(component => component.name === name); 199 | }, 200 | 201 | getPage: state => path => { 202 | return state.pages.find(page => page.path === path); 203 | }, 204 | 205 | getInspectorData: state => (componentName, scenarioName) => { 206 | return state.inspector.data.find(data => data.id === `${componentName}/${scenarioName}`); 207 | } 208 | } 209 | }); 210 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/inspector.js: -------------------------------------------------------------------------------- 1 | const { orderBy, merge, isString } = require('lodash'); 2 | const { titlize, resolveValue, mapValuesAsync } = require('@frctl/fractalite-support/utils'); 3 | const { getScenarioOrDefault, getScenario, getComponent } = require('@frctl/fractalite-core/helpers'); 4 | const { map } = require('asyncro'); 5 | 6 | module.exports = function(app, compiler, adapter, opts = {}) { 7 | let panels = []; 8 | 9 | app.extend({ 10 | addInspectorPanel(panel) { 11 | if (typeof panel.name !== 'string') { 12 | throw new TypeError(`Inspector panels must specify a .name property`); 13 | } 14 | 15 | const panelDefaults = { 16 | label: titlize(panel.name), 17 | position: panels.length + 1, 18 | renderServer: true, 19 | renderClient: false, 20 | props: {}, 21 | template: '' 22 | }; 23 | 24 | panel = merge({}, panelDefaults, panel); 25 | 26 | if (isString(panel.templateFile)) { 27 | panel.template = async () => { 28 | const tpl = await app.views.getTemplateAsync(panel.templateFile); 29 | return tpl.tmplStr; 30 | }; 31 | } 32 | 33 | if (panel.renderServer) { 34 | const tpl = panel.template; 35 | panel.template = async ctx => { 36 | const tplString = await resolveValue(tpl, ctx); 37 | return app.utils.renderPage(tplString, { ...ctx, panel }, { template: true }); 38 | }; 39 | } 40 | 41 | if (isString(panel.css)) { 42 | app.addCSS(panel.css); 43 | } 44 | 45 | if (isString(panel.js)) { 46 | app.addJS(panel.js); 47 | } 48 | 49 | panels.push(panel); 50 | return app; 51 | }, 52 | 53 | removeInspectorPanel(name) { 54 | panels = panels.filter(p => p.name !== name); 55 | return app; 56 | }, 57 | 58 | getInspectorPanels() { 59 | return orderBy(panels, 'position', 'asc'); 60 | } 61 | }); 62 | 63 | app.addRoute('api.inspect', '/api/inspect/:component/:scenario.json', async (ctx, next) => { 64 | const { component } = ctx; 65 | let scenario; 66 | 67 | try { 68 | scenario = getScenarioOrDefault(component, ctx.params.scenario); 69 | } catch (err) { 70 | err.status = 404; 71 | throw err; 72 | } 73 | 74 | const panels = await map(app.getInspectorPanels(), panel => { 75 | return mapValuesAsync(panel, value => resolveValue(value, { ...ctx.state, scenario, component })); 76 | }); 77 | 78 | ctx.body = { 79 | preview: await app.utils.renderPreview(component, scenario), 80 | panels: panels.filter(panel => panel.template) 81 | }; 82 | 83 | return next(); 84 | }); 85 | 86 | app.addBuilder((state, { request }) => { 87 | state.components.forEach(component => { 88 | component.scenarios.forEach(scenario => { 89 | request({ name: 'api.inspect', params: { component, scenario: scenario.name } }); 90 | }); 91 | }); 92 | }); 93 | 94 | app.addRoute('inspect', '/inspect/:component/:scenario?', (ctx, next) => ctx.render('app')); 95 | 96 | /* 97 | * Compiler middleware to add inspector url properties 98 | */ 99 | compiler.use(async (components, next) => { 100 | await next(); 101 | components.forEach(component => { 102 | component.url = app.url('inspect', { component: component.name, scenario: component.scenarios[0].name }); 103 | component.scenarios.forEach(scenario => { 104 | scenario.url = app.url('inspect', { component: component.name, scenario: scenario.name }); 105 | }); 106 | }); 107 | }); 108 | 109 | app.utils.addReferenceLookup('inspect', (state, identifier) => { 110 | const [componentName, scenarioName] = identifier.split('/'); 111 | const component = getComponent(state, componentName, true); 112 | const scenario = scenarioName ? getScenario(component, scenarioName, true) : component.scenarios[0]; 113 | return { 114 | url: app.url('inspect', { component, scenario: scenario.name }) 115 | }; 116 | }); 117 | 118 | return app; 119 | }; 120 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/nav.js: -------------------------------------------------------------------------------- 1 | const { dirname } = require('path'); 2 | const { merge, isBoolean, isFunction, isPlainObject, flatMap, uniqBy, orderBy, compact } = require('lodash'); 3 | const { titlize, slugify } = require('@frctl/fractalite-support/utils'); 4 | const { isComponent, isFile } = require('@frctl/fractalite-core/helpers'); 5 | 6 | module.exports = function(app, compiler, adapter, opts = {}) { 7 | const defaults = { 8 | label: str => str, 9 | items: defaultGenerator, 10 | scenarios: true 11 | }; 12 | 13 | if (Array.isArray(opts) || isFunction(opts)) { 14 | opts = { items: opts }; 15 | } 16 | 17 | opts = merge(defaults, opts); 18 | 19 | app.addRoute('api.navigation', '/api/navigation.json', ctx => { 20 | ctx.body = { 21 | opts, 22 | items: buildNav(opts.items, ctx.state) 23 | }; 24 | }); 25 | 26 | app.addBuilder((state, { request }) => request({ name: 'api.navigation' })); 27 | 28 | compiler.use(components => { 29 | components.forEach(component => { 30 | component.position = component.config.position || 1000; 31 | }); 32 | }); 33 | 34 | function generateLabel(str, target) { 35 | return isFunction(opts.label) ? opts.label(str, target) : str; 36 | } 37 | 38 | function buildNav(items, state) { 39 | items = isFunction(items) ? items(state, toTree) : items; 40 | return expandValues(items); 41 | } 42 | 43 | function expandValues(items, level = 0) { 44 | return flatMap(compact(items), item => { 45 | if (Array.isArray(item)) { 46 | return expandValues(item, level); 47 | } 48 | 49 | if (item.entity) { 50 | item = item.entity; 51 | } 52 | 53 | const entry = {}; 54 | 55 | if (isComponent(item)) { 56 | Object.assign(entry, { 57 | id: `component-${item.name}`, 58 | type: 'component', 59 | label: item.label, 60 | url: item.scenarios[0].url 61 | }); 62 | if (opts.scenarios) { 63 | entry.children = item.scenarios.map(scenario => { 64 | const entry = { 65 | id: `scenario-${item.name}-${scenario.name}`, 66 | type: 'scenario', 67 | url: scenario.url 68 | }; 69 | entry.label = generateLabel(scenario.label, entry, scenario); 70 | return entry; 71 | }); 72 | } 73 | } else if (isFile(item)) { 74 | Object.assign(entry, { 75 | id: item.handle, 76 | type: 'file', 77 | url: entry.url, 78 | label: item.handle 79 | }); 80 | } else { 81 | Object.assign(entry, { 82 | url: item.url, 83 | children: item.children ? expandValues(item.children, level + 1) : null, 84 | label: item.label || item.handle 85 | }); 86 | } 87 | 88 | if (isPlainObject(item.url)) { 89 | const { route, props } = item.url; 90 | entry.url = app.url(route, props); 91 | } 92 | 93 | entry.label = generateLabel(entry.label, entry, item); 94 | entry.id = slugify(entry.id || item.id || item.url || item.path || `level-${level}-${entry.label}`); 95 | entry.collapsable = isBoolean(item.collapsable) ? item.collapsable : true; 96 | entry.expanded = isBoolean(item.expanded) ? item.expanded : false; 97 | 98 | return entry; 99 | }); 100 | } 101 | }; 102 | 103 | function defaultGenerator({ components, pages }, toTree) { 104 | return [ 105 | toTree(pages), 106 | components.length === 0 107 | ? null 108 | : { 109 | label: 'Components', 110 | children: toTree(components) 111 | } 112 | ]; 113 | } 114 | 115 | function toTree(items, props = {}, pathProp) { 116 | let nodes = flatMap(items, item => { 117 | const path = item.treePath || item[pathProp] || ''; 118 | const nodes = makeNodes(path.trim('/')); 119 | const leaf = nodes[nodes.length - 1]; 120 | leaf.entity = item; 121 | leaf.position = item.position || leaf.position; 122 | return nodes; 123 | }); 124 | nodes = orderBy(uniqBy(nodes, 'path'), ['position'], ['asc']); 125 | 126 | return nodes 127 | .filter(node => node.depth === 1) 128 | .map(node => { 129 | node.children = getChildNodes(node, nodes); 130 | return node; 131 | }); 132 | } 133 | 134 | function getChildNodes(parent, nodes) { 135 | const children = nodes.filter(node => { 136 | return node.depth === parent.depth + 1 && dirname(node.path) === parent.path; 137 | }); 138 | for (const child of children) { 139 | child.children = getChildNodes(child, nodes); 140 | } 141 | return children; 142 | } 143 | 144 | function makeNodes(path = '') { 145 | const nodes = []; 146 | let tmpPath; 147 | const segments = []; 148 | for (const segment of path.split('/')) { 149 | const [, position = 10000, name] = segment.match(/^(?:(\d+)-)?(.*)$/); 150 | tmpPath = tmpPath ? `${tmpPath}/${segment}` : segment; 151 | segments.push(name); 152 | nodes.push({ 153 | name, 154 | position: parseInt(position, 10), 155 | label: titlize(name), 156 | path: tmpPath, 157 | depth: segments.length 158 | }); 159 | } 160 | return nodes; 161 | } 162 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/pages.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const { find, uniqBy, cloneDeep, isBoolean } = require('lodash'); 3 | const { normalizeSrc } = require('@frctl/fractalite-support/utils'); 4 | const { titlize } = require('@frctl/fractalite-support/utils'); 5 | const { read, watch } = require('@frctl/fractalite-core'); 6 | const { map } = require('asyncro'); 7 | const matter = require('gray-matter'); 8 | 9 | module.exports = function(app, compiler, adapter, opts = {}) { 10 | if (typeof opts === 'string' || Array.isArray(opts)) opts = { src: opts }; 11 | opts = cloneDeep(opts); 12 | opts.src = normalizeSrc(opts.src); 13 | opts.src.paths.push(resolve(__dirname, '../../pages')); 14 | 15 | let pages = []; 16 | 17 | app.utils.addReferenceLookup('page', (state, identifier) => { 18 | let page = state.pages.find(page => page.handle === identifier); 19 | if (!page) { 20 | page = state.pages.find(page => page.url === identifier); 21 | } 22 | return page; 23 | }); 24 | 25 | app.utils.parseFrontMatter = function(content, opts = {}) { 26 | return matter({ ...opts, content }); 27 | }; 28 | 29 | app.utils.renderPage = async function(str = '', props = {}, opts = {}) { 30 | const state = compiler.getState(); 31 | if (opts.template === true) { 32 | str = await app.views.renderStringAsync(str, { ...state, ...props }); 33 | } 34 | if (opts.refs === true) { 35 | str = app.utils.parseRefs(str); 36 | } 37 | if (opts.markdown === true) { 38 | str = app.utils.renderMarkdown(str); 39 | } 40 | return str; 41 | }; 42 | 43 | app.addRoute('api.page', '/api/pages/:path(.+).json', async (ctx, next) => { 44 | const page = find(pages, { indexPath: ctx.params.path }); 45 | if (page) { 46 | const content = await app.utils.renderPage( 47 | await page.getContents(), 48 | { page }, 49 | { 50 | markdown: page.markdown, 51 | template: page.template, 52 | refs: page.refs 53 | } 54 | ); 55 | ctx.body = { path: ctx.params.path, page, content }; 56 | } 57 | return next(); 58 | }); 59 | 60 | app.addBuilder((state, { request }) => { 61 | state.pages.forEach(page => { 62 | request({ name: 'api.page', params: { path: page.indexPath } }); 63 | }); 64 | }); 65 | 66 | app.addRoute('page', ':path(.*)', async (ctx, next) => { 67 | const page = find(pages, { urlPath: ctx.params.path }); 68 | if (page) { 69 | await ctx.render('app'); 70 | } 71 | return next(); 72 | }); 73 | 74 | app.beforeStart(async (app, state) => { 75 | try { 76 | if (app.mode === 'develop') { 77 | pages = await getPages(); 78 | state.pages = pages; 79 | watch(opts.src.paths, { ignoreInitial: true }).on('all', async (event, path) => { 80 | const hrStart = process.hrtime(); 81 | try { 82 | pages = await getPages(); 83 | state.pages = pages; 84 | const time = process.hrtime(hrStart); 85 | app.emit('state.updated', state, { event, path, time }); 86 | } catch (err) { 87 | app.emit('error', err); 88 | } 89 | }); 90 | } else if (app.mode === 'build') { 91 | pages = await getPages(); 92 | state.pages = pages; 93 | } 94 | } catch (err) { 95 | app.emit('error', err); 96 | } 97 | }); 98 | 99 | async function getPages() { 100 | const files = await read(opts.src.paths, { ...opts.src.opts, onlyFiles: true }); 101 | pages = await map(files, async file => { 102 | const { data, content } = app.utils.parseFrontMatter(await file.getContents(), opts.frontmatter); 103 | return makePage(file, content, data); 104 | }); 105 | return uniqBy(pages, 'url'); 106 | } 107 | 108 | function makePage(file, content, data) { 109 | const page = { ...data }; 110 | 111 | const segments = file.relative 112 | .replace(file.extname, '') 113 | .split('/') 114 | .map(segment => segment.match(/^(?:(\d+)-)?(.*)$/)[2]); 115 | 116 | let urlPath = segments.join('/'); 117 | if (urlPath === 'index' || /^.*\/index$/.test(urlPath)) { 118 | if (urlPath === 'index') { 119 | urlPath = ''; 120 | } 121 | page.index = true; 122 | page.label = page.label || opts.indexLabel || 'Overview'; 123 | page.position = page.position || 1; 124 | } else { 125 | page.label = page.label || titlize(file.name); 126 | } 127 | 128 | page.handle = page.handle || urlPath.replace('/', '-'); 129 | if (page.handle === '') { 130 | page.handle = 'index'; 131 | } 132 | 133 | page.title = page.title || page.label; 134 | page.treePath = urlPath; 135 | page.urlPath = '/' + urlPath; 136 | page.indexPath = (urlPath + (page.index ? '/index' : '')).replace(/^\//, ''); 137 | 138 | page.url = app.url('page', { path: page.urlPath }); 139 | 140 | page.layout = typeof page.layout === 'boolean' ? page.layout : true; 141 | 142 | page.refs = isBoolean(page.refs) ? page.refs : true; 143 | page.markdown = isBoolean(page.markdown) ? page.markdown : ['.md', '.markdown'].includes(file.ext); 144 | page.template = isBoolean(page.template) ? page.template : file.ext === '.njk'; 145 | 146 | page.raw = content; 147 | page.isPage = true; 148 | 149 | page.getContents = function() { 150 | return Promise.resolve(page.raw); 151 | }; 152 | return page; 153 | } 154 | }; 155 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/plugins/index.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | key: 'inspector.info', 4 | handler: require('./inspector-info') 5 | }, 6 | { 7 | key: 'inspector.html', 8 | handler: require('./inspector-html') 9 | }, 10 | { 11 | key: 'inspector.props', 12 | handler: require('./inspector-props') 13 | } 14 | ]; 15 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/plugins/inspector-html.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: "off" */ 2 | 3 | module.exports = function(opts = {}) { 4 | return function inspectorHTMLPlugin(app, compiler, adapter) { 5 | if (opts === false) return; 6 | 7 | const prettify = opts.prettify !== false; 8 | 9 | app.addInspectorPanel({ 10 | name: 'html', 11 | label: opts.label || 'HTML', 12 | renderClient: true, 13 | template: ` 14 | 15 | `, 16 | async props(state) { 17 | const { scenario, component } = state; 18 | let items = await adapter.renderAllToStaticMarkup(component, scenario.preview.props); 19 | items = items.map(item => (prettify ? app.utils.prettify(item, 'html') : item)); 20 | return { html: items }; 21 | } 22 | }); 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/plugins/inspector-info.js: -------------------------------------------------------------------------------- 1 | module.exports = function(opts = {}) { 2 | return function inspectorInfoPlugin(app) { 3 | if (opts === false) return; 4 | 5 | const className = 'inspector-panel-info'; 6 | 7 | app.addInspectorPanel({ 8 | name: 'info', 9 | label: opts.label, 10 | props: { className }, 11 | templateFile: 'plugins/inspector-info', 12 | css: ` 13 | .${className} { 14 | padding: 12px; 15 | } 16 | .${className}__header { 17 | display: flex; 18 | border-bottom: 1px solid #ddd; 19 | padding-bottom: 12px; 20 | margin-bottom: 20px; 21 | } 22 | .${className}__title { 23 | font-size: 20px; 24 | } 25 | .${className}__proptable a { 26 | text-decoration: none; 27 | } 28 | ` 29 | }); 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/plugins/inspector-props.js: -------------------------------------------------------------------------------- 1 | module.exports = function(opts = {}) { 2 | return function inspectorPropsPlugin(app) { 3 | if (opts === false) return; 4 | 5 | app.addInspectorPanel({ 6 | name: 'props', 7 | label: opts.label || 'Props', 8 | renderClient: true, 9 | template: ` 10 | 11 | `, 12 | props(state) { 13 | const { scenario } = state; 14 | const { props } = scenario.preview; 15 | return props.length > 1 ? props : props[0]; 16 | } 17 | }); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/public.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app, compiler, adapter, opts = {}) { 2 | if (typeof opts === 'string') { 3 | opts = { 4 | path: opts 5 | }; 6 | } 7 | 8 | if (opts.path) { 9 | app.addStaticDir('public', opts.path, opts.mount); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/references.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app, compiler, adapter, opts = {}) { 2 | if (opts === false) return; 3 | 4 | const refMatcher = opts.match || /{(.*?)}/g; 5 | 6 | const lookup = { 7 | component(state, identifier) { 8 | return state.components.find(c => c.name === identifier); 9 | }, 10 | file(state, identifier) { 11 | return state.files.find(c => c.handle === identifier); 12 | } 13 | }; 14 | 15 | app.utils.addReferenceLookup = (key, handler) => { 16 | lookup[key] = handler; 17 | return app; 18 | }; 19 | 20 | app.utils.parseRefs = str => { 21 | return str.replace(refMatcher, (matched, ref) => { 22 | const refParts = ref.split(':'); 23 | 24 | const [entity, identifier, prop = 'url'] = refParts; 25 | 26 | if (!lookup[entity]) { 27 | throw new Error(`Could not resolve reference tag - '${entity}' is not a recognised entity`); 28 | } 29 | try { 30 | const target = lookup[entity](compiler.getState(), identifier); 31 | if (!target) { 32 | throw new Error(`Could not resolve reference tag - '${entity}:${identifier}' not found`); 33 | } 34 | return target[prop] || ''; 35 | } catch (err) { 36 | app.emit('error', err); 37 | return ''; 38 | } 39 | }); 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/search.js: -------------------------------------------------------------------------------- 1 | const { flatMap } = require('lodash'); 2 | 3 | module.exports = function(app, compiler, adapter, opts = {}) { 4 | app.addRoute('api.search', '/api/search.json', ctx => { 5 | const components = flatMap(ctx.components, component => { 6 | const { search } = component; 7 | if (search.hidden) { 8 | return; 9 | } 10 | return { 11 | label: component.label, 12 | url: component.url, 13 | aliases: search.aliases || [], 14 | scenarios: component.scenarios.map(scenario => { 15 | return { 16 | label: scenario.label, 17 | url: scenario.url 18 | }; 19 | }) 20 | }; 21 | }).filter(component => component); 22 | ctx.body = { opts, components }; 23 | }); 24 | 25 | compiler.use(components => { 26 | const defaults = { 27 | hidden: false, 28 | aliases: [] 29 | }; 30 | components.forEach(component => { 31 | const searchOpts = component.config.search || {}; 32 | component.search = { ...defaults, ...searchOpts }; 33 | }); 34 | }); 35 | 36 | app.addBuilder((state, { request }) => request({ name: 'api.search' })); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/theme.js: -------------------------------------------------------------------------------- 1 | const { isString, isFunction, isPlainObject, forEach } = require('lodash'); 2 | 3 | module.exports = function(app, compiler, adapter, opts = {}) { 4 | if (isFunction(opts)) { 5 | opts = opts(app) || {}; 6 | } 7 | 8 | if (isString(opts.css)) { 9 | app.addCSS(opts.css); 10 | } 11 | 12 | if (isString(opts.js)) { 13 | app.addCSS(opts.js); 14 | } 15 | 16 | if (Array.isArray(opts.stylesheets)) { 17 | opts.stylesheets.forEach(stylesheet => { 18 | const { path, url } = app.utils.resolveAsset(stylesheet); 19 | app.addStylesheet(url, path); 20 | }); 21 | } 22 | 23 | if (Array.isArray(opts.scripts)) { 24 | opts.scripts.forEach(script => { 25 | const { path, url } = app.utils.resolveAsset(script); 26 | app.addScript(url, path); 27 | }); 28 | } 29 | 30 | if (isString(opts.views)) { 31 | app.addViewPath(opts.views); 32 | } 33 | 34 | if (isPlainObject(opts.vars)) { 35 | let css = ':root {\n'; 36 | forEach(opts.vars, (val, key) => { 37 | css += `--${key}: ${val};\n`; 38 | }); 39 | css += '}'; 40 | app.addCSS(css); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/utils/highlight.js: -------------------------------------------------------------------------------- 1 | const hljs = require('highlight.js'); 2 | const escape = require('escape-html'); 3 | 4 | module.exports = function() { 5 | return function highlightCode(str, lang = 'html') { 6 | if (lang && hljs.getLanguage(lang)) { 7 | try { 8 | const h = hljs.highlight(lang, str, true); 9 | return `
${h.value}
`; 10 | } catch (err) {} 11 | } 12 | return `
${escape(str)}
`; 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/utils/markdown.js: -------------------------------------------------------------------------------- 1 | const { defaultsDeep } = require('@frctl/fractalite-support/utils'); 2 | const Markdown = require('markdown-it'); 3 | 4 | module.exports = function(opts = {}) { 5 | const md = new Markdown( 6 | defaultsDeep(opts, { 7 | html: true, 8 | linkify: true, 9 | highlight: opts.highlight 10 | }) 11 | ); 12 | 13 | function renderMarkdown(str) { 14 | return md.render(str); 15 | } 16 | 17 | renderMarkdown.engine = md; 18 | 19 | return renderMarkdown; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/utils/prettify.js: -------------------------------------------------------------------------------- 1 | const prettier = require('prettier'); 2 | 3 | module.exports = function() { 4 | return function prettify(str, opts = 'babel') { 5 | if (typeof opts === 'string') { 6 | opts = { parser: opts }; 7 | } 8 | if (!opts.parser || opts.parser === 'js' || opts.parser === 'json') { 9 | opts.parser = 'babel'; 10 | } 11 | return prettier.format(str, opts); 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/fractalite/src/server/utils/resolve-asset.js: -------------------------------------------------------------------------------- 1 | const { basename } = require('path'); 2 | const { isPlainObject, isString } = require('lodash'); 3 | const { normalizePath } = require('@frctl/fractalite-support/utils'); 4 | 5 | module.exports = function(app) { 6 | return function resolveAsset(asset) { 7 | let path; 8 | let url; 9 | if (isString(asset)) { 10 | if (asset.startsWith('//') || asset.includes('://')) { 11 | url = asset; // Full URL, use as-is 12 | } else if (asset.includes(':')) { 13 | // Reference a file in a static directory 14 | url = app.resourceUrl(asset); 15 | } else { 16 | // Assume it's a path 17 | path = asset; 18 | } 19 | } else if (isPlainObject(asset)) { 20 | path = asset.path; 21 | url = asset.url; 22 | if (!path && !url) { 23 | throw new Error(`Cannot add asset - either a .url or a .path property must be defined`); 24 | } 25 | } else { 26 | throw new Error(`Cannot add asset - either a string or an object description is required`); 27 | } 28 | path = path ? normalizePath(path) : path; 29 | if (!url) { 30 | url = `/${basename(path)}`; 31 | } 32 | return { path, url }; 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /packages/fractalite/views/app.njk: -------------------------------------------------------------------------------- 1 | {% extends "layout" %} 2 | {% set loading = true %} 3 | 4 | {% block body %} 5 | 6 |
7 |
8 |
9 | {% include "partials/brand" %} 10 |
11 |
12 | 15 |
16 | 17 |
18 |
19 |
20 |
21 |
22 | 23 |
24 |
25 | {% endblock %} 26 | 27 | {% block preScripts %} 28 | 32 | {% for template in ['navigation', 'preview', 'inspector', 'page', 'error', 'search'] %} 33 | 36 | {% endfor %} 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /packages/fractalite/views/error.njk: -------------------------------------------------------------------------------- 1 | {% extends "layout" %} 2 | 3 | {% block body %} 4 |
5 |
6 |

{{ error.title | default('An error occured') }}

7 |

{{ error.message }}

8 | {% if error.status != 404 %} 9 |
{{ error.stack }}
10 | {% endif %} 11 |
12 |
13 | {% endblock %} 14 | 15 | {% block postScripts %} 16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /packages/fractalite/views/layout.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {%- block preStyles %}{% endblock %} 8 | {%- block styles %} 9 | {% for url in stylesheets -%} 10 | 11 | {%- endfor %} 12 | {% endblock -%} 13 | {% block postStyles %}{% endblock -%} 14 | 15 | {% block title %}{% if page.title %}{{ page.title }} | {% endif %}{{ app.meta.title }}{% endblock %} 16 | 17 | 18 | 19 |
20 | {%- block preBody %}{% endblock %} 21 | {% block body %}{% endblock %} 22 | {%- block postbody %}{% endblock %} 23 |
24 | 25 | {%- block preScripts %}{% endblock %} 26 | {%- block scripts %} 27 | {% for url in scripts -%} 28 | 29 | {%- endfor %} 30 | {% endblock -%} 31 | {%- block postScripts %}{% endblock -%} 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /packages/fractalite/views/partials/brand.njk: -------------------------------------------------------------------------------- 1 |
2 | {{ app.title }} 3 |
4 | -------------------------------------------------------------------------------- /packages/fractalite/views/plugins/inspector-info.njk: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ component.label }}

4 |
5 |
6 |

Scenarios:

7 |
8 | {% for scene in component.scenarios %} 9 | 17 | {% endfor %} 18 |
19 |

Files:

20 |
21 | {% for file in component.files %} 22 |
23 |
24 | {{ file.componentPath }} 25 |
26 |
27 | {{ file.size }} 28 |
29 |
30 | View 31 |
32 |
33 | {% endfor %} 34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /packages/fractalite/views/preview.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% for url in stylesheets %} 7 | 8 | {% endfor %} 9 | {% if css %} 10 | 13 | {% endif %} 14 | {% block title %}{{ meta.title | default(component.label + ' Preview') }}{% endblock %} 15 | 16 | 17 | {% block content %} 18 | {{ content | safe }} 19 | {% endblock %} 20 | {% for url in scripts %} 21 | 22 | {% endfor %} 23 | {% if js %} 24 | 27 | {% endif %} 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/fractalite/views/vue/error.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ name }}

4 |

{{ message }}

5 |
{{ stack }}
6 |
7 |
8 | -------------------------------------------------------------------------------- /packages/fractalite/views/vue/inspector.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |
6 | 11 |
12 |
13 | 21 |
22 |
23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /packages/fractalite/views/vue/navigation.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /packages/fractalite/views/vue/page.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ page.title }}

4 |
5 |
6 |
7 |
8 | -------------------------------------------------------------------------------- /packages/fractalite/views/vue/preview.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
{{ previewWidth }}px x {{ previewHeight }}px
6 |
7 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /packages/fractalite/views/vue/search.html: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /packages/plugin-assets-bundler/README.md: -------------------------------------------------------------------------------- 1 | ## @frctl/fractalite-plugin-assets-bundler 2 | 3 | Uses [Parcel](https://parceljs.org) to handle asset bundling for Fractal component libraries. 4 | 5 | #### 1. Install in your project: 6 | 7 | ```bash 8 | npm i --save-dev @frctl/fractalite-plugin-assets-bundler 9 | ``` 10 | 11 | #### 2. Add plugin to Fractal config file: 12 | 13 | ```js 14 | // fractal.config.js 15 | module.exports = { 16 | // ... 17 | plugins: [ 18 | require('@frctl/fractalite-plugin-assets-bundler')({ 19 | entryFile: './src/preview.js', 20 | outFile: './dist/build.js' 21 | }) 22 | ] 23 | }; 24 | ``` 25 | 26 | #### 3. Create entry file: 27 | 28 | ```js 29 | // ./assets/preview.js 30 | import '../components/**/*.scss' 31 | import button from '../components/@button/button.js' 32 | ``` 33 | 34 | > See the Parcel docs on module resolution for more info on paths, globbing and aliases: https://parceljs.org/module_resolution.html 35 | 36 | #### 4. View your components... 37 | 38 | The asset bundler handles serving assets and injecting them into your component previews so no further configuration is needed. 39 | -------------------------------------------------------------------------------- /packages/plugin-assets-bundler/index.js: -------------------------------------------------------------------------------- 1 | const { dirname, basename, relative } = require('path'); 2 | const slash = require('slash'); 3 | const Bundler = require('parcel-bundler'); 4 | const prettier = require('prettier'); 5 | const { outputFile } = require('fs-extra'); 6 | 7 | module.exports = function(opts = {}) { 8 | return function(app) { 9 | app.beforeStart(async () => { 10 | const entryDir = dirname(opts.entryFile); 11 | const outDir = dirname(opts.outFile); 12 | const publicUrl = opts.publicUrl || '/assets'; 13 | 14 | // Serve any assets generated in the output dir 15 | app.addStaticDir('bundled-assets', outDir, publicUrl); 16 | 17 | // If an entry generator is provided, use that to 18 | // dynamically create (and re-create) the entry file 19 | // when the compiler state changes 20 | await generateEntry(app.compiler.getState()); 21 | 22 | let bundler = await createBundler(true); 23 | 24 | // When glob imports are used Parcel does not 25 | // correctly recognise when files are added/removed, 26 | // only when changed. This is the equivalent of manually 27 | // stopping and restarting the watch task/hmr server. 28 | app.on('state.updated', async (state, { event }) => { 29 | if (event === 'add' || event === 'unlink') { 30 | if (bundler) { 31 | try { 32 | await bundler.stop(); 33 | } catch (err) { 34 | app.emit(err); 35 | } 36 | await generateEntry(state); 37 | bundler = await createBundler(); 38 | app.socket.broadcast('refresh'); 39 | } 40 | } 41 | }); 42 | 43 | async function generateEntry(state) { 44 | if (typeof opts.entryBuilder !== 'function') { 45 | return; 46 | } 47 | try { 48 | const entry = await opts.entryBuilder(state, { dir: entryDir }); 49 | await outputFile(opts.entryFile, prettier.format(entry, { parser: 'babel' })); 50 | } catch (err) { 51 | app.emit('error', err); 52 | } 53 | } 54 | 55 | async function createBundler(initial = false) { 56 | const bundler = new Bundler(opts.entryFile, { 57 | outDir, 58 | outFile: basename(opts.outFile), 59 | watch: app.mode === 'develop', 60 | logLevel: 3, 61 | publicUrl, 62 | hmrHostname: opts.hmrHostname || 'localhost', 63 | hmr: app.mode === 'develop' ? opts.hmr !== false : false 64 | }); 65 | 66 | if (initial) { 67 | bundler.on('buildEnd', () => { 68 | if (opts.hmr === false) { 69 | app.socket.broadcast('refresh'); 70 | } 71 | }); 72 | } 73 | 74 | const bundle = await bundler.bundle(); 75 | 76 | if (initial) { 77 | const bundles = addBundles(bundle); 78 | 79 | const assets = bundles.map(bundle => { 80 | return { 81 | url: `${publicUrl}/${slash(relative(outDir, bundle.name))}`, 82 | type: bundle.type 83 | }; 84 | }); 85 | 86 | if (opts.addToPreview !== false) { 87 | // Automatically add the bundled scripts 88 | // and stylesheets to the preview 89 | for (const { type, url } of assets) { 90 | if (type === 'css') { 91 | app.addPreviewStylesheet(url); 92 | } 93 | if (type === 'js') { 94 | app.addPreviewScript(url); 95 | } 96 | } 97 | } 98 | } 99 | 100 | return bundler; 101 | 102 | function addBundles(bundle, bundles = []) { 103 | if (['js', 'css'].includes(bundle.type)) { 104 | bundles.push(bundle); 105 | } 106 | for (const childBundle of bundle.childBundles) { 107 | addBundles(childBundle, bundles); 108 | } 109 | return bundles; 110 | } 111 | } 112 | }); 113 | }; 114 | }; 115 | -------------------------------------------------------------------------------- /packages/plugin-assets-bundler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-plugin-assets-bundler", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "fs-extra": "^7.0.1", 6 | "parcel-bundler": "^1.11.0", 7 | "prettier": "^1.16.4", 8 | "slash": "^2.0.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/plugin-notes/README.md: -------------------------------------------------------------------------------- 1 | ## @frctl/fractalite-plugin-notes 2 | 3 | Adds an inspector panel to display component notes. 4 | -------------------------------------------------------------------------------- /packages/plugin-notes/index.js: -------------------------------------------------------------------------------- 1 | const { defaultsDeep } = require('@frctl/fractalite-support/utils'); 2 | const { rewriteUrls } = require('@frctl/fractalite-support/html'); 3 | const stripIndent = require('strip-indent'); 4 | 5 | module.exports = function(opts = {}) { 6 | return function inspectorNotesPlugin(app, compiler) { 7 | if (opts.notesFile) { 8 | /* 9 | * Compiler middleware to read notes content from notes files 10 | */ 11 | compiler.use(async components => { 12 | const filePath = typeof opts.notesFile === 'string' ? opts.notesFile : 'notes.md'; 13 | await Promise.all( 14 | components.map(async component => { 15 | const notesFile = component.files.find(file => file.relative === filePath); 16 | if (notesFile) { 17 | component.notes = await notesFile.getContents(); 18 | } 19 | }) 20 | ); 21 | }); 22 | } 23 | 24 | /* 25 | * Compiler middleware to extract notes from config. 26 | * Will override notes from the notesFile if supplied. 27 | */ 28 | compiler.use(components => { 29 | components.forEach(component => { 30 | if (component.config.notes) { 31 | component.notes = stripIndent(component.config.notes); 32 | } 33 | }); 34 | }); 35 | 36 | app.addInspectorPanel({ 37 | name: 'notes', 38 | label: opts.label, 39 | renderServer: false, 40 | async template(state) { 41 | const { component } = state; 42 | let notes; 43 | 44 | if (typeof component.notes === 'string') { 45 | /* 46 | * Notes are parsed in the same way as pages and can contain frontmatter 47 | * and reference tags. 48 | */ 49 | const { content, data } = app.utils.parseFrontMatter(component.notes); 50 | 51 | const renderOpts = defaultsDeep(data, { 52 | markdown: true, 53 | template: false, 54 | refs: true 55 | }); 56 | 57 | notes = await app.utils.renderPage(content, {}, renderOpts); 58 | 59 | // Rewrite relative URLs to src files in output 60 | notes = rewriteUrls(notes, path => { 61 | if (path.startsWith('./')) { 62 | const file = component.files.find(file => `./${file.relative}` === path); 63 | return file ? app.url('src', { file }) : path; 64 | } 65 | }); 66 | } else { 67 | notes = 'No notes available'; 68 | } 69 | 70 | return `
${notes}
`; 71 | }, 72 | 73 | css: ` 74 | .inspector-panel-notes { 75 | padding: 12px; 76 | } 77 | ` 78 | }); 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /packages/plugin-notes/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-plugin-notes", 3 | "version": "0.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "strip-indent": { 8 | "version": "2.0.0", 9 | "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", 10 | "integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/plugin-notes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-plugin-notes", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "@frctl/fractalite-support": "^0.0.0", 6 | "strip-indent": "^2.0.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/support/README.md: -------------------------------------------------------------------------------- 1 | ## @frctl/fractalite-support 2 | 3 | Utilities and helpers for Fractalite package development. 4 | -------------------------------------------------------------------------------- /packages/support/html.js: -------------------------------------------------------------------------------- 1 | const attributes = require('html-url-attributes'); 2 | 3 | const urlAttrs = new RegExp( 4 | // eslint-disable-next-line no-useless-escape 5 | `(${Object.keys(attributes).join('|')})\=([\"\'])([^\"\']*)([\"\'])`, 6 | 'gi' 7 | ); 8 | 9 | module.exports = { 10 | rewriteUrls(str, replacer) { 11 | return str.replace(urlAttrs, (...args) => { 12 | const [matched, attr, quoteOpen, path, quoteClose] = args; 13 | const newPath = replacer(path, { attr, matched, quoteOpen, quoteClose }); 14 | if (newPath) { 15 | return `${attr}=${quoteOpen}${newPath}${quoteClose}`; 16 | } 17 | return matched; 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /packages/support/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | utils: require('./utils'), 3 | html: require('./html'), 4 | loadConfig: require('./load-config') 5 | }; 6 | -------------------------------------------------------------------------------- /packages/support/load-config.js: -------------------------------------------------------------------------------- 1 | const cosmiconfig = require('cosmiconfig'); 2 | 3 | module.exports = async function(opts = {}) { 4 | let configFile; 5 | 6 | if (opts.path) { 7 | const configFinder = cosmiconfig(opts.name || 'fractal'); 8 | configFile = await configFinder.load(opts.path); 9 | } else { 10 | const configFinder = cosmiconfig(opts.name || 'fractal', { 11 | stopDir: opts.cwd || process.cwd() 12 | }); 13 | configFile = await configFinder.search(); 14 | } 15 | 16 | if (!configFile) { 17 | const error = new Error('Config file not found.'); 18 | error.type = 'CONFIG_NOT_FOUND'; 19 | throw error; 20 | } 21 | 22 | return { 23 | path: configFile.filepath, 24 | config: configFile.config 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/support/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-support", 3 | "version": "0.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "argparse": { 8 | "version": "1.0.10", 9 | "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", 10 | "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", 11 | "requires": { 12 | "sprintf-js": "~1.0.2" 13 | } 14 | }, 15 | "caller-callsite": { 16 | "version": "2.0.0", 17 | "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", 18 | "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=", 19 | "requires": { 20 | "callsites": "^2.0.0" 21 | } 22 | }, 23 | "caller-path": { 24 | "version": "2.0.0", 25 | "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", 26 | "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=", 27 | "requires": { 28 | "caller-callsite": "^2.0.0" 29 | } 30 | }, 31 | "callsites": { 32 | "version": "2.0.0", 33 | "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", 34 | "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=" 35 | }, 36 | "cosmiconfig": { 37 | "version": "5.0.7", 38 | "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.0.7.tgz", 39 | "integrity": "sha512-PcLqxTKiDmNT6pSpy4N6KtuPwb53W+2tzNvwOZw0WH9N6O0vLIBq0x8aj8Oj75ere4YcGi48bDFCL+3fRJdlNA==", 40 | "requires": { 41 | "import-fresh": "^2.0.0", 42 | "is-directory": "^0.3.1", 43 | "js-yaml": "^3.9.0", 44 | "parse-json": "^4.0.0" 45 | } 46 | }, 47 | "error-ex": { 48 | "version": "1.3.2", 49 | "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", 50 | "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", 51 | "requires": { 52 | "is-arrayish": "^0.2.1" 53 | } 54 | }, 55 | "esprima": { 56 | "version": "4.0.1", 57 | "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", 58 | "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" 59 | }, 60 | "html-url-attributes": { 61 | "version": "1.0.0", 62 | "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-1.0.0.tgz", 63 | "integrity": "sha1-e0yOlalf3pk6N1vhoWK7LLBY58I=" 64 | }, 65 | "import-fresh": { 66 | "version": "2.0.0", 67 | "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", 68 | "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", 69 | "requires": { 70 | "caller-path": "^2.0.0", 71 | "resolve-from": "^3.0.0" 72 | } 73 | }, 74 | "is-arrayish": { 75 | "version": "0.2.1", 76 | "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", 77 | "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" 78 | }, 79 | "is-directory": { 80 | "version": "0.3.1", 81 | "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", 82 | "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=" 83 | }, 84 | "js-yaml": { 85 | "version": "3.12.1", 86 | "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.1.tgz", 87 | "integrity": "sha512-um46hB9wNOKlwkHgiuyEVAybXBjwFUV0Z/RaHJblRd9DXltue9FTYvzCr9ErQrK9Adz5MU4gHWVaNUfdmrC8qA==", 88 | "requires": { 89 | "argparse": "^1.0.7", 90 | "esprima": "^4.0.0" 91 | } 92 | }, 93 | "json-parse-better-errors": { 94 | "version": "1.0.2", 95 | "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", 96 | "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" 97 | }, 98 | "lodash": { 99 | "version": "4.17.11", 100 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", 101 | "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" 102 | }, 103 | "parse-json": { 104 | "version": "4.0.0", 105 | "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", 106 | "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", 107 | "requires": { 108 | "error-ex": "^1.3.1", 109 | "json-parse-better-errors": "^1.0.1" 110 | } 111 | }, 112 | "resolve-from": { 113 | "version": "3.0.0", 114 | "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", 115 | "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=" 116 | }, 117 | "slash": { 118 | "version": "2.0.0", 119 | "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", 120 | "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==" 121 | }, 122 | "slugify": { 123 | "version": "1.3.1", 124 | "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.3.1.tgz", 125 | "integrity": "sha512-6BwyhjF5tG5P8s+0DPNyJmBSBePG6iMyhjvIW5zGdA3tFik9PtK+yNkZgTeiroCRGZYgkHftFA62tGVK1EI9Kw==" 126 | }, 127 | "sprintf-js": { 128 | "version": "1.0.3", 129 | "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", 130 | "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" 131 | }, 132 | "titlecase": { 133 | "version": "1.1.2", 134 | "resolved": "https://registry.npmjs.org/titlecase/-/titlecase-1.1.2.tgz", 135 | "integrity": "sha1-eBE9EQgIa4MmMxoyR96o9aSeqFM=" 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /packages/support/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@frctl/fractalite-support", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "cosmiconfig": "^5.0.6", 6 | "html-url-attributes": "^1.0.0", 7 | "lodash": "^4.17.10", 8 | "slash": "^2.0.0", 9 | "slugify": "^1.3.1", 10 | "titlecase": "^1.1.2" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/support/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const _ = require('lodash'); 3 | const slug = require('slugify'); 4 | const titlecase = require('titlecase'); 5 | const slash = require('slash'); 6 | 7 | const utils = { 8 | addTrailingSeparator(str) { 9 | return path.join(str, path.sep); 10 | }, 11 | 12 | removeTrailingSeparator(str) { 13 | while (utils.endsInSeparator(str)) { 14 | str = str.slice(0, -1); 15 | } 16 | return str; 17 | }, 18 | 19 | endsInSeparator(str) { 20 | const last = str[str.length - 1]; 21 | return str.length > 1 && (last === '/' || (utils.isWin() && last === '\\')); 22 | }, 23 | 24 | normalizePath(filePath, cwd) { 25 | // Assert.string(filePath, `Path must be a string. Received '${typeof filePath}' [paths-invalid]`); 26 | cwd = cwd || process.cwd(); 27 | if (!path.isAbsolute(filePath)) { 28 | filePath = path.join(cwd, filePath); 29 | } 30 | filePath = utils.removeTrailingSeparator(filePath); 31 | return path.normalize(filePath); 32 | }, 33 | 34 | normalizePaths(paths, cwd) { 35 | paths = utils.toArray(paths); 36 | return paths.map(filePath => utils.normalizePath(filePath, cwd)); 37 | }, 38 | 39 | normalizeName(str) { 40 | return _.kebabCase(str.toLowerCase().replace(/^[^a-z]*/, '')); 41 | }, 42 | 43 | normalizeExt(ext) { 44 | return `.${ext.toLowerCase().replace(/^\./, '')}`; 45 | }, 46 | 47 | permalinkify(str, opts = {}) { 48 | const indexes = opts.indexes || false; 49 | let permalink = _.trim(slash(str), '/'); 50 | if (opts.ext !== false) { 51 | const fallbackExt = utils.normalizeExt(opts.ext || '.html'); 52 | permalink = permalink === '' ? 'index' + fallbackExt : permalink; 53 | if (!path.extname(permalink)) { 54 | permalink += (indexes && !/\/?index$/.test(permalink) ? '/index' : '') + fallbackExt; 55 | } 56 | } 57 | if (opts.prefix) { 58 | permalink = _.trim(opts.prefix, '/') + '/' + permalink; 59 | } 60 | return '/' + permalink; 61 | }, 62 | 63 | slugify(str, replacement) { 64 | return slug(str.toLowerCase(), replacement || '-'); 65 | }, 66 | 67 | titlize(str) { 68 | return titlecase(str.replace(/[-_]/g, ' ')); 69 | }, 70 | 71 | isWin() { 72 | return process.platform === 'win32'; 73 | }, 74 | 75 | defaultsDeep(...args) { 76 | const output = {}; 77 | let customizer; 78 | args = _.compact(args); 79 | if (typeof args[args.length - 1] === 'function') { 80 | const fn = args.pop(); 81 | customizer = (defaultValue, targetValue, ...other) => fn(targetValue, defaultValue, ...other); 82 | } else { 83 | customizer = (defaultValue, targetValue) => { 84 | if (Array.isArray(targetValue)) { 85 | // Don't merge arrays - the target array overrides the default value 86 | return targetValue; 87 | } 88 | if (_.isFunction(defaultValue) && _.isObject(targetValue)) { 89 | return targetValue; 90 | } 91 | }; 92 | } 93 | const items = args.reverse().map(item => _.cloneDeep(item)); 94 | items.forEach(item => _.mergeWith(output, item, customizer)); 95 | return output; 96 | }, 97 | 98 | assign(target, ...sources) { 99 | sources.forEach(source => { 100 | const descriptors = Object.keys(source).reduce((descriptors, key) => { 101 | descriptors[key] = Object.getOwnPropertyDescriptor(source, key); 102 | return descriptors; 103 | }, {}); 104 | Object.getOwnPropertySymbols(source).forEach(sym => { 105 | const descriptor = Object.getOwnPropertyDescriptor(source, sym); 106 | if (descriptor.enumerable) { 107 | descriptors[sym] = descriptor; 108 | } 109 | }); 110 | Object.defineProperties(target, descriptors); 111 | }); 112 | return target; 113 | }, 114 | 115 | toArray(args) { 116 | if (args === null || args === undefined) { 117 | return []; 118 | } 119 | return [].concat(args); 120 | }, 121 | 122 | resolveValue(value, ctx) { 123 | return Promise.resolve(_.isFunction(value) ? value(ctx) : value); 124 | }, 125 | 126 | async mapValuesAsync(obj, asyncFn) { 127 | const keys = Object.keys(obj); 128 | const promises = keys.map(async key => { 129 | return { key, value: await asyncFn(obj[key]) }; 130 | }); 131 | const values = await Promise.all(promises); 132 | const newObj = {}; 133 | values.forEach(v => { 134 | newObj[v.key] = v.value; 135 | }); 136 | return newObj; 137 | }, 138 | 139 | normalizeSrc(src, defaults = {}) { 140 | if (_.isString(src) || Array.isArray(src)) { 141 | src = { 142 | paths: utils.toArray(src) 143 | }; 144 | } 145 | src = utils.defaultsDeep(src, defaults, { opts: {} }); 146 | src.paths = utils.normalizePaths(src.paths); 147 | return src; 148 | } 149 | }; 150 | 151 | module.exports = utils; 152 | -------------------------------------------------------------------------------- /test/helpers/generate-components.js: -------------------------------------------------------------------------------- 1 | const { resolve, join } = require('path'); 2 | const { outputFile } = require('fs-extra'); 3 | const { source, html } = require('common-tags'); 4 | const { map } = require('asyncro'); 5 | 6 | const testComponentsDir = resolve(__dirname, '../fixtures/generated'); 7 | 8 | const nestedProps = JSON.stringify( 9 | { 10 | level1: { 11 | level2: { 12 | level3: { 13 | item1: 'foo', 14 | item2: 'bar', 15 | item3: ['foo', 'bar', 'baz'] 16 | } 17 | } 18 | } 19 | }, 20 | null, 21 | 2 22 | ); 23 | 24 | const svgExpand = ``; 25 | const svgCollapse = ''; 26 | 27 | async function generateComponents(componentCount = 100) { 28 | const nums = Array.from({ length: componentCount }, (_, k) => k + 1); 29 | return map(nums, count => { 30 | const name = `component-${count}`; 31 | const dirPath = join(testComponentsDir, `@${name}`); 32 | const configContents = source` 33 | module.exports = { 34 | position: ${count}, 35 | scenarios: [ 36 | { 37 | name: 'component-${count}-1', 38 | props: { 39 | num: '${count}-1', 40 | collapseIcon: './collapse.svg', 41 | nested: ${nestedProps} 42 | } 43 | }, 44 | { 45 | name: 'component-${count}-2', 46 | props: { 47 | num: '${count}-2', 48 | collapseIcon: './collapse.svg', 49 | nested: ${nestedProps} 50 | } 51 | }, 52 | { 53 | name: 'component-${count}-3', 54 | props: { 55 | num: '${count}-3', 56 | collapseIcon: './collapse.svg', 57 | nested: ${nestedProps} 58 | } 59 | } 60 | ] 61 | }; 62 | `; 63 | const viewContents = html` 64 |
65 |

Component {num}

66 | 67 | 68 |
69 | `; 70 | return Promise.all([ 71 | outputFile(join(dirPath, `${name}.config.js`), configContents), 72 | outputFile(join(dirPath, `view.html`), viewContents), 73 | outputFile(join(dirPath, `expand.svg`), svgExpand), 74 | outputFile(join(dirPath, `collapse.svg`), svgCollapse) 75 | ]); 76 | }); 77 | } 78 | 79 | (async () => { 80 | await generateComponents(100); 81 | console.log('Components generated'); 82 | })(); 83 | --------------------------------------------------------------------------------