Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save GCastilho/edf7c6a96b6bfa8e9dabb58b3e88931f to your computer and use it in GitHub Desktop.
Save GCastilho/edf7c6a96b6bfa8e9dabb58b3e88931f to your computer and use it in GitHub Desktop.
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