Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
import {ELElement, ELHTMLComment, ELHTMLStyleElement, ELHTMLElement, ELCSSStyleDeclaration, ELCSSStyleSheet} from "./schemas/elementary"
/**
*
* Elementary has to decide what to do based on the data structure passd to it
* An array is recursed over, an object is made into an HTMLElement or an HTMLStyleELement
* Null is turned into a blank string, bool, numbers and strings are returned as strings.
*/
function elementary(el: ELElement | ELElement[]) : string
{
if(el instanceof Array)
{
return el.map(elementary).join('')
}
if(el instanceof Object)
{
switch(/* tagName */ Object.keys(el).pop().toLowerCase())
{
case '!':
return bakeHTMLComment(el as ELHTMLComment)
case 'style':
return bakeHTMLStyleElement(el as ELHTMLStyleElement)
default:
return bakeHTMLElement(el as ELHTMLElement)
}
}
else
{
return el ? String(el) : ""
}
}
/**
* Elementary DOM use '!' as a special tagname to indicate a comment that can be printed to the console
* Could be modified to allow arbitrary object to get printed to console but I'll keep it simple to start
*/
function bakeHTMLComment(comment: ELHTMLComment): string
{
return process.env.NOSCRIPT
? `\n<!-- ${JSON.stringify(comment)} -->\n`
: `\n<script>console.warn(JSON.parse(${JSON.stringify(comment)}))</script>\n`
}
/**
* Everything besides <!-- --> and <style> is a generic HTML element
* Elementary HTML elements have one of two structures:
* {tagName: ELElement[]}
* or
* {tagName: {
* style?: ELCSSStyleDeclaration;
* childNodes?: ELHTMLElement[];
* [HTMLAttribute: string]: string;
* }}
*
*/
function bakeHTMLElement(el: ELHTMLElement): string
{
let [[tagName, attributes]] = Object.entries(el)
if(attributes instanceof Array)
{
return interpolate(tagName, /* innerHTML (childNodes) */ attributes.map(elementary))
}
else
{
let innerHTML = []
let outerHTML = []
for(var [attributeName, attributeValue] of Object.entries(attributes))
{
switch(attributeName)
{
case 'childNodes':
// convert entire childNodes array to a string representing the innerHTML of those nodes
innerHTML.push(elementary(attributeValue as ELElement[]))
break
case 'style':
// stringify the CSSStyleDeclaration to inline css, defaults to using space as separator between rules
outerHTML.push(` style=${bakeCSSStyleDeclaration(attributeValue as ELCSSStyleDeclaration)}`)
break
default:
// should probably sanitize these values with .replace('"', '&quot;').replace('&', '&amp;') etc
outerHTML.push(` "${attributeName}"="${attributeValue as string}"`)
}
}
return interpolate(tagName, innerHTML, outerHTML)
}
}
/**
* Called for HTMLElements whose tagName is style
* Doesn't support html attributes on the style tag, use a link tag to external stylesheet if you need media/type attributes
*/
function bakeHTMLStyleElement(el: ELHTMLStyleElement): string
{
return interpolate("style", [bakeCSSStyleSheet(el.style)])
}
/**
* Elementary CSSStyleSheets take the form
* {[selectorText: string]: CSSStyleDeclaration }
* However, selectorText starting with '@' defines at rules that can take one of 3 forms, flat, normal, or nested
*
* Is only ever called to stitch together the "innerHTML" of a <style> tag
*/
function bakeCSSStyleSheet(stylesheet: ELCSSStyleSheet): string
{
let CSSRules = []
for(var [selectorText, rule] of Object.entries(stylesheet))
{
if(selectorText[0] == '@')
{
switch(extractAtRule(selectorText))
{
case 'namespace':
case 'charset':
case 'import':
// 'flat' rules, one liner, like '@import url("fineprint.css") print; have no body'
CSSRules.push(`${selectorText} ${rule as string};`)
break
case 'keyframes':
case 'media':
case 'supports':
// 'nested' rules, like '@media screen and (min-width: 900px)' recurse this function for their body
CSSRules.push(`${selectorText} {\n${bakeCSSStyleSheet(rule as ELCSSStyleSheet)}\n}`)
break
case 'font-face':
case 'page':
// 'normal' rules aren't any different than non-@-rules, embed CSSStyleDeclaration as their body
CSSRules.push(`${selectorText} {\n${bakeCSSStyleDeclaration(rule as ELCSSStyleDeclaration, "\n")}\n}`)
}
}
else
{
CSSRules.push(`${selectorText}: {\n${bakeCSSStyleDeclaration(rule as ELCSSStyleDeclaration, "\n")}\n}`)
}
}
return CSSRules.join('\n')
}
/**
* A helper function for bakeCSSStyleSheet's switch statement, for @import, @font-face, etc
* Is only called after confirming that selectorText starts with an '@', so its a string of at least length one
*/
function extractAtRule(selectorText: string): string
{
if(selectorText.includes(' '))
{
return selectorText.slice(1, selectorText.indexOf(' '))
}
else
{
return selectorText.slice(1)
}
}
/**
* Takes an object which is a mapping
* I want to use TypeScript DOM's CSSStyleDeclaration type so I validate css descriptor names,
* but that requires using javascriptish camel case and converting to valid css, which I kind of don't like.
* and TypeScript CSSStyleDeclaration doens't include the 'src' descriptor for @font-face, so I would need to add that somehow
*
* defaults to separating declarations with a single space for inline style, but overridden with '\n' for stylesheets
*/
function bakeCSSStyleDeclaration(RuleValuePairs: ELCSSStyleDeclaration, seperator: " " | "\n" = " "): string
{
return Object.entries(RuleValuePairs).map
(
([CSSPropertyName, CSSPropertyValue]) => `${CSSPropertyName}: ${CSSPropertyValue};`
).join(seperator)
}
function interpolate(tagName: string, innerHTML:string[] = [], outerHTML:string[] = []): string
{
// cant deal with arguments of improper type, will throw error calling join
if(empty_elements.includes(tagName))
{
return `\n<${tagName}${outerHTML.join('')}>`
}
else
{
return `\n<${tagName}${outerHTML.join('')}>${innerHTML.join('')}</${tagName}>`
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment