A prototype to help explore development ideas for the next version of Fractal.
Features & Status | Demo | Documentation
Development work on a potential Fractal v2 release was halted a while ago after it became clear that it incorporated too many conceptual changes from v1 and the codebase had become too large and unwieldy.
This prototype has been create to explore a middle ground, (hopefully) incorporating many of the v2 improvements into a package that is conceptually much closer to the current fractal v1.x release.
The aim of this prototype is to provide the ground work for the next version of Fractal. It's very much a work in progress and we'd love to get as much input from the community as possible to help shape that process.
Please let us know your thoughts by opening a new issue using the 'Feedback' issue template or by jumping into the #fractalite
channel in the Fractal community Discord to discuss. Feel free to message @mark
with any questions you might have!
This prototype is in the very early 'developer preview' stages and is currently focussed on development direction alongside plugin and adapter APIs.
Feedback, comments and/or pull requests on all aspects of the prototype are however always welcome!
The most full-featured demo is the Nunjucks demo. It uses the Nunjucks adapter alongside the Asset Bundler and Notes plugins.
The source code for the Nunjucks demo contains commented examples of some of the main features of this prototype and is worth investigating in conjunction with the web UI.
npm install
- install top-level dependenciesnpm run bootstrap
: bootstrap packages together (may take some time on first run!)npm run demo
- Start the development serverChanges to the Nunjucks components will be instantly reflected in the UI.
npm run demo:build
- Export flat-file version of the app & serve the dist
directoryExported files can be found in the demos/nunjucks/build
directory after export.
As the name suggests, the static build is not regenerated when component files are updated.
A hosted version of the static build can be found here
npm run demo:vue
Below is some preliminary documentation to help get across some of the key aspects of the Fractalite prototype.
This documentation assumes good knowledge of Fractal (v1) concepts and is not intended as a starter guide!
Note: Fractalite is not currently published to NPM. The following steps are for information purposes only until published.
Install via NPM:
npm i @frctl/fractalite --save-dev
Add the following NPM scripts to the project package.json
file:
{
"scripts": {
"start": "fractalite start --port 3333",
"build": "fractalite build"
}
}
You can now start the Fractalite app by running the npm start
command from within the project directory.
Fractalite config is kept in a fractal.config.js
file in the project root directory. Only the components
property is required.
// fractal.config.js
const { resolve } = require('path');
module.exports = {
// absolute path to the components directory
components: resolve(__dirname, './src/components'),
};
The Nunjucks demo contains a annotated example of a project configuration file that contains more detail on the available options.
Each Fractalite component is a directory containing one or more files.
In order for Fractalite to correctly identify a directory as a component, it must include at least one of the following:
view.*
or *.view.*
(i.e. view.html
or button.view.njk
)config.*
or *.config.*
(i.e. config.yml
or button.config.js
)package.json
fileA component directory can then also include any number of other related files and folders as required.
The file structure for a basic Nunjucks button component might look something like this:
button
βββ button.config.js
βββ button.css
βββ view.njk
Component scenarios are a key concept in Fractalite.
A scenario provides an example implementation of the component by supplying a set of properties to render the component with.
Scenarios are very similar to the concept of
variants
in Fractal v1, but refined and renamed to better suit the way in which v1 variants have been used in practice. They can also be thought of as similar to the 'story' concept in StorybookJS.
For example, a common use for a button
component might be as a 'next' control. A simple scenario object representing that might look as follows:
{
name: 'next', // reference name
label: 'Next step', // display in UI navigation
props: {
text: 'Go Next',
icon: './arrow-right.png'
}
}
props
are similar to thecontext
object in Fractal v1
Scenarios are defined in the component config file. The Fractalite UI creates a component preview for each scenario defined for that component.
Any relative paths to assets in the scenario props
object are resolved to full URLs before rendering.
Sometimes you may want to render multiple instances of the same scenario in one preview window - say for example to test the next
button scenario with labels of differing lengths:
To support this, each scenario can define a preview
property as an array of props. Each item in this array will be merged with the default scenario props, and the preview will render one instance for each set of merged props.
{
name: 'next',
props: {
text: 'Go Next',
icon: './arrow-right.png'
},
preview: [
{
label: 'Next'
},
{
label: 'Next button with a long label that might wrap'
}
]
}
The preview for this scenario will have two buttons in it, one for each of the preview items defined.
See the Previews section for details on how to customise the preview markup.
Template engine adapters such as the Nunjucks adapter support including sub-components with scenario properties provided as their default property values:
{% component 'button' %} <!-- include `button` component with no props -->
{% component 'button/next' %} <!-- include `button` component with props from `next` scenario -->
{% component 'button/next', { text: 'Forwards' } %} <!-- include `button` component with props from `next` scenario merged with inline props -->
Component config files can be JSON, YAML or CommonJS module format, although the latter is recommended for flexibility.
Config files must be named config.{ext}
or {component-name}.config.{ext}
- for example button.config.js
or config.yml
.
CommonJS formatted files should export a configuration object:
// button/button.config.js
module.exports = {
label: 'A basic button',
// other config here...
};
See the demo button component for an annotated example of some of the available config options.
label
[string]
The text that should be used to refer to the component in the UI. [Defaults to a title-cased version of the component name.]
scenarios
[array]
An array of scenario objects.
// button/button.config.js
module.exports = {
scenarios: [
{
name: 'next',
props: {
text: 'Go Next',
icon: './arrow-right.png'
}
},
{
name: 'prev',
props: {
text: 'Go Prev',
icon: './arrow-left.png'
}
}
]
}
View templates are template-engine specific files that contain the code required to render the component.
Fractalite adapters are responsible for determining how view templates are named, rendered and for any other framework/engine related integration details.
However, in the case of 'simple' template engines such as Nunjucks or Handlebars, views are typically templated fragments of HTML as in the following (Nunjucks) example:
<!-- button/view.njk -->
<a class="button" href="https://github.com/frctl/fractalite/blob/master/{{ href }}">
<span class="button__text">{{ text }}</span>
</a>
More complex frameworks such as Vue or React may have different requirements and feature support will be determined by the adapter used.
Referencing local component assets in view templates can be done via relative paths:
button
βββ next-arrow.png
βββ view.njk
<!-- view.njk -->
<img src="https://github.com/frctl/fractalite/raw/master/next-arrow.png">
Any relative paths in html attributes that expect a URL value will be dynamically rewritten to reference the asset correctly.
Rendered component instances are wrapped in an HTML document for previewing.
A typical project will need to be configured to inject the required styles and scripts into the preview to correctly display the component.
The assets bundler plugin automatically injects bundled assets into previews so this step may not be needed if using it in your project.
The preview
option in the project config file lets you specify scripts and stylesheets to be injected into to all component previews.
// fractal.config.js
const { resolve } = require('path');
module.exports = {
// ...
preview: {
/*
* Assets can be specified as either:
*
* 1) an absolute path to the file
* 2) an external URL
* 3) or an object with 'url' and 'path' keys
*/
stylesheets: [
resolve(__dirname, './dist/styles.css'), // (1)
'http://example.com/external-styles.css', // (2)
{
url: '/custom/url/path.css',
path: resolve(__dirname, './dist/yet-more-styles.css')
} // (3)
],
scripts: [
// scripts can be added in the same way as stylesheets
]
}
};
Individual components can also add local CSS/JS files from within their directory using relative paths:
button
βββ preview.css
βββ view.njk
// button/button.config.js
module.exports = {
preview: [
stylesheets: ['./preview.css'],
scripts: [/* ... */],
]
}
It's also possible to add 'inline' JS/CSS code to the previews using the
preview.css
andpreview.js
config options. See the Nunjucks demo button component config for an annotated example of this in action.
As well as adding assets, Fractalite also exposes a number of ways to completely customise the preview markup and output:
preview.wrapEach
option (available both globally and on a component-by-component basis)preview.wrap
option (available both globally and on a component-by-component basis)// button/button.config.js
module.exports = {
preview: {
// add an in-preview title
wrap(html, ctx) {
return `
<h4>${ctx.component.label} / ${ctx.scenario.label}</h4>
${html}`;
},
// wrap each item in the preview to space them out
wrapEach(html, ctx) {
return `<div style="margin-bottom: 20px">${html}</div>`;
}
}
}
Custom preview template markup can be provided by using the preview.template
config option.
Note that preview templates are rendered using Nunjucks. The default preview template can be found here for reference.
// fractal.config.js
module.exports = {
// ...
preview: {
template: `
<!DOCTYPE html>
<html>
<head>
{% for url in stylesheets %}<link rel="stylesheet" href="https://github.com/frctl/fractalite/blob/master/{{ url }}">{% endfor %}
{% if css %}<style>{{ css | safe }}</style>{% endif %}
<title>{{ meta.title | default('Preview') }}</title>
</head>
<body>
<div id="app">
<h1>A custom preview</h1>
<div class="wrapper">
{{ content | safe }}
</div>
</div>
{% for url in scripts %}<script src="https://github.com/frctl/fractalite/raw/master/{{ url }}"></script>{% endfor %}
{% if js %}<script>{{ js | safe }}</script>{% endif %}
</body>
</html>
`
}
};
For even more control over the preview rendering process it is also possible to provide a function instead of a string as the preview.template
value.
This allows you to use any template engine you like for the preview rendering (or none at all!).
// fractal.config.js
module.exports = {
// ...
preview: {
template: function(content, opts){
/*
* The return value of the function should be the
* fully rendered preview template string.
*
* Any stylesheets, scripts etc that have been
* added in global or component config are available
* in the `opts` object.
*/
return `
<html>
<head>
<!-- add stylesheets, meta etc -->
</head>
<body>${content}</body>
<!-- add scripts etc -->
</html>`;
}
}
};
Each project can specify a directory of pages to be displayed in the app.
Pages can either be Markdown documents (with a .md
extension) or Nunjucks templates (with a .njk
extension) and can define Jekyll-style front matter blocks for configuration options.
Add the absolute path to the pages directory to the project config file:
// fractal.config.js
const { resolve } = require('path');
module.exports = {
// ...
pages: resolve(__dirname, './pages'), // absolute path to the pages directory
};
Then create the pages:
./pages
βββ about.njk
βββ index.md
If an
index
file (either with.md
or.njk
extension) is added in the root of the pages directory then this will override the default application welcome page.
Reference tags can be used in pages to make linking to other pages, component previews and source files both easier and less fragile. They also allow basic access to properties of page and component objects.
Reference tags take the form {target:identifier:property}
.
target
: one of component
, page
, file
, inspect
or preview
identifier
: unique identifier for the target - for example the component name or page handleproperty
: optional, defaults to url
Some example reference tags:
<!-- button component inspector URL -->
{inspect:button}
<!-- standalone preview URL for button component with 'next scenario' -->
{preview:button/next}
<!-- URL of raw source of the button view template -->
{file:button/view.njk}
<!-- URL of the about page -->
{page:about}
<!-- title of the about page -->
{page:about:title}
Nunjucks templates (pages with a .njk
extension) have access to the current compiler state properties as well as any data provided in the front matter block:
<!-- about.njk -->
<p>The following components are available</p>
<ul>
{% for component in components %}
<li><a href="https://github.com/frctl/fractalite/blob/master/{{ component.url }}">{{ component.label }}</a></li>
{% endfor %}
</ul>
The following page configuration options are available and can be set in a front matter block at the top of pages that require it.
title
[string]
The title displayed at the top of the page.
label
[string]
Used to refer to the page in any navigation
handle
[string]
Used in reference tags to refer to the page. Defaults to the page URL with slashes replaced by dashes.
markdown
[boolean]
Whether or not to run the page contents through the markdown renderer. [Defaults to true
for .md
pages, false for all others.]
template
[boolean]
Whether or not to run the page contents through the Nunjucks renderer. [Defaults to true
for .njk
pages, false for all others.]
Many aspects of the Fractalite UI can be configured, customised or overridden.
The sidebar navigation contents can be customised in the project fractal.config.js
config file using the nav.items
property.
The value of this property can either be an array of navigation items or a generator function that returns an array of items.
Each item in the nav array should either be an object with the following properties:
label
: Text to be displayed for the nav itemurl
: URL to link to (if children
are not specified)children
: Array of child navigation items (if url
is not specified)Or it an be a Page
, Component
or File
instance. Component
instances will automatically have their scenarios added as child items.
If no value for the nav.items
property is supplied then the default nav generator will be used which includes links to all pages and components.
Most projects will want to dynamically generate their navigation, however it may occasionally be useful to hard-code the nav for some use cases.
// fractal.config.js
module.exports = {
// ...
nav: {
items: [
{
label: 'Welcome',
url: '/'
},
{
label: 'Components',
children: [
{
label: 'Next Button',
url: '/inspect/button/next'
},
{
label: 'Call to Action',
url: '/inspect/cta/default'
}
]
},
{
label: 'Github',
url: 'https://github.com/org/project'
},
]
}
};
A generator function can be supplied instead of hard-coding the items.
The generator will receive the compiler state
as its first argument and a toTree
utility function as the second argument. The toTree
utility can be used to generate a file-system based tree from a flat array of components or files.
The generator function should return an array of navigation items in the same format as the hard-coded example above.
The example below shows components can be filtered before generating the navigation:
// fractal.config.js
module.exports = {
// ...
nav: {
items(state, toTree){
// filter components by some custom property in the config
const components = state.components.filter(component => {
return component.config.customProp === true;
});
// return the navigation tree
return [
{
label: 'Components',
children: components // flat list of filtered components
},
{
label: 'Pages',
children: toTree(state.pages) // tree of pages
}
];
}
}
};
Fractalite offers a number of options for customising the look and feel of the UI.
Basic colour and style changes can be made by customising the available theme variables in the global config file:
// fractal.config.js
module.exports = {
// ...
theme: {
vars: {
'sidebar-bg-color': 'red'
}
}
};
More complex needs can be met by adding custom stylesheets
, scripts
or 'inline' CSS/JS to override the default theme:
// fractal.config.js
module.exports = {
// ...
theme: {
stylesheets: [
{
url: '/custom/url/path.css',
path: resolve(__dirname, './dist/styles.css')
}
],
scripts: [/* ... */],
css:`
body {
background: pink;
}
`,
js: `
console.log(window.location);
`
}
};
It is also possible to override some or all of the templates used in generating the UI by specifiying a custom views directory:
// fractal.config.js
const { resolve } = require('path');
module.exports = {
// ...
theme: {
views: resolve(__dirname, './custom-theme-views') // path to views directory
}
};
Any files placed within this custom views directory will override their equivalents in the application views directory.
custom-theme-views
βββ partials
βββ brand.njk // override sidebar branding template
Themes can also be provided as modules. In this case the module will receive the app
instance and should return a set of theme config values:
// ./custom-theme.js
module.exports = function(app){
// Can customise app instance here...
return {
// ...and return any theme config opts here
vars: {
'tabs-highlight-color--active': 'pink'
}
}
}
// fractal.config.js
module.exports = {
// ...
theme: require('./custom-theme.js')
};
Plugins the primary way that the Fractalite app can be customised, and can affect both the UI and the component parsing/compilation process.
Plugins are added in the project config file:
// fractal.config.js
module.exports = {
plugins: [
require('./plugins/example')({
// customisation opts here
})
]
};
A plugin is a function that receives app
, compiler
and adapter
instances as it's arguments.
A useful pattern is to wrap the plugin function itself in a 'parent' function so that it can receive runtime options:
// plugins/example.js
module.exports = function(opts = {}){
// any plugin initialiation here
return function(app, compiler, adapter){
// this is the plugin function itself
console.log('This is an example plugin');
}
};
The following is an example of a fairly basic plugin that reads author information from component config files and adds a tab to the component inspector UI to display this information.
// plugins/author-info.js
module.exports = function(opts = {}) {
return function authorPlugin(app, compiler) {
const authorDefaults = {
name: 'Unknown Author',
email: null
};
/*
* First add a compiler middleware function
* to extract author info from the component config
* and create a .author property on the component
* object with a normalized set of properties.
*/
compiler.use(components => {
components.forEach(component => {
const authorConfig = component.config.author || {};
component.author = { ...authorDefaults, ...authorConfig };
});
});
/*
* Then add an inspector panel to display the
* author information in the UI. The panel templates
* are rendered using Nunjucks and have access to the
* current component, scenario and compiler state.
*/
app.addInspectorPanel({
name: 'component-author',
label: 'Author Info',
template: `
<div class="author-panel">
<h3>Author information</h3>
<ul>
<li><strong>Name:</strong> {{ component.author.name }}</li>
{% if component.author.email %}
<li><strong>Email:</strong> {{ component.author.email }}</li>
{% endif %}
</ul>
</div>
`,
css: `
.author-panel {
padding: 20px;
}
.author-panel ul {
margin-top: 20px;
}
`
});
};
};
Author information can then be added to component config files and will be displayed in the UI:
// button/button.config.js
module.exports = {
author: {
name: "Daffy Duck",
email: 'daffy@duck.com'
}
}
Note that in the simple example above the compiler middleware could have been skipped in favour of a little more verbosity in the template. In more complex real-world examples however this is not always the case.
The asset bundler plugin uses Parcel to provide a zero-config asset bundling solution for Fractalite.
It handles asset compilation, hot module reloading (HMR) and automatically adds all generated assets into Fractalite component previews.
First add it to the project config file:
// fractal.config.js
module.exports = {
// ...
plugins: [
require('@frctl/fractalite-plugin-assets-bundler')({
entryFile: './src/preview.js',
outFile: './dist/build.js'
})
]
};
Then create the global entry file to bundle the required assets as per your project requirements. An example might look like this:
// ./assets/preview.js
import '../components/**/*.scss'
import button from '../components/button/button.js'
See the Parcel docs on module resolution for more info on paths, globbing and aliases: https://parceljs.org/module_resolution.html
The asset bundler also support dynamic creation of the entry file using the entryBuilder
config option.
The entry builder will be re-run whenever changes are made to the components directory.
const { relative } = require('path');
const bundlerPlugin = require('@frctl/fractalite-plugin-assets-bundler')({
/*
* The entryBuilder function receives state and context
* objects and should return a string of the entry file contents.
*
* This example dynamically build an entry file that imports all
* css files from components.
*/
entryBuilder(state, ctx) {
let entry = '';
state.files.filter(f => f.ext === '.scss').forEach(file => {
entry += `import '${relative(ctx.dir, file.path)}'\n`; // import paths need to be relative to the ctx.dir property
});
return entry;
},
// entry and out files must still be specified
entryFile: './src/preview.js',
outFile: './dist/build.js'
})
The notes plugin adds a inspector panel to display component notes.
Notes can be defined via the notes
property in the component config file, or alternatively kept in a markdown file in the component directory.
// fractal.config.js
module.exports = {
// ...
plugins: [
require('@frctl/fractalite-plugin-notes')({
notesFile: 'notes.md' // optional, only if notes should be read from files
})
]
};
Adapters allow Fractalite to support many different template engines and frameworks.
Fractalite currently supports a single adapter per project. Adapters are specified in the project configuration file:
// fractal.config.js
module.exports = {
adapter: require('./example-adapter.js')({
// customisation opts here
})
};
All adapters must provide a render
function which receives a component, a set of props, and a state object and returns a string representing the rendered component.
Adapters also have access to both the compiler
and the app
instances so can also perform much more complicated integration with the application if required.
An adapter is a function that receives app
and compiler
instances as it's arguments, and must return a render function or an adapter object that includes a render function amongst its properties.
As with plugins, a useful pattern is to wrap the adapter function itself in a 'parent' function so that it can receive runtime options:
// example-adapter.js
module.exports = function(opts = {}){
// any adapter initialiation here
return function(app, compiler){
// do anything with app/compiler here
return function render(component, props, state){
// do rendering here...
return html;
}
}
};
Render functions can be asynchronous - just return a Promise
that resolves to the HTML string.
The following example is for a basic adapter for Mustache templates. To keep it simple it does not include support for partials.
const Mustache = require('mustache');
module.exports = function(opts = {}) {
/*
* Allow users to override the default view name
*/
const viewName = opts.viewName || 'view.mustache';
return function mustacheAdapter() {
/*
* Asynchronous render function.
*
* Looks for a matching view file in the list of component files,
* reads it's contents and then renders the string using the
* Mustache.render method.
*/
return async function render(component, props) {
const tpl = component.files.find(file => file.basename === viewName);
if (!tpl) {
throw new Error(`Cannot render component - no view file found.`);
}
const tplContents = await tpl.getContents();
return Mustache.render(tplContents, props);
};
};
};
More advanced adapters can return an object instead of a simple render
function - in this case the render
function must be provided as a method on the returned object:
// example-adapter.js
module.exports = function(opts = {}){
// any adapter initialiation here
return function(app, compiler){
// do anything with app/compiler here
return {
render(component, props, state){
// do rendering here...
return html;
},
// any additional integration methods here
}
}
};
The adapter can then choose to implement any of the following additional methods to provide a deeper integration with the core:
Should return a string representation of a source template, if relevant to the target template engine or framework. Can return string or a Promise
that resolves to a string.
compiler.use(fn)
Push a compiler middleware function onto the stack.
Middleware receive the components
array as the first argument, and a Koa-style next
function as the second argument.
Middleware can mutate the contents of the components array as needed. Asynchronous middleware is supported.
Unlike in Koa middleware the next
function only needs to be called if the middleware should wait for latter middleware to complete before running.
// 'plain' middleware, no awaiting
compiler.use(components => {
components.forEach(component => {
// ...
})
})
// 'asynchronous' middleware
compiler.use(async components => {
await theAsyncTask();
components.forEach(component => {
// ...
})
})
// middleware that waits for latter middleware to complete first
compiler.use(async (components, next) => {
await next();
components.forEach(component => {
// ...
})
})
compiler.getState()
Returns an object representing the current state of the compiler. By default this includes components
and files
properties.
const state = compiler.getState();
state.components.forEach(component => {
console.log(component.name);
});
state.files.forEach(component => {
console.log(component.path);
});
compiler.parse()
Re-parse the component directory and update the internal compiler state. Returns a Promise that resolves to a state object.
app.mode
app.router
app.views
app.addInspectorPanel(props)
app.getInspectorPanels()
app.removeInspectorPanel(name)
app.addPreviewStylesheet(url, [path])
app.addPreviewScript(url, [path])
app.addPreviewCSS(css)
app.addPreviewJS(js)
app.beforeScenarioRender(fn)
app.afterScenarioRender(fn)
app.beforePreviewRender(fn)
app.afterPreviewRender(fn)
app.addPreviewWrapper(fn, wrapEach)
app.addRoute(name, path, handler)
app.url(name, params)
app.beforeStart(fn)
app.on(event, handler)
app.emit(event, [...args])
app.addViewPath(path)
app.addViewExtension(name, ext)
app.addViewFilter(name, filter)
app.addViewGlobal(name, value)
app.addStylesheet(url, [path])
app.addScript(url, [path])
app.addCSS(css)
app.addJS(js)
app.addStaticDir(name, path, [mount])
app.serveFile(url, path)
app.utils.renderMarkdown(str)
app.utils.highlightCode(code)
app.utils.parseFrontMatter(str)
app.utils.renderPage(str, [props], [opts])
app.utils.addReferenceLookup(key, handler)
app.extend(methods)
adapter.render(component, props)
adapter.renderAll(component, arrayOfProps)
adapter.getTemplateString(component)
component.name
component.label
component.config
component.files
component.scenarios
component.isComponent
component.root
component.path
component.relative
component.url
component.previewUrl
component.matchFiles(matcher)
page.label
page.title
page.position
page.url
page.isPage
page.getContents()
file.name
file.path
file.relative
file.basename
file.dirname
file.extname
file.ext
file.stem
file.stats
file.size
file.url
file.isFile
file.setContents(str)
file.getContents()