Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Really Simple Single-File-Component Support for Ember 3.x+
"use strict";
const fs = require('fs');
/**
Really Simple Single-File-Component Support for Ember 3.x+
By Josiah Bryan <josiahbryan@gmail.com> 2018-09-07
(Posted for discussion at https://discuss.emberjs.com/t/really-simple-single-file-component-support-for-ember-3-x/15438)
Hey everyone! I'm designing a new hobby project, and I really wanted to work with single file
components, so I took an hour or so of my Sunday afternoon and put together this script.
This is, as it says on the tin, **really simple** single-file-component support. What does it do?
* Takes one file (`my-component.hbs`) with `<template>`, `<script>`, and `<style>` tags (see sample below)...
* And write out each tag into it's own file where Ember expects to find it:
* The `<template>` gets written to `app/templates/components/my-component.hbs`
* The `<style>` gets written to `app/styles/components/my-component.scss`
(NB `.scss` not `.css` - feel free to edit the script if you're not using SASS)
* The `<script>` gets written to `app/components/my-component.js`
When you combine this scripts built-in support for watching the files for
changes and re-writing those files above, combined with `ember serve`s built-in
live reloading during development, then development on your Single-File-Component
is just as simple as editing your single file `app/sfcs/my-component.hbs` and
this script will write out those three files automatically, and `ember` will
auto-detect and auto-rebuild your app bundle when it sees those files changed.
Feedback / suggestions all welcome!
To integrate into your Ember project, save this script somewhere.
(I created a folder called `myproject/utils/` and put it there.)
Then, add this line at the top of `ember-cli-build.js`:
`require('./utils/debundle-sfc').watchAndRebuild();`
**IMPORTANT**: You will HAVE to change the `config.appRoot` value embedded
in the script, because it's set for my app, and I'm guessing you don't have
the exact same folder path as I do.
Notes about `appRoot`:
- `appRoot` must end with a slash (`/`)
- `appRoot` must be the root of the Ember app (e.g. where the `components/`
folder is, etc.)
This script assumes you put your single-file-components in a folder under `app/`
called `sfcs`. Change config below to change where they're stored. Note this
script expects your component files to have .hbs as the extension.
Obviously, you'll have to create the `sfcs` folder (`app/sfcs`) manually
since Ember doesn't generate it.
You can run this script manually from the command line, either one-time
(`node script.js`) or with a `--watch` argument to start the file watcher and
rebuild (`node script.js --watch`) (Note: We watch the `app/sfcs` folder, not
the individual files, so you can create new files in that folder and the script
will automatically process those as well.) Note, we don't REMOVE the generated
files if you remove the component from `app/sfcs` - that's an idea for future
improvement, of course.
Obviously, when you add it to `ember-cli-build` with the `.watchAndRebuild()`
call as shown above, you don't have to run it manually on the command line
during development (assuming you have `ember serve`) running in a console
somwhere as you work.
Note: I use Atom as my text editor, and it's built-in
syntax highlighting for HBS "just works".
Example single-file-component, I put the contents in `app/sfcs/hello-world.hbs`:
```hbs
<template>
<h1 local-class='header'>Hello, <b {{action 'randomName'}}>
{{if name name 'World'}}!
</b></h1>
</template>
<style>
.header {
background: #ccc;
padding: 1rem 1.75rem;
border-radius: 1rem;
color: rgba(0,0,0,0.8);
b {
color: rgba(0,0,0,0.95);
text-transform: uppercase;
}
}
</style>
<script>
// Script automatically appends Component import to generated .js
// file, other imports are up to you to add here
export default Component.extend({
tagName: '',
actions: {
randomName() {
this.set('name',
"Person # " + (Math.random() * 1024).toFixed(0));
}
},
});
</script>
```
**/
const config = {
// TODO: Can we autodetect this?
appRoot: '/opt/wys/client/app/',
// Our custom folder input
input: {
// folder under app/ holding sfc files as .hbs files with <template/> and optional <script/> and <style/> tags
// NB: Tags are matched with regex, not by processing the DOM, so ...yeah...just FYI
// NB all paths assumed to end with slash by code below
sfcs: 'sfcs/',
},
// Output to default locations Ember expects to find
output: {
// NB all paths assumed to end with slash by code below
template: 'templates/components/',
style: 'styles/components/',
script: 'components/',
},
};
// Precompile our regexs for use in parsing the HBS
const regexp = {};
['template','script','style'].forEach(tag => {
// Simple regex to extract contents of the tag (and attrs of the tag itself)
regexp[tag] = new RegExp(`<${tag}(.*?)>([\\s\\S]*?)<\\/${tag}>`, 'i');
});
// Main loop to process all .hbs files in the app/sfcs/ folder
function main() {
const sfcFiles = fs
.readdirSync(config.appRoot + config.input.sfcs)
.filter(filename => filename.endsWith('.hbs'));
sfcFiles.forEach(debundleSfc);
}
// Run right away when this script first executes
main();
function debounceRebuild(eventType, filename) {
// debounce PER-FILE, because multiple files could change,
// and we need the filename for debundleSfc()
clearTimeout(debounceRebuild.timers[filename]);
debounceRebuild.timers[filename] = setTimeout( () => {
console.log(`\n\n[debundle-sfc] ${filename} ${eventType}ed at ${new Date()}...\n`);
debundleSfc(filename);
}, 250);
}
debounceRebuild.timers = {};
function watchAndRebuild() {
const folder = config.appRoot + config.input.sfcs;
console.log(`\n[debundle-sfc] Watching ${folder} for changes...\n`);
fs.watch(folder, debounceRebuild);
}
module.exports = { watchAndRebuild };
// You can run this script from the command line as "node script.js --watch" to start watcher automatically
{
const flag = process.argv.length >= 2 ? process.argv[2]: '';
if(flag == '--watch' || flag == 'watch')
watchAndRebuild();
}
// debundleSfc actually does the parsing of the .hbs file - with regexps, nothing fancy
function debundleSfc(filename) {
const input = fs.readFileSync(config.appRoot + config.input.sfcs + filename, 'utf8');
// Extract our three tags (template, script, style) from the file
// using the precompiled regexps above
Object.keys(regexp).forEach(tag => {
const match = regexp[tag].exec(input);
if(match) {
// NOTE: Currently, props unused. Future use could be to write .css instead of .scss, etc
const [ props, content ] = match
// Regexp returns props of the tag and the content in positions 1 and 2 respectively
.slice(1,3)
// Trim the whitespace from start/end of raw data
.map(value => value.toString().trim());
// Write the content to the appros location
writeSfcTag(filename, tag, props, content);
}
});
}
function writeSfcTag(filename, tag, props, content) {
if(!content)
return;
// NOTE: My project uses scss (and css-modules), so this just writes out .scss files.
// Future expansion of this script could use the props to set a flag to write .css instead of .scss, for example
const extension = {
template: '.hbs',
script: '.js',
style: '.scss'
};
const nameWithoutExtension = filename.replace(/.hbs$/, '');
const outputFilename = [
config.appRoot,
config.output[tag],
nameWithoutExtension,
extension[tag]
].join('');
const outputData =
tag === 'script' ?
// Add the Component import just as a convenience
`import Component from '@ember/component';\n${content}` :
content;
console.log("[writeSfcTag] Writing ", outputFilename);
fs.writeFileSync(outputFilename, outputData);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment