diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 92b7b1134..5504238b6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -42,6 +42,24 @@ module.exports = { **note** invalid characters will be replaced with an underscore (`_`). + - `injectStyleTags: { preprocessor: Preprocessor } | true | false` (default: `false`) + + Using this option will transform your file to your file to create a CSS style tag when your module is imported. + + e.x. + + ```javascript + if (typeof document !== 'undefined') { + const style = document.createElement('style'); + style.innerHTML = '.absdjfsdf__42__header {text-transform:uppercase;}'; + document.head.appendChild(style); + } + ``` + + You may want to use this option if your package does not have a bundler set up to handle CSS files. + + `true` will use the default preprocessor, and you can set a custom preprocessor by passing an object with the value `preprocessor` set to your preprocessor. + ### Variables - `hash`: The hash of the content. @@ -141,7 +159,7 @@ After that, your `package.json` should look like the following: Now in your `preact.config.js`, we will modify the babel rule to use the necessary loaders and presets. Add the following: ```js -export default config => { +export default (config) => { const { options, ...babelLoaderRule } = config.module.rules[0]; // Get the babel rule and options options.presets.push('@babel/preset-react', 'linaria/babel'); // Push the necessary presets config.module.rules[0] = { @@ -150,15 +168,15 @@ export default config => { use: [ { loader: 'babel-loader', - options + options, }, { loader: 'linaria/loader', options: { - babelOptions: options // Pass the current babel options to linaria's babel instance - } - } - ] + babelOptions: options, // Pass the current babel options to linaria's babel instance + }, + }, + ], }; }; ``` @@ -255,7 +273,7 @@ exports.onCreateWebpackConfig = ({ actions, loaders, getConfig, stage }) => { config.module.rules = [ ...config.module.rules.filter( - rule => String(rule.test) !== String(/\.js?$/) + (rule) => String(rule.test) !== String(/\.js?$/) ), { diff --git a/src/__tests__/__snapshots__/babel.test.ts.snap b/src/__tests__/__snapshots__/babel.test.ts.snap index 404e76702..38b86e362 100644 --- a/src/__tests__/__snapshots__/babel.test.ts.snap +++ b/src/__tests__/__snapshots__/babel.test.ts.snap @@ -1,5 +1,45 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Transpiles injecting the style tags into the document head if injectStyleTags is true 1`] = ` +"import { css } from 'linaria'; +import { styled } from 'linaria/react'; +export const a = \\"ah6xni0\\"; +export const B = /*#__PURE__*/styled(\\"div\\")({ + name: \\"B\\", + class: \\"b1u0rrat\\" +}); +export const C = /*#__PURE__*/styled(\\"div\\")({ + name: \\"C\\", + class: \\"c1n8pbpy\\" +}); + +if (typeof document !== \\"undefined\\") { + const style = document.createElement(\\"style\\"); + style.innerHTML = \\".ah6xni0{font-size:14px;}\\\\n.b1u0rrat{font-weight:bold;}\\\\n.c1n8pbpy .b1u0rrat{font-weight:normal;}\\\\n\\"; + document.head.appendChild(style); +}" +`; + +exports[`Transpiles injecting the style tags into the document head if injectStyleTags is true 2`] = ` + +CSS: + +.ah6xni0 { + font-size: 14px; +} +.b1u0rrat { + font-weight: bold; +} +.c1n8pbpy { + .b1u0rrat { + font-weight: normal; + } +} + +Dependencies: NA + +`; + exports[`does not include styles if not referenced anywhere 1`] = ` "import { css } from 'linaria'; import { styled } from 'linaria/react'; diff --git a/src/__tests__/babel.test.ts b/src/__tests__/babel.test.ts index bc8addad3..b3f0a2394 100644 --- a/src/__tests__/babel.test.ts +++ b/src/__tests__/babel.test.ts @@ -488,3 +488,30 @@ it('includes unreferenced styles for :global', async () => { expect(code).toMatchSnapshot(); expect(metadata).toMatchSnapshot(); }); + +it('Transpiles injecting the style tags into the document head if injectStyleTags is true', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from 'linaria'; + import { styled } from 'linaria/react'; + + export const a = css\` + font-size: 14px; + \`; + + export const B = styled.div\` + font-weight: bold; + \`; + + export const C = styled.div\` + ${'${B}'} { + font-weight: normal; + } + \`; + `, + { injectStyleTags: true } + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); diff --git a/src/__tests__/preprocess.test.ts b/src/__tests__/preprocess.test.ts new file mode 100644 index 000000000..317b791ca --- /dev/null +++ b/src/__tests__/preprocess.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-template-curly-in-string */ + +import path from 'path'; +import { transformUrl } from '../preprocess'; + +describe('transformUrl', () => { + type TransformUrlArgs = Parameters; + const dataset: Record = { + '../assets/test.jpg': [ + './assets/test.jpg', + './.linaria-cache/test.css', + './test.js', + ], + '../a/b/test.jpg': [ + '../a/b/test.jpg', + './.linaria-cache/test.css', + './a/test.js', + ], + }; + + it('should work with posix paths', () => { + for (const result of Object.keys(dataset)) { + expect(transformUrl(...dataset[result])).toBe(result); + } + }); + + it('should work with win32 paths', () => { + const toWin32 = (p: string) => p.split(path.posix.sep).join(path.win32.sep); + const win32Dataset = Object.keys(dataset).reduce( + (acc, key) => ({ + ...acc, + [key]: [ + dataset[key][0], + toWin32(dataset[key][1]), + toWin32(dataset[key][2]), + path.win32, + ] as TransformUrlArgs, + }), + {} as Record + ); + + for (const result of Object.keys(win32Dataset)) { + expect(transformUrl(...win32Dataset[result])).toBe(result); + } + }); +}); diff --git a/src/__tests__/transform.test.ts b/src/__tests__/transform.test.ts index 35de204c1..fbfbf53e9 100644 --- a/src/__tests__/transform.test.ts +++ b/src/__tests__/transform.test.ts @@ -1,8 +1,7 @@ /* eslint-disable no-template-curly-in-string */ -import path from 'path'; import dedent from 'dedent'; -import transform, { transformUrl } from '../transform'; +import transform from '../transform'; import evaluator from '../babel/evaluators/extractor'; const outputFilename = './.linaria-cache/test.css'; @@ -14,48 +13,6 @@ const rules = [ }, ]; -describe('transformUrl', () => { - type TransformUrlArgs = Parameters; - const dataset: Record = { - '../assets/test.jpg': [ - './assets/test.jpg', - './.linaria-cache/test.css', - './test.js', - ], - '../a/b/test.jpg': [ - '../a/b/test.jpg', - './.linaria-cache/test.css', - './a/test.js', - ], - }; - - it('should work with posix paths', () => { - for (const result of Object.keys(dataset)) { - expect(transformUrl(...dataset[result])).toBe(result); - } - }); - - it('should work with win32 paths', () => { - const toWin32 = (p: string) => p.split(path.posix.sep).join(path.win32.sep); - const win32Dataset = Object.keys(dataset).reduce( - (acc, key) => ({ - ...acc, - [key]: [ - dataset[key][0], - toWin32(dataset[key][1]), - toWin32(dataset[key][2]), - path.win32, - ] as TransformUrlArgs, - }), - {} as Record - ); - - for (const result of Object.keys(win32Dataset)) { - expect(transformUrl(...win32Dataset[result])).toBe(result); - } - }); -}); - it('rewrites a relative path in url() declarations', async () => { const { cssText } = await transform( dedent` diff --git a/src/babel/extract.ts b/src/babel/extract.ts index f7baa35e9..97ebfd7ed 100644 --- a/src/babel/extract.ts +++ b/src/babel/extract.ts @@ -14,6 +14,7 @@ import type { Node, Program, Expression } from '@babel/types'; import type { NodePath, Scope, Visitor } from '@babel/traverse'; import { expression, statement } from '@babel/template'; import generator from '@babel/generator'; +import preprocess from '../preprocess'; import evaluate from './evaluators'; import getTemplateProcessor from './evaluators/templateProcessor'; import Module from './module'; @@ -104,6 +105,48 @@ function addLinariaPreval( ); } +function injectStyleAst({ types: t }: Core, css: string) { + const createStyleElement = t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('style'), + t.callExpression( + t.memberExpression( + t.identifier('document'), + t.identifier('createElement') + ), + [t.stringLiteral('style')] + ) + ), + ]); + + const assignInnerHTML = t.expressionStatement( + t.assignmentExpression( + '=', + t.memberExpression(t.identifier('style'), t.identifier('innerHTML')), + t.stringLiteral(css) + ) + ); + + const appendToDOM = t.expressionStatement( + t.callExpression( + t.memberExpression( + t.memberExpression(t.identifier('document'), t.identifier('head')), + t.identifier('appendChild') + ), + [t.identifier('style')] + ) + ); + + return t.ifStatement( + t.binaryExpression( + '!==', + t.unaryExpression('typeof', t.identifier('document')), + t.stringLiteral('undefined') + ), + t.blockStatement([createStyleElement, assignInnerHTML, appendToDOM]) + ); +} + export default function extract( babel: Core, options: StrictOptions @@ -200,7 +243,7 @@ export default function extract( ); state.queue.forEach((item) => process(item, state, valueCache)); }, - exit(_: any, state: State) { + exit(path: NodePath, state: State) { if (Object.keys(state.rules).length) { // Store the result as the file metadata under linaria key state.file.metadata.linaria = { @@ -208,6 +251,17 @@ export default function extract( replacements: state.replacements, dependencies: state.dependencies, }; + + if (options.injectStyleTags) { + const { cssText } = preprocess(state.rules, { + filename: state.file.opts.filename, + preprocessor: + options.injectStyleTags === true + ? undefined + : options.injectStyleTags.preprocessor, + }); + path.pushContainer('body', injectStyleAst(babel, cssText)); + } } // Invalidate cache for module evaluation when we're done diff --git a/src/babel/types.ts b/src/babel/types.ts index b7ada5738..2a45147a3 100644 --- a/src/babel/types.ts +++ b/src/babel/types.ts @@ -8,6 +8,7 @@ import type { TransformOptions } from '@babel/core'; import type { NodePath } from '@babel/traverse'; import type { VisitorKeys } from '@babel/types'; import type { StyledMeta } from '../StyledMeta'; +import { Preprocessor } from '../types'; export type JSONValue = string | number | boolean | JSONObject | JSONArray; @@ -121,6 +122,7 @@ type ClassNameFn = (hash: string, title: string) => string; export type StrictOptions = { classNameSlug?: string | ClassNameFn; + injectStyleTags: { preprocessor: Preprocessor } | true | false; displayName: boolean; evaluate: boolean; ignore?: RegExp; diff --git a/src/babel/utils/loadOptions.ts b/src/babel/utils/loadOptions.ts index f508fbf5a..ebed02242 100644 --- a/src/babel/utils/loadOptions.ts +++ b/src/babel/utils/loadOptions.ts @@ -30,6 +30,7 @@ export default function loadOptions( action: 'ignore', }, ], + injectStyleTags: false, ...(result ? result.config : null), ...rest, }; diff --git a/src/preprocess.ts b/src/preprocess.ts new file mode 100644 index 000000000..776154660 --- /dev/null +++ b/src/preprocess.ts @@ -0,0 +1,89 @@ +import path from 'path'; +import type { Mapping } from 'source-map'; +import stylis from 'stylis'; +import type { Preprocessor, PreprocessorFn, Rules } from './types'; + +const STYLIS_DECLARATION = 1; +const posixSep = path.posix.sep; + +type PreprocessOptions = { + filename: string; + preprocessor?: Preprocessor; + outputFilename?: string | undefined; +}; + +export function transformUrl( + url: string, + outputFilename: string, + sourceFilename: string, + platformPath: typeof path = path +) { + // Replace asset path with new path relative to the output CSS + const relative = platformPath.relative( + platformPath.dirname(outputFilename), + // Get the absolute path to the asset from the path relative to the JS file + platformPath.resolve(platformPath.dirname(sourceFilename), url) + ); + + if (platformPath.sep === posixSep) { + return relative; + } + + return relative.split(platformPath.sep).join(posixSep); +} + +function getPreprocessor({ + preprocessor, + outputFilename, + filename, +}: PreprocessOptions): PreprocessorFn { + if (typeof preprocessor === 'function') { + return preprocessor; + } + switch (preprocessor) { + case 'none': + return (selector, text) => `${selector} {${text}}\n`; + case 'stylis': + default: + stylis.use(null)((context, decl) => { + if (context === STYLIS_DECLARATION && outputFilename) { + // When writing to a file, we need to adjust the relative paths inside url(..) expressions + // It'll allow css-loader to resolve an imported asset properly + return decl.replace( + /\b(url\((["']?))(\.[^)]+?)(\2\))/g, + (match, p1, p2, p3, p4) => + p1 + transformUrl(p3, outputFilename, filename) + p4 + ); + } + + return decl; + }); + + return stylis as PreprocessorFn; + } +} + +export default function preprocess( + rules: Rules, + options: PreprocessOptions +): { mappings: Mapping[]; cssText: string } { + const preprocessor = getPreprocessor(options); + const mappings: Mapping[] = []; + let cssText = ''; + + Object.keys(rules).forEach((selector, index) => { + mappings.push({ + generated: { + line: index + 1, + column: 0, + }, + original: rules[selector].start!, + name: selector, + source: '', + }); + + cssText += `${preprocessor(selector, rules[selector].cssText)}\n`; + }); + + return { mappings, cssText }; +} diff --git a/src/transform.ts b/src/transform.ts index 84f11e72e..d7a3e724b 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -7,39 +7,15 @@ * - return transformed code (without Linaria template literals), generated CSS, source maps and babel metadata from transform step. */ -import path from 'path'; import { parseSync, transformFromAstSync } from '@babel/core'; -import stylis from 'stylis'; -import type { Mapping } from 'source-map'; import { SourceMapGenerator } from 'source-map'; import loadOptions from './babel/utils/loadOptions'; import { debug } from './babel/utils/logger'; -import type { LinariaMetadata, Options, PreprocessorFn, Result } from './types'; +import preprocess from './preprocess'; +import type { LinariaMetadata, Options, Result } from './types'; -const STYLIS_DECLARATION = 1; -const posixSep = path.posix.sep; const babelPreset = require.resolve('./babel'); -export function transformUrl( - url: string, - outputFilename: string, - sourceFilename: string, - platformPath: typeof path = path -) { - // Replace asset path with new path relative to the output CSS - const relative = platformPath.relative( - platformPath.dirname(outputFilename), - // Get the absolute path to the asset from the path relative to the JS file - platformPath.resolve(platformPath.dirname(sourceFilename), url) - ); - - if (platformPath.sep === posixSep) { - return relative; - } - - return relative.split(platformPath.sep).join(posixSep); -} - export default function transform(code: string, options: Options): Result { // Check if the file contains `css` or `styled` words first // Otherwise we should skip transforming @@ -104,54 +80,8 @@ export default function transform(code: string, options: Options): Result { } = (metadata as babel.BabelFileMetadata & { linaria: LinariaMetadata; }).linaria; - const mappings: Mapping[] = []; - - let cssText = ''; - - let preprocessor: PreprocessorFn; - if (typeof options.preprocessor === 'function') { - // eslint-disable-next-line prefer-destructuring - preprocessor = options.preprocessor; - } else { - switch (options.preprocessor) { - case 'none': - preprocessor = (selector, text) => `${selector} {${text}}\n`; - break; - case 'stylis': - default: - stylis.use(null)((context, decl) => { - const { outputFilename } = options; - if (context === STYLIS_DECLARATION && outputFilename) { - // When writing to a file, we need to adjust the relative paths inside url(..) expressions - // It'll allow css-loader to resolve an imported asset properly - return decl.replace( - /\b(url\((["']?))(\.[^)]+?)(\2\))/g, - (match, p1, p2, p3, p4) => - p1 + transformUrl(p3, outputFilename, options.filename) + p4 - ); - } - return decl; - }); - - preprocessor = stylis; - } - } - - Object.keys(rules).forEach((selector, index) => { - mappings.push({ - generated: { - line: index + 1, - column: 0, - }, - original: rules[selector].start!, - name: selector, - source: '', - }); - - // Run each rule through stylis to support nesting - cssText += `${preprocessor(selector, rules[selector].cssText)}\n`; - }); + const { mappings, cssText } = preprocess(rules, options); return { code: transformedCode || '', diff --git a/src/types.ts b/src/types.ts index 6b8e44779..eb7c8ed84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,7 +15,7 @@ export type Rules = { [className: string]: { cssText: string; displayName: string; - start: Location | null; + start: Location | null | undefined; }; };