Last active
June 13, 2025 17:01
-
-
Save GCastilho/edf7c6a96b6bfa8e9dabb58b3e88931f to your computer and use it in GitHub Desktop.
This file contains hidden or 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 path from 'path' | |
import { createHash } from 'crypto' | |
import { existsSync, readFileSync } from 'fs' | |
const customElementSimpleTag = `{{ | |
tag: "{TAG_NAME}", | |
extend(svelteCustomElementClass) { | |
return class extends svelteCustomElementClass { | |
constructor() { | |
super(); | |
const sheet = new CSSStyleSheet(); | |
sheet.replaceSync({CSS_FILE_CONTENTS}); | |
this.shadowRoot?.adoptedStyleSheets.push(sheet); | |
} | |
}; | |
}, | |
}}` | |
const customElementExtendMethod = ` | |
extend(svelteCustomElementClass) { | |
return class extends svelteCustomElementClass { | |
constructor() { | |
super(); | |
const sheet = new CSSStyleSheet(); | |
sheet.replaceSync({CSS_FILE_CONTENTS}); | |
this.shadowRoot?.adoptedStyleSheets.push(sheet); | |
} | |
}; | |
},\ | |
` | |
/** | |
* @typedef {import('svelte/compiler').PreprocessorGroup} PreprocessorGroup | |
* @implements {PreprocessorGroup} | |
*/ | |
class CustomElementPreprocessor { | |
/** Detect indentation from the first non-empty line */ | |
_indentMatch = /^(?:\s*?\n)*([ \t]*)\S/m | |
name = 'CustomElementPreprocessor' | |
/** | |
* @param {string} cssAbsolutePath | |
* @param {string} cssHash | |
*/ | |
constructor(cssAbsolutePath, cssHash) { | |
this.cssAbsolutePath = cssAbsolutePath | |
this.cssHash = cssHash | |
} | |
/** | |
* @type {import('svelte/compiler').MarkupPreprocessor} | |
*/ | |
markup = options => { | |
const svelteOptionTag = options.content.match( | |
/<svelte:options\b[\s\S]*?\/>|<svelte:options\b[\s\S]*?<\/svelte:options\s*>/gi | |
)?.[0] | |
if (!svelteOptionTag) return | |
// Regex to capture the customElement prop, including quotes for string values | |
const customElementValueRegex = /customElement\s*=\s*(?:"([^"]*)"|'([^']*)'|{{([\s\S]*?)}}|(\S+))/i | |
const valueMatch = svelteOptionTag.match(customElementValueRegex) | |
const customElementValue = | |
valueMatch?.[1] !== undefined ? `"${valueMatch[1]}"` : // double quotes, keep quotes | |
valueMatch?.[2] !== undefined ? `'${valueMatch[2]}'` : // single quotes, keep quotes | |
(valueMatch?.[3] ? `{${valueMatch[3]}}` : null) ?? // object between {{ }}, keep only inner braces | |
valueMatch?.[4] ?? // simple value | |
null | |
const type = valueMatch?.[1] ?? valueMatch?.[2] ?? valueMatch?.[4] ? 'tag' | |
: valueMatch?.[3] ? 'object' | |
: null | |
if (!customElementValue || !type) return // Not a customElement | |
if (type == 'tag') { | |
// Extract tag name from customElementValue (handles quotes or no quotes) | |
const tagNameMatch = customElementValue.match(/^["']?([^"']+)["']?$/) | |
const tagName = tagNameMatch ? tagNameMatch[1] : customElementValue | |
const customElementNewValue = customElementSimpleTag | |
.replace('{TAG_NAME}', tagName) | |
.replace('{CSS_FILE_CONTENTS}', `css_${this.cssHash}`) | |
return { | |
code: options.content.replace(customElementValue, customElementNewValue) | |
} | |
} else if (type == 'object') { | |
const tagMatch = customElementValue.match(/tag\s*:\s*(?:(['"])(.*?)\1|([a-zA-Z0-9\-_]+))/) | |
const tagName = tagMatch?.[2] ?? tagMatch?.[3] | |
if (!tagName) return // Not a customElement | |
const shadowMatch = customElementValue.match(/^[ \t]*\/\/.*$|shadow\s*:\s*(?:(['"])(.*?)\1|([a-zA-Z0-9\-_]+))/m) | |
const shadowValue = shadowMatch?.[0].trim().startsWith('//') | |
? undefined | |
: shadowMatch?.[2] ?? shadowMatch?.[3] ?? undefined | |
if (shadowValue == 'none') return // Not using shadow Dom | |
const hasExtend = /(?:\bextend\s*\(|extend\s*:)/.test(customElementValue) | |
if (!hasExtend) { | |
const innerObjectRegex = /{([\s\S]*?)}/ | |
const innerObject = customElementValue.match(innerObjectRegex)?.[1] | |
if (!innerObject) return // Probably invalid syntax | |
const newInnerObject = customElementExtendMethod + innerObject | |
const newCustomElementValue = customElementValue.replace(innerObject, newInnerObject) | |
return { | |
code: options.content.replace(customElementValue, newCustomElementValue) | |
} | |
} | |
const constructorBlockRegex = /extend\s*:?\s*\([^)]*\)\s*(?:=>|\{)[\s\S]*?return\s+class\s+extends\s+[^{]*\{[\s\S]*?constructor\s*\([^)]*\)\s*\{([\s\S]*?)\}(?:\s*}.*){3}/ | |
const constructorBlock = customElementValue.match(constructorBlockRegex)?.[1] | |
if (!constructorBlock) return // Probably invalid syntax, just ignore it | |
const intent = constructorBlock?.match(this._indentMatch)?.[1] ?? '' | |
const injectedConstructorCode = [ | |
'const sheet_{CSS_HASH} = new CSSStyleSheet();', | |
'sheet_{CSS_HASH}.replaceSync({CSS_FILE_CONTENTS});', | |
'this.shadowRoot?.adoptedStyleSheets.push(sheet_{CSS_HASH});', | |
] | |
.map(s => `${intent}${s}`) | |
.join('\n') | |
.replaceAll('{CSS_HASH}', this.cssHash) | |
.replace('{CSS_FILE_CONTENTS}', `css_${this.cssHash}`) | |
const newConstructorBlock = constructorBlock | |
.split('\n') | |
.toSpliced(-1, 0, injectedConstructorCode) | |
.join('\n') | |
const newCustomElementValue = customElementValue.replace(constructorBlock, newConstructorBlock) | |
return { | |
code: options.content.replace(customElementValue, newCustomElementValue) | |
} | |
} | |
} | |
/** | |
* @type {import('svelte/compiler').Preprocessor} | |
*/ | |
script = options => { | |
if (!options.filename) return | |
let relativePath = path | |
.relative(path.dirname(options.filename), this.cssAbsolutePath) | |
.replace(/\\/g, '/') | |
// Ensure relativePath starts with './' if it's not going up a directory or already starting with '.' | |
if (!relativePath.startsWith('.') && !relativePath.startsWith('/')) { | |
relativePath = './' + relativePath | |
} | |
// Detect indentation from the first non-empty line | |
const indent = options.content.match(this._indentMatch)?.[1] ?? '' | |
return { | |
code: `\n${indent}import css_${this.cssHash} from "${relativePath}?raw";\n` + options.content | |
} | |
} | |
} | |
/** | |
* @typedef {Object} PreprocessorOptions | |
* @property {string} [cssFile] | |
*/ | |
/** | |
* @param {PreprocessorOptions} [options] | |
* @returns {import('svelte/compiler').PreprocessorGroup} | |
*/ | |
export default function preprocessor(options = {}) { | |
const { | |
cssFile = 'src/app.css', | |
} = options | |
const cssAbsolutePath = path.resolve(cssFile) | |
if (!existsSync(cssAbsolutePath)) { | |
console.debug(`CustomElementPreprocessor: css file ${cssFile} not found. Skipping preprocess`) | |
return {} | |
} | |
const cssContents = readFileSync(cssAbsolutePath, 'utf8') | |
const cssHash = createHash('sha256') | |
.update(cssContents) | |
.digest('hex') | |
.slice(0, 8) | |
return new CustomElementPreprocessor(cssAbsolutePath, cssHash) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment