Created
May 19, 2024 23:32
-
-
Save brantwedel/fefd89a1ac4ac7144126787967cf6209 to your computer and use it in GitHub Desktop.
CSS scoped modules (Express, React)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import express from 'express'; | |
import path from 'path'; | |
import fs from 'fs'; | |
const app = express(); | |
const transformCssModule = (cssFilePath, cssUrlPath, scopeId, isolated = true) => { | |
const cssContent = fs.readFileSync(cssFilePath, 'utf8'); | |
const classNames = new Set(cssContent.match(/\.[\w-_]+/g) || []); | |
let exportedClasses = `const isolated = ${isolated ? 'true' : 'false'};\nconst _styles = { $scoped: '__scoped', scoped: '__scoped', ':scoped': '__scoped', root: '__root', $root: '__root', ':root': '__root', host: '__root', $host: '__root', ':host': '__root' };\n`; | |
// Step 1: Mark classes following `>>>` with `__NOSCOPE__` | |
let transformedCss = cssContent.replace(/>>>( *\.?[\w-_]+[\s>+{,])+/g, (match) => { | |
return match.replace(/\.?\w+/g, (className) => `${className}__NOSCOPE__`); | |
}); | |
// Step 1: Mark classes in :global() with `__NOSCOPE__` | |
transformedCss = transformedCss.replace(/\:global\((.*)\)/g, (match, p1) => { | |
return p1.replace(/\w+/g, (className) => `${className}__NOSCOPE__`); | |
}); | |
// Step 2: Add scope to all other classes, making sure not to scope `__NOSCOPE__` marked classes | |
transformedCss = transformedCss.replace(/(\.|^)([\w-_]+)(?![^{}]*[;}])/g, (match, p1, p2) => { | |
if (match.includes('__NOSCOPE__')) { | |
return match; // Skip adding scope to `__NOSCOPE__` classes | |
} | |
return `${p1}${p2}${p1 === '.' ? '--__SCOPE__' : ''}`; | |
}); | |
// Step 3: Scope raw tag selectors by adding a class parent, unless the selector already contains __SCOPE__ | |
transformedCss = transformedCss.replace(/(^|\s)([^{]+?)(?=\s*{)/g, (match, p1, selector) => { | |
let rootSelector = selector; | |
if (!(selector.includes('__SCOPE__') )) { | |
rootSelector = selector.replace(/(^|\s)([a-z]+)(?=\s|,|;|\{|$)(?![^{}]*\})/gi, (m, sp, tag) => { | |
if (/^[a-z]+$/.test(tag)) { | |
return `${sp}.__root--__SCOPEID__ ${tag}`; | |
} | |
return m; | |
}); | |
} | |
const scopedSelector = selector.replace(/(^|\s)([a-z]+)(?=\s|,|;|\{|$)(?![^{}]*\})/gi, (m, sp, tag) => { | |
if (!sp.includes('__NOSCOPE__') && /^[a-z]+$/.test(tag)) { | |
return `${sp}${tag}.__scoped--__SCOPEID__`; | |
} | |
return m; | |
}); | |
return `${p1}${rootSelector}, ${scopedSelector}`; | |
}); | |
// Remove the __NOSCOPE__ suffix added previously | |
transformedCss = transformedCss.replace(/__NOSCOPE__/g, ''); | |
transformedCss = transformedCss.replace(/ >>> /g, ' ').replace(/>>>/g, ''); | |
transformedCss = transformedCss.replace(/\:root/g, '.__root--__SCOPEID__'); | |
// Remove redundant nested scopes | |
transformedCss = transformedCss.replace(/\.__root--__SCOPEID__ (\w+)\s+\.__root--__SCOPEID__/g, '.__root--__SCOPEID__ $1'); | |
// Separate selectors from properties | |
const scopedCss = transformedCss.replace(/([^{]+)\{([^}]+)\}/g, (match, selectors, properties) => { | |
const scopedSelectors = selectors.replace(/(\.|^)([\w-_]+)/g, (selMatch, selP1, selP2) => { | |
if (selMatch.includes('--__SCOPE__') || true) { | |
return selMatch; // Skip already scoped selectors | |
} | |
return `${selP1}${selP2}${selP1 === '.' ? '--__SCOPE__' : ''}`; | |
}); | |
return `${scopedSelectors} {${properties}}`; | |
}); | |
classNames.forEach((className) => { | |
const cleanClassName = className.replace('.', '').trim(); | |
const camelCaseName = cleanClassName.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); | |
// Export global styles | |
exportedClasses += `_styles['${cleanClassName}'] = '${cleanClassName}';\n`; | |
exportedClasses += `_styles['${camelCaseName}'] = '${cleanClassName}';\n`; | |
}); | |
let scopedFunction = ` | |
let scopedCalled = false; | |
const scoped = (id) => { | |
scopedCalled = true; | |
let scopedCss = \`${scopedCss.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\`; | |
if (isolated) { | |
scopedCss = scopedCss.replace(/--__SCOPE__/g, '.__scoped--' + id).replace(/--__SCOPEID__/g, '--' + id); | |
} else { | |
scopedCss = scopedCss.replace(/--__SCOPE__/g, '.__scoped--' + id).replace(/--__SCOPEID__/g, '--' + id); | |
} | |
const style = document.createElement('style'); | |
style.textContent = scopedCss; | |
document.head.appendChild(style); | |
const scopedStyles = {}; | |
Object.keys(_styles).forEach(key => { | |
if (isolated || _styles[key].startsWith('__')) { | |
scopedStyles[key] = _styles[key] + '--' + id; | |
} else { | |
scopedStyles[key] = '__scoped--' + id + ' ' + _styles[key]; | |
} | |
}); | |
return scopedStyles; | |
};`; | |
if (!scopeId) { | |
scopedFunction += ` | |
console.debug('%cLoaded unscoped styles: ' + '${cssUrlPath}', 'color: dodgerblue;'); | |
const style = document.createElement('style'); | |
style.textContent = \`${cssContent.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\`; | |
document.head.appendChild(style); | |
const styles = _styles; | |
`; | |
} else { | |
scopedFunction += ` | |
const styles = scoped('${scopeId}'); | |
`; | |
} | |
const wrappedCss = ` | |
${exportedClasses} | |
${scopedFunction} | |
let helper = function(...args) { | |
let flatArgs = new Set((!isolated ? ['__scoped--${scopeId}'] : [])); | |
let vals = Object.values(styles); | |
args.forEach(arg => { | |
if (!arg) { | |
flatArgs.add('--missing'); | |
} else { | |
if (vals.includes(arg)) { | |
arg.split(' ').forEach(v => { | |
flatArgs.add(v); | |
}); | |
} else { | |
arg.split(' ').forEach(k => { | |
(styles[k] ?? k ?? '').split(' ').forEach(v => { | |
flatArgs.add(v); | |
}); | |
}); | |
} | |
} | |
}); | |
return [...flatArgs].join(' '); | |
} | |
Object.keys(styles).forEach(k => { | |
helper[k] = styles[k]; | |
}); | |
export { styles, scoped, helper }; | |
export default helper; | |
`; | |
return wrappedCss; | |
}; | |
// handle requests for CSS modules | |
app.get(/\/.*.css([@?].*)?\.js$/, (req, res) => { | |
const relativePath = req.url.replace('.js', '').replace(/css[@?][^\.]*/, 'css'); | |
const cssFilePath = path.join(__dirname, '..', 'client', relativePath); | |
const cssUrlPath = '' + relativePath; // Adjust this based on your public directory structure | |
fs.readFile(cssFilePath, 'utf8', (err, data) => { | |
if (err) { | |
// CSS file does not exist, send a script to log a warning in the browser | |
const warningScript = ` | |
console.warn('CSS file "${cssFilePath}" not found.'); | |
`; | |
res.type('application/javascript'); | |
res.send(warningScript); | |
return; | |
} | |
// Optionally handle scope parsing here if needed | |
const scopeMatch = req.url.match(/[@?]([^&\.]*)/); | |
const scopeId = scopeMatch ? scopeMatch[1].replace('!', '') : req.url.replace('.css.js', '').replace(/[\/\.]/g,'-'); | |
const wrappedCss = transformCssModule(cssFilePath, cssUrlPath, scopeId, (req.url.includes('@') || req.url.includes('?')) && !req.url.includes('!') ? false : true); // Assuming we want to use a link tag | |
res.type('application/javascript'); | |
res.send(wrappedCss); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment