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

Adding support for CSS modules #38

Merged
merged 5 commits into from
Mar 12, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
build
node_modules
.github/actions/*/package-lock.json
*.css.d.ts
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,16 @@ This style of import can only be used in `static-build`.
## CSS

```js
import cssURL, { inline } from 'css:./styles.css';
import cssURL, { inline, $tabButton } from './styles.css';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to drop the use of css: prefix 😢

If the import is css:./styles.css, there's no way to tell typescript to treat ./styles.css as a relative path. I'll file an issue with them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

```

`css:` followed by a path to some CSS will add and minify that CSS to the build.
Imports ending `.css` are assumed to be CSS.

The CSS supports CSS modules, Sass-style nesting, and will be minified.

- `cssURL` - URL to the CSS resource.
- `inline` - The text of the CSS.

The CSS can also use Sass-style nesting.
- `$*` - Other imports starting with \$ refer to class names within the CSS. So, if the CSS contains `.tab-button`, then `$tabButton` will be one of the exports.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm prefixing all these exports with $ for two reasons. It avoids clashes with JS reserved words, so .return becomes $return, and it means we can add other exports that won't clash.

Happy to change the prefix if there's a preference.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should prefix them with 🎨

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a real possibility? (Because I'm all for that) -- but aren't there emoji clashes in Linux?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think technically it should work, but we’re honestly just asking for trouble and it’s a ball-ache to type.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reminds me of this from justin w3c/csswg-drafts#3714


## Markdown

Expand Down
145 changes: 93 additions & 52 deletions lib/css-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,80 +12,121 @@
*/
import { promises as fsp, readFileSync } from 'fs';
import { createHash } from 'crypto';
import { promisify } from 'util';
import { parse as parsePath, resolve as resolvePath, dirname } from 'path';

import postcss from 'postcss';
import postCSSNested from 'postcss-nested';
import postCSSUrl from 'postcss-url';
import postCSSModules from 'postcss-modules';
import cssNano from 'cssnano';
import camelCase from 'lodash.camelcase';
import glob from 'glob';

const prefix = 'css:';
const globP = promisify(glob);

const suffix = '.css';
const assetRe = new RegExp('/fake/path/to/asset/([^/]+)/', 'g');

