Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: js style import support #12

Merged
merged 11 commits into from
Dec 3, 2019
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,32 @@ We also do a bit of path magic at build time to let you use relative imports!
{{import SpecialButton from './super-special-button-used-only-in-this-route'}}

<SpecialButton>I'm so special!</SpecialButton>

```

### Octane imports style

```hbs
import BasicDropdown from 'ember-basic-dropdown/components/basic-dropdown';
import { BasicDropdown as SameDropdown } from 'ember-basic-dropdown/components';

--- hbs ---

<BasicDropdown />
<SameDropdown />
```

### Classic imports style

```hbs
{{import BasicDropdown from 'ember-basic-dropdown/components/basic-dropdown'}}
{{import SameDropdown from 'ember-basic-dropdown/components/basic-dropdown'}}

<BasicDropdown />
<SameDropdown />
```


Motivation
------------------------------------------------------------------------------

Expand Down
110 changes: 51 additions & 59 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,28 @@
'use strict';
"use strict";

/* eslint-env node */

const assert = require('assert');
const path = require('path');
const BroccoliFilter = require('broccoli-persistent-filter');
const md5Hex = require('md5-hex');
const { transformImports, createImportWarning } = require('./lib/utils');
const {
transformOctaneImports,
hasOctaneImports
} = require('./lib/octane-utils');

const usingStylesImport = false;
const IMPORT_PATTERN = /\{\{\s*import\s+([^\s]+)\s+from\s+['"]([^'"]+)['"]\s*\}\}/gi;
let usingStylesImport = false;

try {
usingStylesImport = !!require.resolve('ember-template-styles-import');
} catch(e) {
// noop
}

function isValidVariableName(name) {
if (!(/^[A-Za-z0-9]+$/.test(name))) {
return false;
}
if (name.charAt(0).toUpperCase() !== name.charAt(0)) {
return false;
}
return true;
}
class TemplateImportProcessor extends BroccoliFilter {

constructor(inputNode, options = {}) {
if (!options.hasOwnProperty('persist')) {
if (!options.hasOwnProperty("persist")) {
options.persist = true;
}

Expand All @@ -40,68 +34,67 @@ class TemplateImportProcessor extends BroccoliFilter {
this.options = options;
this._console = this.options.console || console;

this.extensions = [ 'hbs', 'handlebars' ];
this.targetExtension = 'hbs';
this.extensions = ["hbs", "handlebars"];
this.targetExtension = "hbs";
}

baseDir() {
return __dirname;
}

cacheKeyProcessString(string, relativePath) {
return md5Hex([
string,
relativePath
]);
return md5Hex([string, relativePath]);
}

processString(contents, relativePath) {
let imports = [];
let rewrittenContents = contents.replace(IMPORT_PATTERN, (_, localName, importPath) => {
if (importPath.endsWith('styles.scoped.scss') && usingStylesImport) {
return _;
}
if (importPath.startsWith('.')) {
importPath = path.resolve(relativePath, '..', importPath).split(path.sep).join('/');
importPath = path.relative(this.options.root, importPath).split(path.sep).join('/');
}
imports.push({ localName, importPath, isLocalNameValid: isValidVariableName(localName) });
return '';
});
if (hasOctaneImports(contents)) {
contents = transformOctaneImports(contents, relativePath);
}

let header = imports.map(({ importPath, localName, isLocalNameValid }) => {
const warnPrefix = 'ember-template-component-import: ';
const abstractWarn = `${warnPrefix} Allowed import variable names - CamelCased strings, like: FooBar, TomDale`;
const componentWarn = `
${warnPrefix}Warning!
in file: "${relativePath}"
subject: "${localName}" is not allowed as Variable name for Template import.`;
const warn = isLocalNameValid ? '' : `
<pre data-test-name="${localName}">${componentWarn}</pre>
<pre data-test-global-warn="${localName}">${abstractWarn}</pre>
`;
if (!isLocalNameValid) {
this._console.log(componentWarn);
if (relativePath !== 'dummy/pods/application/template.hbs') {
// don't throw on 'dummy/pods/application/template.hbs' (test template)
throw new Error(componentWarn);
}
}
return `${warn}{{#let (component '${ importPath }') as |${ localName }|}}`;
}).join('');
let footer = imports.map(() => `{{/let}}`).join('');
const { imports, rewrittenContents } = transformImports(
contents,
relativePath,
this.options.root,
usingStylesImport
);

let header = imports
.map(({ importPath, localName, isLocalNameValid }) => {
const warn = createImportWarning(
relativePath,
localName,
isLocalNameValid,
this._console
);
let componentName = `(component '${importPath}')`;

return `${warn}{{#let ${componentName} as |${localName}|}}`;
})
.join("");
let footer = imports.map(() => `{{/let}}`).join("");
let result = header + rewrittenContents + footer;
return result;
}

}

module.exports = {
name: require('./package').name,
name: require("./package").name,

setupPreprocessorRegistry(type, registry) {
const podModulePrefix = this.project.config().podModulePrefix;
// this is called before init, so, we need to check podModulePrefix later (in toTree)
let componentsRoot = null;
const projectConfig = this.project.config();
const podModulePrefix = projectConfig.podModulePrefix;

// by default `ember g component foo-bar --pod`
// will create app/components/foo-bar/{component.js,template.hbs}
// so, we can handle this case and just fallback to 'app/components'

if (podModulePrefix === undefined) {
componentsRoot = path.join(this.project.root, 'app', 'components');
} else {
componentsRoot = path.join(this.project.root, podModulePrefix);
}

assert.notStrictEqual(
podModulePrefix,
Expand All @@ -112,14 +105,13 @@ module.exports = {
name: 'ember-template-component-import',
ext: 'hbs',
toTree: (tree) => {
let componentsRoot = path.join(this.project.root, podModulePrefix);
tree = new TemplateImportProcessor(tree, { root: componentsRoot });
return tree;
}
});

if (type === 'parent') {
if (type === "parent") {
this.parentRegistry = registry;
}
},
}
};
123 changes: 123 additions & 0 deletions lib/octane-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/* jshint node: true */
/* global module */
"use strict";

const OCTANE_IMPORT_SPLITTER = /---\s?hbs\s?---/;

function dasherizeName(name = "") {
const result = [];
const nameSize = name.length;
if (!nameSize) {
return "";
}
result.push(name.charAt(0));
for (let i = 1; i < nameSize; i++) {
let char = name.charAt(i);
if (char === char.toUpperCase()) {
if (char !== "-" && char !== "/" && char !== "_") {
if (
result[result.length - 1] !== "-" &&
result[result.length - 1] !== "/"
) {
result.push("-");
}
}
}
result.push(char);
}
return result.join("");
}

function toComponentFileName(name) {
return dasherizeName(name.trim()).toLowerCase();
}

function extractStatements(leftImportPart) {
let normalizedLeft = leftImportPart.replace(/[{}]+/g, " ").trim();
const statements = normalizedLeft
.trim()
.split(",")
.map(name => name && name.trim())
.filter(name => name.length);
return statements;
}

function splitImportExpressions(line) {
return line
.split("import")
.map(item => item.trim())
.filter(text => text.length)
.map(i => i.split(" from "));
}

function normalizeRightImportPart(right) {
let importSt = right
.replace(/[^a-zA-Z0-9-.@]+/g, " ")
.trim()
.split(" ")
.join("/");
return importSt;
}

function hasImportAlias(name) {
return name.split(" as ").length === 2;
}

function toLegacyImport(line) {
var cleanImports = splitImportExpressions(line);
const components = [];
cleanImports.map(([left, right]) => {
let importSt = normalizeRightImportPart(right);
if (left.includes('{')) {
// we have multiple imports from path, like
// import { Foo, Bar, Baz } from './-components';
if (!importSt.endsWith('/')) {
importSt = importSt + '/';
}
const statements = extractStatements(left);
statements.forEach(name => {
if (hasImportAlias(name)) {
const [originalName, newName] = name.split(' as ');
components.push([
newName.trim(),
importSt + toComponentFileName(originalName)
]);
} else {
components.push([name.trim(), importSt + toComponentFileName(name)]);
}
});
} else {
// plain single component import by final path, like
// import InputForm from './-components/input-form';
components.push([left.trim(), importSt]);
}
});
let results = [];

components.forEach(([head, rawTail]) => {
results.push(`{{import ${head} from "${rawTail}"}}`);
});

return results.join('\n');
}

function hasOctaneImports(templateText) {
return templateText.split(OCTANE_IMPORT_SPLITTER).length === 2;
}

function transformOctaneImports(templateText, relativePath) {
if (!relativePath) {
return templateText;
}
const [importStatements, templateContent] = templateText.split(
OCTANE_IMPORT_SPLITTER
);
return [toLegacyImport(importStatements), templateContent].join(
"\n"
);
}

module.exports.dasherizeName = dasherizeName;
module.exports.toLegacyImport = toLegacyImport;
module.exports.hasOctaneImports = hasOctaneImports;
module.exports.transformOctaneImports = transformOctaneImports;
65 changes: 65 additions & 0 deletions lib/octane-utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use strict";
/* eslint-env jest */
/* eslint-env node */
function assert(left, right) {
expect(left).toEqual(right);
}

const {
dasherizeName,
toLegacyImport,
hasOctaneImports,
transformOctaneImports
} = require("./octane-utils");

it("must dasherize component name", () => {
assert(dasherizeName("foo"), "foo");
assert(dasherizeName("FooBar"), "Foo-Bar");
assert(dasherizeName("FooBar/FooBaz"), "Foo-Bar/Foo-Baz");
assert(dasherizeName("Foobar"), "Foobar");
});

it("must detect OctaneImports", () => {
assert(hasOctaneImports("--- hbs ---"), true);
assert(hasOctaneImports("---hbs---"), true);
assert(hasOctaneImports("---hbs ---"), true);
assert(hasOctaneImports("--- hbs---"), true);
assert(hasOctaneImports("---"), false);
assert(hasOctaneImports("-- hbs --"), false);
});

it("toLegacyImport must transform js-like imports to hbs-like", () => {
assert(
toLegacyImport(`import foo from 'bar'`, "boo"),
'{{import foo from "bar"}}'
);
assert(
toLegacyImport(`import { foo } from 'bar'`, "boo"),
'{{import foo from "bar/foo"}}'
);
assert(
toLegacyImport(`import { foo as doo } from 'bar'`, "boo"),
'{{import doo from "bar/foo"}}'
);
assert(
toLegacyImport(`import { foo as doo, buzz } from 'bar'`, "boo"),
['{{import doo from "bar/foo"}}', '{{import buzz from "bar/buzz"}}'].join(
"\n"
)
);
});

it("transformOctaneImports must transform template text from octane to classic imports", () => {
const input = `
import FooBar from 'some-path';
import { Boo } from 'second-path';
--- hbs ---
`;
assert(
transformOctaneImports(input, "path").trim(),
[
`{{import FooBar from "some-path"}}`,
`{{import Boo from "second-path/boo"}}`
].join("\n")
);
});
Loading