export default function() {
/** @type {string[]} */
let emittedCSSIds;
/** @type {Map<string, string>} */
let hashToId;
/** @type {Map<string, string>} */
let pathToModule;

return {
name: 'css',
buildStart() {
async buildStart() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved all the CSS processing to buildStart so the css.d.ts files are generated before TypeScript gets there. It does mean that we might build CSS files that aren't referenced anywhere, but that should be rare.

emittedCSSIds = [];
hashToId = new Map();
},
async resolveId(id, importer) {
if (!id.startsWith(prefix)) return null;
pathToModule = new Map();

const realId = id.slice(prefix.length);
const resolveResult = await this.resolve(realId, importer);
if (!resolveResult) {
throw Error(`Cannot resolve ${resolveResult.id}`);
}
return prefix + resolveResult.id;
},
async load(id) {
if (!id.startsWith(prefix)) return;

const realId = id.slice(prefix.length);
const parsedPath = parsePath(realId);
this.addWatchFile(realId);
const file = await fsp.readFile(realId);
const cssResult = await postcss([
postCSSNested,
postCSSUrl({
url: ({ relativePath, url }) => {
if (/^https?:\/\//.test(url)) return url;
const parsedPath = parsePath(relativePath);
const source = readFileSync(
resolvePath(dirname(realId), relativePath),
);
const fileId = this.emitFile({
type: 'asset',
name: parsedPath.base,
source,
});
const hash = createHash('md5');
hash.update(source);
const md5 = hash.digest('hex');
hashToId.set(md5, fileId);
return `/fake/path/to/asset/${md5}/`;
},
}),
cssNano,
]).process(file, {
from: undefined,
const cssPaths = await globP('static-build/**/*.css', {
nodir: true,
absolute: true,
});

const fileId = this.emitFile({
type: 'asset',
source: cssResult.css,
name: parsedPath.base,
});
await Promise.all(
cssPaths.map(async path => {
this.addWatchFile(path);
const parsedPath = parsePath(path);
const file = await fsp.readFile(path);
let moduleJSON;
const cssResult = await postcss([
postCSSNested,
postCSSModules({
getJSON(_, json) {
moduleJSON = json;
},
}),
postCSSUrl({
url: ({ relativePath, url }) => {
if (/^https?:\/\//.test(url)) return url;
const parsedPath = parsePath(relativePath);
const source = readFileSync(
resolvePath(dirname(realId), relativePath),
);
const fileId = this.emitFile({
type: 'asset',
name: parsedPath.base,
source,
});
const hash = createHash('md5');
hash.update(source);
const md5 = hash.digest('hex');
hashToId.set(md5, fileId);
return `/fake/path/to/asset/${md5}/`;
},
}),
cssNano,
]).process(file, {
from: undefined,
});

const cssClassExports = Object.entries(moduleJSON).map(
([key, val]) =>
`export const $${camelCase(key)} = ${JSON.stringify(val)};`,
);

emittedCSSIds.push(fileId);
const defs = [
'declare var url: string;',
'export default url;',
'export const inline: string;',
...Object.keys(moduleJSON).map(
key => `export const $${camelCase(key)}: string;`,
),
];

const fileId = this.emitFile({
type: 'asset',
source: cssResult.css,
name: parsedPath.base,
});

emittedCSSIds.push(fileId);

await fsp.writeFile(path + '.d.ts', defs.join('\n'));

pathToModule.set(
path,
[
`export default import.meta.ROLLUP_FILE_URL_${fileId}`,
`export const inline = ${JSON.stringify(cssResult.css)}`,
...cssClassExports,
].join('\n'),
);
}),
);
},
async load(id) {
if (!id.endsWith(suffix)) return;
if (!pathToModule.has(id)) {
throw Error(`Cannot find ${id} in pathToModule`);
}

return `export default import.meta.ROLLUP_FILE_URL_${fileId}; export const inline = ${JSON.stringify(
cssResult.css,
)}`;
return pathToModule.get(id);
},
async generateBundle(options, bundle) {
const cssAssets = emittedCSSIds.map(id => this.getFileName(id));
Expand Down
41 changes: 26 additions & 15 deletions lib/simple-ts.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@ export default function simpleTS(mainPath, { noBuild, watch } = {}) {
const config = loadConfig(mainPath);
const args = ['-b', mainPath];

let done = Promise.resolve();
let tsBuildDone;

if (!noBuild) {
done = new Promise(resolve => {
function tsBuild(rollupContext) {
if (tsBuildDone) return tsBuildDone;
if (noBuild) {
return (tsBuildDone = Promise.resolve());
}
tsBuildDone = new Promise(resolve => {
const proc = spawn('tsc', args, {
stdio: 'inherit',
});
Expand All @@ -56,23 +60,25 @@ export default function simpleTS(mainPath, { noBuild, watch } = {}) {
resolve();
});
});
}

if (!noBuild && watch) {
done.then(() => {
spawn('tsc', [...args, '--watch', '--preserveWatchOutput'], {
stdio: 'inherit',
});
tsBuildDone.then(async () => {
const matches = await globP(config.options.outDir + '/**/*.js');
for (const match of matches) rollupContext.addWatchFile(match);
});

if (watch) {
tsBuildDone.then(() => {
spawn('tsc', [...args, '--watch', '--preserveWatchOutput'], {
stdio: 'inherit',
});
});
}

return tsBuildDone;
}

return {
name: 'simple-ts',
async buildStart() {
await done;
const matches = await globP(config.options.outDir + '/**/*.js');
for (const match of matches) this.addWatchFile(match);
},
resolveId(id, importer) {
// If there isn't an importer, it's an entry point, so we don't need to resolve it relative
// to something.
Expand All @@ -96,9 +102,14 @@ export default function simpleTS(mainPath, { noBuild, watch } = {}) {
}
return tsResolve.resolvedModule.resolvedFileName;
},
load(id) {
async load(id) {
if (!extRe.test(id)) return null;

// TypeScript building is deferred until the first TS file load.
// This allows prerequisites to happen first,
// such as css.d.ts generation in css-plugin.
await tsBuild(this);

// Look for the JS equivalent in the tmp folder
const newId = join(
config.options.outDir,
Expand Down
Loading