├── .github └── workflows │ ├── ci.yml │ └── cla.yml ├── .gitignore ├── README.md ├── index.mjs ├── lib ├── is-custom-element.mjs ├── transcode.mjs └── walk.mjs ├── package.json └── test ├── enhance.test.mjs ├── fixtures └── templates │ ├── multiple-slots.mjs │ ├── my-button.mjs │ ├── my-content.mjs │ ├── my-context-child.mjs │ ├── my-context-parent.mjs │ ├── my-counter.mjs │ ├── my-custom-heading-with-named-slot.mjs │ ├── my-custom-heading.mjs │ ├── my-empty-style.mjs │ ├── my-external-script.mjs │ ├── my-heading.mjs │ ├── my-id.mjs │ ├── my-instance-id.mjs │ ├── my-link-node-first.mjs │ ├── my-link-node-second.mjs │ ├── my-link.mjs │ ├── my-list-container.mjs │ ├── my-list.mjs │ ├── my-multiples.mjs │ ├── my-outline.mjs │ ├── my-page.mjs │ ├── my-paragraph.mjs │ ├── my-pre-page.mjs │ ├── my-pre.mjs │ ├── my-slot-as.mjs │ ├── my-store-data.mjs │ ├── my-style-import-first.mjs │ ├── my-style-import-second.mjs │ ├── my-super-heading.mjs │ ├── my-title.mjs │ ├── my-transform-script.mjs │ ├── my-transform-style.mjs │ └── my-wrapped-heading.mjs └── is-custom-element.test.mjs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | # Push tests commits; pull_request tests PR merges 4 | on: [ push, pull_request ] 5 | 6 | defaults: 7 | run: 8 | shell: bash 9 | 10 | jobs: 11 | # ----- Only git tag testing + package publishing beyond this point ----- # 12 | 13 | # Publish to package registries 14 | publish: 15 | # Setup 16 | if: startsWith(github.ref, 'refs/tags/v') 17 | runs-on: ubuntu-latest 18 | 19 | # Go 20 | steps: 21 | - name: Check out repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Set up Node.js 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 14 28 | registry-url: https://registry.npmjs.org/ 29 | 30 | # Publish to npm 31 | - name: Publish @RC to npm 32 | if: contains(github.ref, 'RC') 33 | run: npm publish --tag RC --access public 34 | env: 35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | 37 | - name: Publish @latest to npm 38 | if: contains(github.ref, 'RC') == false #'!contains()'' doesn't work lol 39 | run: npm publish --access public 40 | env: 41 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | 43 | -------------------------------------------------------------------------------- /.github/workflows/cla.yml: -------------------------------------------------------------------------------- 1 | name: "CLA Assistant" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened,closed,synchronize] 7 | 8 | permissions: 9 | actions: write 10 | contents: write 11 | pull-requests: write 12 | statuses: write 13 | 14 | jobs: 15 | CLAAssistant: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: "CLA Assistant" 19 | if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' 20 | uses: contributor-assistant/github-action@v2.4.0 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 24 | with: 25 | path-to-signatures: 'signatures/v1/cla.json' 26 | path-to-document: 'https://github.com/enhance-dev/.github/blob/main/CLA.md' 27 | branch: 'main' 28 | allowlist: brianleroux,colepeters,kristoferjoseph,macdonst,ryanbethel,ryanblock,tbeseda 29 | remote-organization-name: enhance-dev 30 | remote-repository-name: .github 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | package-lock 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Enhance SSR 2 | 3 | Server side render for [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements). 4 | 5 | Enhance enables a web standards based workflow that embraces the platform by supporting Custom Elements and [slot syntax](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_slots#adding_flexibility_with_slots). 6 | 7 | 8 | ## Install 9 | `npm i @enhance/ssr` 10 | 11 | ## Usage 12 | ```javascript 13 | import HelloWorld from './path/to/elements/hello-world.mjs' 14 | import enhance from '@enhance/ssr' 15 | const html = enhance({ 16 | elements: { 17 | 'hello-world': HelloWorld 18 | } 19 | }) 20 | console.log(html``) 21 | ``` 22 | ### An example custom element template for use in Server Side Rendering 23 | Elements are pure functions that are passed an object containing an `html` function used to expand custom elements and a state object comprised of `attrs` which are the attributes set on the custom element and a `store` object that contains application state. 24 | ```javascript 25 | export default function HelloWorld({ html, state }) { 26 | const { attrs } = state 27 | const { greeting='Hello World' } = attrs 28 | return html` 29 | 34 | 35 |

${greeting}

36 | ` 37 | } 38 | ``` 39 | 40 | The rendered output 41 | ```javascript 42 | 43 | 48 | 49 | 50 | 51 | 52 |

Hello World

53 |
54 | 55 | ``` 56 | 57 | ### Render function 58 | You can also use an object that exposes a `render` function as your template. The render function will be passed the same arguments `{ html:function, state:object }`. 59 | 60 | ```javascript 61 | { 62 | attrs: [ 'label' ], 63 | init(el) { 64 | el.addEventListener('click', el.click) 65 | }, 66 | render({ html, state }) { 67 | const { attrs={} } = state 68 | const { label='Nope' } = attrs 69 | return html` 70 |
 71 |       ${JSON.stringify(state)}
 72 |     
73 | 74 | ` 75 | }, 76 | click(e) { 77 | console.log('CLICKED') 78 | }, 79 | adopted() { 80 | console.log('ADOPTED') 81 | }, 82 | connected() { 83 | console.log('CONNECTED') 84 | }, 85 | disconnected() { 86 | console.log('DISCONNECTED') 87 | } 88 | } 89 | ``` 90 | > Use these options objects with the [enhance custom element factory](https://github.com/enhance-dev/enhance) 91 | 92 | ### Store 93 | Supply initital state to enhance and it will be passed along in a `store` object nested inside the state object. 94 | 95 | #### Node 96 | ```javascript 97 | import MyStoreData from './path/to/elements/my-store-data.mjs' 98 | import enhance from '@enhance/ssr' 99 | const html = enhance({ 100 | elements: { 101 | 'my-store-data': MyStoreData 102 | }, 103 | initialState: { apps: [ { users: [ { name: 'tim', id: 001 }, { name: 'kim', id: 002 } ] } ] } 104 | }) 105 | console.log(html``) 106 | ``` 107 | ### Element template 108 | ```javascript 109 | export default function MyStoreData({ html, state }) { 110 | const { attrs, store } = state 111 | const appIndex = attrs['app-index'] 112 | const userIndex = attrs['user-index'] 113 | const { id='', name='' } = store?.apps?.[appIndex]?.users?.[userIndex] || {} 114 | return ` 115 |
116 |

${name}

117 |

${id}

118 |
119 | ` 120 | } 121 | ``` 122 | The store is used to pass state to all components in the tree. 123 | 124 | ### Slots 125 | Enhance supports the use of [`slots` in your custom element templates](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_slots). 126 | ```javascript 127 | export default function MyParagraph({ html }) { 128 | return html` 129 |

130 | 131 | My default text 132 | 133 |

134 | ` 135 | } 136 | ``` 137 | You can override the default text by adding a slot attribute with a value that matches the slot name you want to replace. 138 | ```html 139 | 140 | Let's have some different text! 141 | 142 | ``` 143 | 144 | #### Unnamed slots 145 | Enhance supports unnamed slots for when you want to create a container element for all non-slotted child nodes. 146 | 147 | ```javascript 148 | export default function MyButton({ html }) { 149 | return html` 150 | 153 | ` 154 | } 155 | ``` 156 | 157 | ```html 158 | 159 | 160 | Save 161 | 162 | ``` 163 | 164 | ### Transforms 165 | Enhance supports the inclusion of script and style transform functions. You add a function to the array of `scriptTransforms` and/or `styleTransforms` and are able to transform the contents however you wish, just return your desired output. 166 | 167 | ```javaScript 168 | import enhance from '@enhance/ssr' 169 | 170 | const html = enhance({ 171 | elements: { 172 | 'my-transform-script': MyTransformScript 173 | }, 174 | scriptTransforms: [ 175 | function({ attrs, raw }) { 176 | // raw is the raw text from inside the script tag 177 | // attrs are the attributes from the script tag 178 | return raw + ' yolo' 179 | } 180 | ], 181 | styleTransforms: [ 182 | function({ attrs, raw }) { 183 | // raw is the raw text from inside the style tag 184 | // attrs are the attributes from the style tag 185 | const { scope } = attrs 186 | return ` 187 | /* Scope: ${ scope } */ 188 | ${ raw } 189 | ` 190 | } 191 | ] 192 | }) 193 | 194 | function MyTransformScript({ html }) { 195 | return html` 196 | 201 |

My Transform Script

202 | 210 | ` 211 | } 212 | 213 | console.log(html``) 214 | ``` 215 | 216 | ### `context` 217 | There are times you will need to pass state to nested child custom elements. To avoid the tedium of passing attributes through multiple levels of nested elements Enhance SSR supplies a `context` object to add state to. 218 | 219 | Parent sets context 220 | 221 | ```javaScript 222 | export default function MyContextParent({ html, state }) { 223 | const { attrs, context } = state 224 | const { message } = attrs 225 | context.message = message 226 | 227 | return html` 228 | 229 | ` 230 | } 231 | 232 | ``` 233 | Child retrieves state from parent supplied context 234 | 235 | ```javaScript 236 | export default function MyContextChild({ html, state }) { 237 | const { context } = state 238 | const { message } = context 239 | return html` 240 | ${ message } 241 | ` 242 | } 243 | ``` 244 | 245 | Authoring 246 | 247 | ```javaScript 248 | 249 |
250 | 251 | 252 | 253 |
254 |
255 | ``` 256 | 257 | ### `instanceID` 258 | When rendering custom elements from a single template there are times where you may need to target a specific instance. The `instanceID` is passed in the `state` object. 259 | 260 | ```javaScript 261 | export default function MyInstanceID({ html, state }) { 262 | const { instanceID='' } = state 263 | 264 | return html` 265 |

${instanceID}

266 | ` 267 | } 268 | 269 | ``` 270 | 271 | ### `bodyContent` 272 | Enhance SSR outputs an entire valid HTML page but you can pass `bodyContent: true` to get just the content of the `` element. This can be useful for when you want to isolate the HTML output to just the Custom Element(s) you are authoring. 273 | 274 | ```javaScript 275 | const html = enhance({ 276 | bodyContent: true, 277 | elements: { 278 | 'my-paragraph': MyParagraph, 279 | } 280 | }) 281 | const output = html` 282 | 283 | ` 284 | ``` 285 | 286 | ### Integration with ``, ``, and `` elements 287 | 288 | Enhance SSR will intelligently merge its rendered ``, ``, and `` elements with any that you provide to it (unless you choose to use the `bodyContent: true` option). 289 | 290 | For example: 291 | 292 | ```js 293 | const html = enhance({ 294 | elements: { 295 | 'my-content': MyContent, 296 | } 297 | }) 298 | 299 | html` 300 | 301 | 302 | 303 | My Website 304 | 305 | 306 | 307 | 308 | 309 | ` 310 | ``` 311 | 312 | > P.S. Enhance works really well with [Begin](https://begin.com). 313 | 314 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | import { parse, fragment, serialize, serializeOuter } from '@begin/parse5' 2 | import isCustomElement from './lib/is-custom-element.mjs' 3 | import { encode, decode } from './lib/transcode.mjs' 4 | import walk from './lib/walk.mjs' 5 | import { customAlphabet } from 'nanoid' 6 | const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; 7 | const nanoid = customAlphabet(alphabet, 7); 8 | 9 | export default function Enhancer(options={}) { 10 | const { 11 | initialState={}, 12 | elements=[], 13 | scriptTransforms=[], 14 | styleTransforms=[], 15 | uuidFunction=nanoid, 16 | bodyContent=false, 17 | enhancedAttr=true 18 | } = options 19 | const store = Object.assign({}, initialState) 20 | 21 | function processCustomElements({ node }) { 22 | const collectedStyles = [] 23 | const collectedScripts = [] 24 | const collectedLinks = [] 25 | const context = {} 26 | 27 | walk(node, child => { 28 | if (isCustomElement(child.tagName)) { 29 | if (elements[child.tagName]) { 30 | const { 31 | frag:expandedTemplate, 32 | styles:stylesToCollect, 33 | scripts:scriptsToCollect, 34 | links:linksToCollect 35 | } = expandTemplate({ 36 | node: child, 37 | elements, 38 | state: { 39 | context, 40 | instanceID: uuidFunction(), 41 | store 42 | }, 43 | styleTransforms, 44 | scriptTransforms 45 | }) 46 | 47 | if (enhancedAttr) { 48 | child.attrs.push({ name: 'enhanced', value:'✨' }) 49 | } 50 | collectedScripts.push(scriptsToCollect) 51 | collectedStyles.push(stylesToCollect) 52 | collectedLinks.push(linksToCollect) 53 | fillSlots(child, expandedTemplate) 54 | } 55 | } 56 | }) 57 | 58 | return { 59 | collectedStyles, 60 | collectedScripts, 61 | collectedLinks 62 | } 63 | } 64 | 65 | return function html(strings, ...values) { 66 | const doc = parse(render(strings, ...values)) 67 | const html = doc.childNodes.find(node => node.tagName === 'html') 68 | const body = html.childNodes.find(node => node.tagName === 'body') 69 | const head = html.childNodes.find(node => node.tagName === 'head') 70 | const { 71 | collectedStyles, 72 | collectedScripts, 73 | collectedLinks 74 | } = processCustomElements({ node: body }) 75 | if (collectedScripts.length) { 76 | const uniqueScripts = collectedScripts.flat().reduce((acc, script) => { 77 | const scriptSrc = script?.attrs?.find(a => a.name === 'src') 78 | const scriptSrcValue = scriptSrc?.value 79 | const scriptContents = script?.childNodes?.[0]?.value 80 | if (scriptContents || scriptSrc) { 81 | return { 82 | ...acc, 83 | [scriptContents || scriptSrcValue]: script 84 | } 85 | } 86 | return {...acc} 87 | }, {}) 88 | 89 | appendNodes(body, Object.values(uniqueScripts)) 90 | } 91 | if (collectedStyles.length) { 92 | const uniqueStyles = collectedStyles.flat().reduce((acc, style) => { 93 | if (style?.childNodes?.[0]?.value) { 94 | return { ...acc, [style.childNodes[0].value]: '' } 95 | } 96 | return {...acc} 97 | }, { }) 98 | const mergedCss = Object.keys(uniqueStyles) 99 | mergedCss.sort((a, b) => { 100 | const aStart = a.trim().substring(0,7) 101 | const bStart = b.trim().substring(0,7) 102 | if (aStart === '@import' && bStart !== '@import') return -1 103 | if (aStart !== '@import' && bStart === '@import') return 1 104 | return 0 105 | }) 106 | const mergedCssString = mergedCss.join('\n') 107 | const mergedStyles = mergedCssString? ``:'' 108 | if (mergedStyles) { 109 | const stylesNodeHead = [fragment(mergedStyles).childNodes[0]] 110 | appendNodes(head, stylesNodeHead) 111 | } 112 | } 113 | if (collectedLinks.length) { 114 | const uniqueLinks = collectedLinks.flat().reduce((acc, link) => { 115 | if (link) { 116 | return { 117 | ...acc, 118 | [normalizeLinkHtml(link)]: link 119 | } 120 | } 121 | return {...acc} 122 | }, {}) 123 | 124 | appendNodes(head, Object.values(uniqueLinks)) 125 | } 126 | 127 | return (bodyContent 128 | ? serializeOuter(body.childNodes[0]) 129 | : serialize(doc)) 130 | .replace(/__b_\d+/g, '') 131 | } 132 | } 133 | 134 | function render(strings, ...values) { 135 | const collect = [] 136 | for (let i = 0; i < strings.length - 1; i++) { 137 | collect.push(strings[i], encode(values[i])) 138 | } 139 | collect.push(strings[strings.length - 1]) 140 | return collect.join('') 141 | } 142 | 143 | function expandTemplate({ node, elements, state, styleTransforms, scriptTransforms }) { 144 | const tagName = node.tagName 145 | const frag = renderTemplate({ 146 | name: node.tagName, 147 | elements, 148 | attrs: node.attrs, 149 | state 150 | }) || '' 151 | const styles= [] 152 | const scripts = [] 153 | const links = [] 154 | for (const node of frag.childNodes) { 155 | if (node.nodeName === 'script') { 156 | frag.childNodes.splice(frag.childNodes.indexOf(node), 1) 157 | const transformedScript = applyScriptTransforms({ node, scriptTransforms, tagName }) 158 | if (transformedScript) { 159 | scripts.push(transformedScript) 160 | } 161 | } 162 | if (node.nodeName === 'style') { 163 | frag.childNodes.splice(frag.childNodes.indexOf(node), 1) 164 | const transformedStyle = applyStyleTransforms({ node, styleTransforms, tagName, context: 'markup' }) 165 | if (transformedStyle) { 166 | styles.push(transformedStyle) 167 | } 168 | } 169 | if (node.nodeName === 'link') { 170 | frag.childNodes.splice(frag.childNodes.indexOf(node), 1) 171 | links.push(node) 172 | } 173 | } 174 | return { frag, styles, scripts, links } 175 | } 176 | 177 | function normalizeLinkHtml(node) { 178 | const attrs = Array.from(node.attrs) 179 | .sort((a, b) => { 180 | if (a.name < b.name) { 181 | return -1; 182 | } else if (b.name < a.name) { 183 | return 1 184 | } 185 | return 0 186 | }) 187 | .map(attr => `${attr.name}="${attr.value}"`) 188 | 189 | return `` 190 | } 191 | 192 | function renderTemplate({ name, elements, attrs=[], state={} }) { 193 | attrs = attrs ? attrsToState(attrs) : {} 194 | state.attrs = attrs 195 | const templateRenderFunction = elements[name]?.render || elements[name]?.prototype?.render 196 | const template = templateRenderFunction 197 | ? templateRenderFunction 198 | : elements[name] 199 | 200 | if (template && typeof template === 'function') { 201 | return fragment(template({ html: render, state })) 202 | } 203 | else { 204 | throw new Error(`Could not find the template function for ${name}`) 205 | } 206 | } 207 | 208 | function attrsToState(attrs=[], obj={}) { 209 | [...attrs].forEach(attr => obj[attr.name] = decode(attr.value)) 210 | return obj 211 | } 212 | 213 | function fillSlots(node, template) { 214 | const slots = findSlots(template) 215 | const inserts = findInserts(node) 216 | const usedSlots = [] 217 | const usedInserts = [] 218 | const unnamedSlots = [] 219 | for (let i=0; i { 263 | const nodeChildren = node.childNodes 264 | .filter(node => !usedInserts.includes(node)) 265 | const children = nodeChildren.length 266 | ? nodeChildren 267 | : [ ...slot.childNodes ] 268 | const slotParentChildNodes = slot.parentNode.childNodes 269 | slotParentChildNodes.splice( 270 | slotParentChildNodes 271 | .indexOf(slot), 272 | 1, 273 | ...children 274 | ) 275 | }) 276 | 277 | const unusedSlots = slots.filter(slot => !usedSlots.includes(slot)) 278 | const nodeChildNodes = node.childNodes 279 | 280 | replaceSlots(template, unusedSlots) 281 | nodeChildNodes.splice( 282 | 0, 283 | nodeChildNodes.length, 284 | ...template.childNodes 285 | ) 286 | } 287 | 288 | function findSlots(node) { 289 | const elements = [] 290 | const find = (node) => { 291 | for (const child of node.childNodes) { 292 | if (child.tagName === 'slot') { 293 | elements.push(child) 294 | } 295 | if (child.childNodes) { 296 | find(child) 297 | } 298 | } 299 | } 300 | find(node) 301 | return elements 302 | } 303 | 304 | function findInserts(node) { 305 | const elements = [] 306 | const find = (node) => { 307 | for (const child of node.childNodes) { 308 | const hasSlot = child.attrs?.find(attr => attr.name === 'slot') 309 | if (hasSlot) { 310 | elements.push(child) 311 | } 312 | } 313 | } 314 | find(node) 315 | return elements 316 | } 317 | 318 | function replaceSlots(node, slots) { 319 | slots.forEach(slot => { 320 | const value = slot.attrs.find(attr => attr.name === 'name')?.value 321 | const asTag = slot.attrs.find(attr => attr.name === 'as')?.value 322 | const name = 'slot' 323 | const slotChildren = slot.childNodes.filter( 324 | n => { 325 | return !n.nodeName.startsWith('#') 326 | } 327 | ) 328 | if (value) { 329 | if (!slotChildren.length || slotChildren.length > 1) { 330 | // Only has text nodes 331 | const wrapperSpan = { 332 | nodeName: asTag ? asTag : 'span', 333 | tagName: asTag ? asTag : 'span', 334 | attrs: [{ value, name }], 335 | namespaceURI: 'http://www.w3.org/1999/xhtml', 336 | childNodes: [] 337 | } 338 | 339 | wrapperSpan.childNodes.push(...slot.childNodes) 340 | slot.childNodes.length = 0 341 | slot.childNodes.push(wrapperSpan) 342 | } 343 | if (slotChildren.length === 1) { 344 | slotChildren[0].attrs.push({ value, name }) 345 | } 346 | 347 | const slotParentChildNodes = slot.parentNode.childNodes 348 | slotParentChildNodes.splice( 349 | slotParentChildNodes 350 | .indexOf(slot), 351 | 1, 352 | ...slot.childNodes 353 | ) 354 | } 355 | }) 356 | return node 357 | } 358 | 359 | function applyScriptTransforms({ node, scriptTransforms, tagName }) { 360 | const attrs = node?.attrs || [] 361 | if (node.childNodes.length) { 362 | const raw = node.childNodes[0].value 363 | let out = raw 364 | scriptTransforms.forEach(transform => { 365 | out = transform({ attrs, raw: out, tagName }) 366 | }) 367 | if (out.length) { 368 | node.childNodes[0].value = out 369 | } 370 | } 371 | return node 372 | } 373 | 374 | function applyStyleTransforms({ node, styleTransforms, tagName, context='' }) { 375 | const attrs = node?.attrs || [] 376 | if (node.childNodes.length) { 377 | const raw = node.childNodes[0].value 378 | let out = raw 379 | styleTransforms.forEach(transform => { 380 | out = transform({ attrs, raw: out, tagName, context }) 381 | }) 382 | if (out.length) { 383 | node.childNodes[0].value = out 384 | } 385 | } 386 | return node 387 | } 388 | 389 | function appendNodes(target, nodes) { 390 | target.childNodes.push(...nodes) 391 | } 392 | -------------------------------------------------------------------------------- /lib/is-custom-element.mjs: -------------------------------------------------------------------------------- 1 | const regex = /^[a-z](?:[\.0-9_a-z\xB7\xC0-\xD6\xD8-\xF6\xF8-\u037D\u037F-\u1FFF\u200C\u200D\u203F\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]|[\uD800-\uDB7F][\uDC00-\uDFFF])*-(?:[\x2D\.0-9_a-z\xB7\xC0-\xD6\xD8-\xF6\xF8-\u037D\u037F-\u1FFF\u200C\u200D\u203F\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]|[\uD800-\uDB7F][\uDC00-\uDFFF])*$/ 2 | const reservedTags = [ 3 | 'annotation-xml', 4 | 'color-profile', 5 | 'font-face', 6 | 'font-face-src', 7 | 'font-face-uri', 8 | 'font-face-format', 9 | 'font-face-name', 10 | 'missing-glyph' 11 | ] 12 | const notReservedTag = tagName => reservedTags.indexOf(tagName) === -1 13 | 14 | export default function isCustomElement(tagName) { 15 | return regex.test(tagName) && 16 | notReservedTag(tagName) 17 | } 18 | -------------------------------------------------------------------------------- /lib/transcode.mjs: -------------------------------------------------------------------------------- 1 | const map = {} 2 | let place = 0 3 | export function encode(value) { 4 | if (typeof value == 'string' ) { 5 | return value 6 | } 7 | else if (typeof value == 'number' ) { 8 | return value 9 | } 10 | else { 11 | const id = `__b_${place++}` 12 | map[id] = value 13 | return id 14 | } 15 | } 16 | 17 | export function decode(value) { 18 | return value.startsWith('__b_') 19 | ? map[value] 20 | : value 21 | } -------------------------------------------------------------------------------- /lib/walk.mjs: -------------------------------------------------------------------------------- 1 | const walk = (node, callback) => { 2 | if (callback(node) === false) { 3 | return false 4 | } 5 | else { 6 | let childNode 7 | let i 8 | if (node.childNodes !== undefined) { 9 | i = 0 10 | childNode = node.childNodes[i] 11 | } 12 | 13 | while (childNode !== undefined) { 14 | if (walk(childNode, callback) === false) { 15 | return false 16 | } 17 | else { 18 | childNode = node.childNodes[++i] 19 | } 20 | } 21 | } 22 | } 23 | 24 | export default walk 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@enhance/ssr", 3 | "version": "4.0.3", 4 | "description": "Server-side rendering for custom elements with template and slots support", 5 | "engines": { 6 | "node": ">=14.0.0" 7 | }, 8 | "type": "module", 9 | "main": "index.mjs", 10 | "scripts": { 11 | "test": "tape ./test/enhance.test.mjs | tap-arc" 12 | }, 13 | "keywords": [], 14 | "author": "kj ", 15 | "license": "Apache-2.0", 16 | "devDependencies": { 17 | "tap-arc": "^1.2.2", 18 | "tape": "^5.7.5" 19 | }, 20 | "dependencies": { 21 | "@begin/parse5": "^0.0.4", 22 | "nanoid": "^4.0.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/enhance.test.mjs: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import enhance from '../index.mjs' 3 | import MyButton from './fixtures/templates/my-button.mjs' 4 | import MyContent from './fixtures/templates/my-content.mjs' 5 | import MyCounter from './fixtures/templates/my-counter.mjs' 6 | import MyHeading from './fixtures/templates/my-heading.mjs' 7 | import MySuperHeading from './fixtures/templates/my-super-heading.mjs' 8 | import MyLink from './fixtures/templates/my-link.mjs' 9 | import MyListContainer from './fixtures/templates/my-list-container.mjs' 10 | import MyList from './fixtures/templates/my-list.mjs' 11 | import MyMultiples from './fixtures/templates/my-multiples.mjs' 12 | import MyOutline from './fixtures/templates/my-outline.mjs' 13 | import MyParagraph from './fixtures/templates/my-paragraph.mjs' 14 | import MyPrePage from './fixtures/templates/my-pre-page.mjs' 15 | import MyPre from './fixtures/templates/my-pre.mjs' 16 | import MyStoreData from './fixtures/templates/my-store-data.mjs' 17 | import MyTransformScript from './fixtures/templates/my-transform-script.mjs' 18 | import MyTransformStyle from './fixtures/templates/my-transform-style.mjs' 19 | import MySlotAs from './fixtures/templates/my-slot-as.mjs' 20 | import MyExternalScript from './fixtures/templates/my-external-script.mjs' 21 | import MyInstanceID from './fixtures/templates/my-instance-id.mjs' 22 | import MyContextParent from './fixtures/templates/my-context-parent.mjs' 23 | import MyContextChild from './fixtures/templates/my-context-child.mjs' 24 | import MyLinkNodeFirst from './fixtures/templates/my-link-node-first.mjs' 25 | import MyLinkNodeSecond from './fixtures/templates/my-link-node-second.mjs' 26 | import MyStyleImportFirst from './fixtures/templates/my-style-import-first.mjs' 27 | import MyStyleImportSecond from './fixtures/templates/my-style-import-second.mjs' 28 | import MyCustomHeading from './fixtures/templates/my-custom-heading.mjs' 29 | import MyCustomHeadingWithNamedSlot from './fixtures/templates/my-custom-heading-with-named-slot.mjs' 30 | import MultipleSlots from './fixtures/templates/multiple-slots.mjs' 31 | import MyEmptyStyle from './fixtures/templates/my-empty-style.mjs' 32 | 33 | function Head() { 34 | return ` 35 | 36 | 37 | ` 38 | } 39 | 40 | const strip = str => str.replace(/\r?\n|\r|\s\s+/g, '') 41 | 42 | test('Enhance should', t => { 43 | t.ok(true, 'it really should') 44 | t.end() 45 | }) 46 | 47 | test('exist', t => { 48 | t.ok(enhance, 'it lives') 49 | t.end() 50 | }) 51 | 52 | test('return an html function', t => { 53 | const html = enhance() 54 | t.ok(html, 'ah yes, this might come in handy') 55 | t.end() 56 | }) 57 | 58 | test('default content in unnamed slot', t => { 59 | const html = enhance({ 60 | bodyContent: true, 61 | elements: { 62 | 'my-button': MyButton, 63 | }, 64 | enhancedAttr: false 65 | }) 66 | const actual = html` 67 | 68 | ` 69 | const expected = ` 70 | 71 | 72 | 73 | ` 74 | t.equal( 75 | strip(actual), 76 | strip(expected), 77 | 'Will you look at that! Default content in unnamed slot works.' 78 | ) 79 | t.end() 80 | }) 81 | 82 | test('default content in unnamed slot with white space', t => { 83 | const html = enhance({ 84 | bodyContent: true, 85 | elements: { 86 | 'my-button': MyButton, 87 | }, 88 | enhancedAttr: false 89 | }) 90 | const actual = html` 91 | 92 | ` 93 | const expected = ` 94 | 95 | 96 | 97 | ` 98 | t.equal( 99 | strip(actual), 100 | strip(expected), 101 | 'Whitespace is treated as content for an unnamed slot so no default content is shown. This is how the browser works ¯\_(ツ)_/¯' 102 | ) 103 | t.end() 104 | }) 105 | 106 | test('should replace default content in unnamed slot', t => { 107 | const html = enhance({ 108 | bodyContent: true, 109 | elements: { 110 | 'my-button': MyButton, 111 | }, 112 | enhancedAttr: false 113 | }) 114 | const actual = html` 115 | Let's Go! 116 | ` 117 | const expected = ` 118 | 119 | 120 | 121 | ` 122 | t.equal( 123 | strip(actual), 124 | strip(expected), 125 | 'Yes we DO replace default content, thank you very much.' 126 | ) 127 | t.end() 128 | }) 129 | 130 | test('expand template', t => { 131 | const html = enhance({ 132 | bodyContent: true, 133 | elements: { 134 | 'my-paragraph': MyParagraph, 135 | }, 136 | enhancedAttr: false 137 | }) 138 | const actual = html` 139 | 140 | ` 141 | const expected = ` 142 | 143 |

My default text

144 |
145 | ` 146 | t.equal( 147 | strip(actual), 148 | strip(expected), 149 | 'by gum, i do believe that it does expand that template with slotted default content' 150 | ) 151 | t.end() 152 | }) 153 | 154 | test('add enhanced attribute', t => { 155 | const html = enhance({ 156 | bodyContent: true, 157 | elements: { 158 | 'my-paragraph': MyParagraph, 159 | } 160 | }) 161 | const actual = html` 162 | 163 | ` 164 | const expected = ` 165 | 166 |

My default text

167 |
168 | ` 169 | t.equal( 170 | strip(actual), 171 | strip(expected), 172 | 'by gum, i do believe that it does expand that template with slotted default content' 173 | ) 174 | t.end() 175 | }) 176 | 177 | test('Passing state through multiple levels', t => { 178 | const html = enhance({ 179 | bodyContent: true, 180 | elements: { 181 | 'my-pre-page': MyPrePage, 182 | 'my-pre': MyPre 183 | }, 184 | enhancedAttr: false 185 | }) 186 | const items = ['test'] 187 | const actual = html` 188 | 189 | ` 190 | const expected = ` 191 | 192 | 193 |
test
194 |
195 |
196 | ` 197 | 198 | t.equal( 199 | strip(actual), 200 | strip(expected), 201 | 'state makes it to the inner component render' 202 | ) 203 | t.end() 204 | }) 205 | 206 | test('should render as div tag with slot name', t => { 207 | const html = enhance({ 208 | bodyContent: true, 209 | elements: { 210 | 'my-multiples': MyMultiples 211 | }, 212 | enhancedAttr: false 213 | }) 214 | const actual = html` 215 | 216 | ` 217 | const expected = ` 218 | 219 |
220 | My default text 221 | 222 |

223 | A smaller heading 224 |

225 | 226 | 227 | Random text 228 | 229 | a code block 230 |
231 |
232 | ` 233 | 234 | t.equal( 235 | strip(actual), 236 | strip(expected), 237 | 'Whew it renders slot as div tag with the slot name added' 238 | ) 239 | t.end() 240 | }) 241 | 242 | test('should not duplicate slotted elements', t => { 243 | const html = enhance({ 244 | bodyContent: true, 245 | elements: { 246 | 'my-outline': MyOutline 247 | }, 248 | enhancedAttr: false 249 | }) 250 | 251 | const actual = html` 252 | 253 |
things
254 |
` 255 | 256 | const expected = ` 257 | 258 |
things
259 |
260 | ` 261 | 262 | t.equal( 263 | strip(actual), 264 | strip(expected), 265 | 'It better not be duplicating slotted elements' 266 | ) 267 | t.end() 268 | }) 269 | 270 | test('fill named slot', t => { 271 | const html = enhance({ 272 | bodyContent: true, 273 | elements: { 274 | 'my-paragraph': MyParagraph 275 | }, 276 | enhancedAttr: false 277 | }) 278 | const actual = html` 279 | 280 | Slotted 281 | 282 | ` 283 | const expected = ` 284 | 285 |

Slotted

286 |
287 | ` 288 | 289 | t.equal( 290 | strip(actual), 291 | strip(expected), 292 | 'fills that named slot alright' 293 | ) 294 | t.end() 295 | }) 296 | 297 | test('add authored children to unnamed slot', t => { 298 | const html = enhance({ 299 | bodyContent: true, 300 | elements: { 301 | 'my-content': MyContent 302 | }, 303 | enhancedAttr: false 304 | }) 305 | const actual = html` 306 | 307 |

Custom title

308 |
` 309 | 310 | const expected = ` 311 | 312 |

My Content

313 |

Custom title

314 |
315 | 316 | ` 317 | t.equal( 318 | strip(actual), 319 | strip(expected), 320 | 'adds unslotted children to the unnamed slot' 321 | ) 322 | t.end() 323 | }) 324 | 325 | test('pass attributes as state', t => { 326 | const html = enhance({ 327 | elements: { 328 | 'my-link': MyLink 329 | }, 330 | enhancedAttr: false 331 | }) 332 | const actual = html` 333 | ${Head()} 334 | 335 | ` 336 | const expected = ` 337 | 338 | 339 | 340 | 341 | 342 | sketchy 343 | 344 | 354 | 355 | 356 | ` 357 | 358 | t.equal( 359 | strip(actual), 360 | strip(expected), 361 | 'passes attributes as a state object when executing template functions' 362 | ) 363 | t.end() 364 | }) 365 | 366 | test('pass attribute array values correctly', t => { 367 | const html = enhance({ 368 | elements: { 369 | 'my-list': MyList 370 | }, 371 | enhancedAttr: false 372 | }) 373 | const things = [{ title: 'one' }, { title: 'two' }, { title: 'three' }] 374 | const actual = html` 375 | ${Head()} 376 | 377 | ` 378 | const expected = ` 379 | 380 | 381 | 382 | 383 | 384 |

My list

385 |
    386 |
  • one
  • 387 |
  • two
  • 388 |
  • three
  • 389 |
390 |
391 | 401 | 402 | 403 | ` 404 | 405 | t.equal( 406 | strip(actual), 407 | strip(expected), 408 | 'this means that encoding and decoding arrays and objects works, exciting' 409 | ) 410 | t.end() 411 | }) 412 | 413 | 414 | test('should update deeply nested slots', t => { 415 | const html = enhance({ 416 | bodyContent: true, 417 | elements: { 418 | 'my-content': MyContent 419 | }, 420 | enhancedAttr: false 421 | }) 422 | const actual = html` 423 | 424 | 425 |

Second

426 | 427 |

Third

428 |
429 |
430 |
` 431 | 432 | const expected = ` 433 | 434 |

My Content

435 |

436 | Title 437 |

438 | 439 |

My Content

440 |

Second

441 | 442 |

My Content

443 |

Third

444 |
445 |
446 |
447 | ` 448 | 449 | t.equal( 450 | strip(actual), 451 | strip(expected), 452 | 'updates deeply nested slots SLOTS ON SLOTS ON SLOTS' 453 | ) 454 | t.end() 455 | }) 456 | 457 | test('fill nested rendered slots', t => { 458 | const html = enhance({ 459 | elements: { 460 | 'my-list-container': MyListContainer, 461 | 'my-list': MyList 462 | }, 463 | enhancedAttr: false 464 | }) 465 | const items = [{ title: 'one' }, { title: 'two' }, { title: 'three' }] 466 | const actual = html` 467 | ${Head()} 468 | 469 | YOLO 470 | 471 | ` 472 | const expected = ` 473 | 474 | 475 | 476 | 477 | 478 |

My List Container

479 | 480 | YOLO 481 | 482 | 483 |

Content List

484 |
    485 |
  • one
  • 486 |
  • two
  • 487 |
  • three
  • 488 |
489 |
490 |
491 | 502 | 513 | 514 | 515 | ` 516 | t.equal( 517 | strip(actual), 518 | strip(expected), 519 | 'Wow it renders nested custom elements by passing that handy render function when executing template functions' 520 | ) 521 | t.end() 522 | }) 523 | 524 | test('should allow supplying custom head tag', t => { 525 | const html = enhance({ 526 | elements: { 527 | 'my-counter': MyCounter 528 | }, 529 | enhancedAttr: false 530 | }) 531 | const myHead = ` 532 | 533 | 534 | 535 | Yolo! 536 | 537 | 538 | ` 539 | const actual = html` 540 | ${myHead} 541 | 542 | ` 543 | const expected = ` 544 | 545 | 546 | 547 | 548 | Yolo! 549 | 550 | 551 | 552 |

Count: 3

553 | 554 | 555 | ` 556 | 557 | t.equal( 558 | strip(actual), 559 | strip(expected), 560 | 'Can supply custom head tag' 561 | ) 562 | t.end() 563 | }) 564 | 565 | test('should pass store to template', t => { 566 | const initialState = { 567 | apps: [ 568 | { 569 | id: 1, 570 | name: 'one', 571 | users: [ 572 | { 573 | id: 1, 574 | name: 'jim' 575 | }, 576 | { 577 | id: 2, 578 | name: 'kim' 579 | }, 580 | { 581 | id: 3, 582 | name: 'phillip' 583 | } 584 | ] 585 | } 586 | ] 587 | } 588 | const html = enhance({ 589 | elements: { 590 | 'my-store-data': MyStoreData 591 | }, 592 | initialState, 593 | enhancedAttr: false 594 | }) 595 | const actual = html` 596 | ${Head()} 597 | 598 | ` 599 | const expected = ` 600 | 601 | 602 | 603 | 604 | 605 |
606 |

kim

607 |

2

608 |
609 |
610 | 611 | 612 | ` 613 | 614 | t.equal(strip(actual), strip(expected), 'Should render store data') 615 | t.end() 616 | }) 617 | 618 | test('should run script transforms and add only one script per custom element', t => { 619 | const html = enhance({ 620 | elements: { 621 | 'my-transform-script': MyTransformScript 622 | }, 623 | scriptTransforms: [ 624 | function({ attrs, raw, tagName }) { 625 | return `${raw}\n${tagName}` 626 | } 627 | ], 628 | enhancedAttr: false 629 | }) 630 | const actual = html` 631 | ${Head()} 632 | 633 | 634 | ` 635 | const expected = ` 636 | 637 | 638 | 639 | 640 | 641 |

My Transform Script

642 |
643 | 644 |

My Transform Script

645 |
646 | 655 | 656 | 657 | ` 658 | 659 | t.equal(strip(actual), strip(expected), 'ran script transforms') 660 | t.end() 661 | }) 662 | 663 | test('should run style transforms', t => { 664 | const html = enhance({ 665 | elements: { 666 | 'my-transform-style': MyTransformStyle 667 | }, 668 | styleTransforms: [ 669 | function({ attrs, raw, tagName, context }) { 670 | if (attrs.find(i => i.name === "scope")?.value === "global" && context === "template") return '' 671 | return ` 672 | ${raw} 673 | /* 674 | ${tagName} styles 675 | context: ${context} 676 | */ 677 | ` 678 | 679 | } 680 | ], 681 | enhancedAttr: false 682 | }) 683 | const actual = html` 684 | ${Head()} 685 | 686 | ` 687 | const expected = ` 688 | 689 | 690 | 691 | 710 | 711 | 712 | 713 |

My Transform Style

714 |
715 | 723 | 724 | 725 | ` 726 | 727 | t.equal(strip(actual), strip(expected), 'ran style transform style') 728 | t.end() 729 | }) 730 | 731 | test('should not add duplicated style tags to head', t => { 732 | const html = enhance({ 733 | elements: { 734 | 'my-transform-style': MyTransformStyle, 735 | }, 736 | styleTransforms: [ 737 | function({ attrs, raw, tagName, context }) { 738 | // if tagged as global only add to the head 739 | if (attrs.find(i => i.name === "scope")?.value === "global" && context === "template") return '' 740 | 741 | return ` 742 | ${raw} 743 | /* 744 | ${tagName} styles 745 | context: ${context} 746 | */ 747 | ` 748 | 749 | } 750 | ], 751 | enhancedAttr: false 752 | }) 753 | const actual = html` 754 | ${Head()} 755 | 756 | 757 | ` 758 | const expected = ` 759 | 760 | 761 | 762 | 779 | 780 | 781 | 782 |

My Transform Style

783 |
784 | 785 |

My Transform Style

786 |
787 | 795 | 796 | 797 | ` 798 | 799 | t.equal(strip(actual), strip(expected), 'removed duplicate style sheet') 800 | t.end() 801 | }) 802 | 803 | test('should respect as attribute', t => { 804 | const html = enhance({ 805 | bodyContent: true, 806 | elements: { 807 | 'my-slot-as': MySlotAs 808 | }, 809 | enhancedAttr: false 810 | }) 811 | const actual = html` 812 | 813 | ` 814 | const expected = ` 815 | 816 |
817 | stuff 818 |
819 |
820 | ` 821 | t.equal(strip(actual), strip(expected), 'respects as attribute') 822 | t.end() 823 | }) 824 | 825 | test('should add multiple external scripts', t => { 826 | const html = enhance({ 827 | elements: { 828 | 'my-external-script': MyExternalScript 829 | }, 830 | scriptTransforms: [ 831 | function({ attrs, raw, tagName }) { 832 | return `${raw}\n${tagName}` 833 | } 834 | ], 835 | enhancedAttr: false 836 | }) 837 | const actual = html` 838 | ${Head()} 839 | 840 | 841 | ` 842 | const expected = ` 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 | 855 | 856 | 857 | 858 | ` 859 | t.equal(strip(actual), strip(expected), 'Adds multiple external scripts') 860 | t.end() 861 | }) 862 | 863 | test('should support nested custom elements with nested slots', t => { 864 | const html = enhance({ 865 | bodyContent: true, 866 | elements: { 867 | 'my-heading': MyHeading, 868 | 'my-super-heading': MySuperHeading 869 | }, 870 | enhancedAttr: false 871 | }) 872 | const actual = html` 873 | 874 | 875 | ✨ 876 | 877 | My Heading 878 | 879 | ` 880 | const expected = ` 881 | 882 | 883 | ✨ 884 | 885 | 886 |

887 | My Heading 888 |

889 |
890 |
891 | ` 892 | 893 | t.equal( 894 | strip(actual), 895 | strip(expected), 896 | 'Renders nested slots in nested custom elements' 897 | ) 898 | t.end() 899 | }) 900 | 901 | test('should not fail when passed a custom element without a template function', t => { 902 | const html = enhance() 903 | const out = html`` 904 | t.ok(out, 'Does not fail when passed a custom element that has no template function') 905 | t.end() 906 | }) 907 | 908 | test('should supply instance ID', t => { 909 | const html = enhance({ 910 | bodyContent: true, 911 | uuidFunction: function() { return 'abcd1234' }, 912 | elements: { 913 | 'my-instance-id': MyInstanceID 914 | }, 915 | enhancedAttr: false 916 | }) 917 | const actual = html` 918 | 919 | ` 920 | const expected = ` 921 | 922 |

abcd1234

923 |
924 | ` 925 | t.equal( 926 | strip(actual), 927 | strip(expected), 928 | 'Has access to instance ID' 929 | ) 930 | t.end() 931 | }) 932 | 933 | test('should supply context', t => { 934 | const html = enhance({ 935 | bodyContent: true, 936 | elements: { 937 | 'my-context-parent': MyContextParent, 938 | 'my-context-child': MyContextChild 939 | }, 940 | enhancedAttr: false 941 | }) 942 | const actual = html` 943 | 944 |
945 | 946 | 947 | 948 |
949 | 950 | 951 | 952 |
953 | ` 954 | const expected = ` 955 | 956 |
957 | 958 | 959 | hmmm 960 | 961 | 962 |
963 | 964 | 965 | sure 966 | 967 | 968 |
969 | ` 970 | t.equal( 971 | strip(actual), 972 | strip(expected), 973 | 'Passes context data to child elements' 974 | ) 975 | t.end() 976 | 977 | }) 978 | 979 | test('move link elements to head', t => { 980 | const html = enhance({ 981 | elements: { 982 | 'my-link-node-first': MyLinkNodeFirst, 983 | 'my-link-node-second': MyLinkNodeSecond 984 | }, 985 | enhancedAttr: false 986 | }) 987 | const actual = html` 988 | ${Head()} 989 | first 990 | second 991 | first again 992 | ` 993 | const expected = ` 994 | 995 | 996 | 997 | 998 | 999 | 1000 | 1001 | first 1002 | second 1003 | first again 1004 | 1005 | 1006 | ` 1007 | t.equal( 1008 | strip(actual), 1009 | strip(expected), 1010 | 'moves deduplicated link elements to the head' 1011 | ) 1012 | t.end() 1013 | }) 1014 | 1015 | test('should hoist css imports', t => { 1016 | const html = enhance({ 1017 | elements: { 1018 | 'my-style-import-first': MyStyleImportFirst, 1019 | 'my-style-import-second': MyStyleImportSecond 1020 | }, 1021 | enhancedAttr: false 1022 | }) 1023 | const actual = html` 1024 | ${Head()} 1025 | 1026 | 1027 | ` 1028 | 1029 | const expected = ` 1030 | 1031 | 1032 | 1033 | 1039 | 1040 | 1041 | 1042 | 1043 | 1044 | 1045 | ` 1046 | t.equal(strip(actual), strip(expected), 'Properly hoists CSS imports') 1047 | t.end() 1048 | }) 1049 | 1050 | test('Should render nested named slot inside unnamed slot', t => { 1051 | 1052 | const html = enhance({ 1053 | bodyContent: true, 1054 | elements: { 1055 | 'my-custom-heading': MyCustomHeading, 1056 | 'my-custom-heading-with-named-slot': MyCustomHeadingWithNamedSlot 1057 | }, 1058 | enhancedAttr: false 1059 | }) 1060 | 1061 | const actual = html` 1062 | 1063 | Here's my text 1064 | 1065 | ` 1066 | const expected = ` 1067 | 1068 | 1069 |

1070 | Here's my text 1071 |

1072 |
1073 |
1074 | ` 1075 | 1076 | t.equal( 1077 | strip(actual), 1078 | strip(expected), 1079 | 'Renders nested named slot inside unnamed slot' 1080 | ) 1081 | t.end() 1082 | }) 1083 | 1084 | test('multiple slots with unnamed slot first', t => { 1085 | const html = enhance({ 1086 | bodyContent: true, 1087 | elements: { 1088 | 'multiple-slots': MultipleSlots, 1089 | } 1090 | }) 1091 | const actual = html` 1092 | unnamed slot
slot One
1093 | ` 1094 | const expected = ` 1095 | 1096 | unnamed slot
slot One
1097 |
1098 | ` 1099 | t.equal( 1100 | strip(actual), 1101 | strip(expected), 1102 | 'Unnamed and named slots work together' 1103 | ) 1104 | t.end() 1105 | }) 1106 | 1107 | test('should render empty style tag', t => { 1108 | const html = enhance({ 1109 | bodyContent: true, 1110 | elements: { 1111 | 'empty-style': MyEmptyStyle, 1112 | } 1113 | }) 1114 | const actual = html` 1115 | 1116 | ` 1117 | const expected = ` 1118 | 1119 | ` 1120 | t.equal( 1121 | strip(actual), 1122 | strip(expected), 1123 | 'Does not throw an error when an empty style tag is rendered' 1124 | ) 1125 | t.end() 1126 | }) 1127 | -------------------------------------------------------------------------------- /test/fixtures/templates/multiple-slots.mjs: -------------------------------------------------------------------------------- 1 | export default function MultipleSlots({ html }) { 2 | return html`` 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-button.mjs: -------------------------------------------------------------------------------- 1 | export default function MyButton({ html }) { 2 | return html` 3 | 6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-content.mjs: -------------------------------------------------------------------------------- 1 | export default function MyContent({ html }) { 2 | return html` 3 |

My Content

4 | 5 |

6 | Title 7 |

8 |
9 | 10 | ` 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-context-child.mjs: -------------------------------------------------------------------------------- 1 | export default function MyContextChild({ html, state }) { 2 | const { context } = state 3 | const { message } = context 4 | return html` 5 | ${ message } 6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-context-parent.mjs: -------------------------------------------------------------------------------- 1 | export default function MyContextParent({ html, state }) { 2 | const { attrs, context } = state 3 | const { message } = attrs 4 | context.message = message 5 | 6 | return html` 7 | 8 | ` 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-counter.mjs: -------------------------------------------------------------------------------- 1 | export default function MyCounter({ state }) { 2 | const { count=0 } = state.attrs 3 | return ` 4 |

Count: ${count}

5 | ` 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-custom-heading-with-named-slot.mjs: -------------------------------------------------------------------------------- 1 | export default function MyCustomHeadingWithNamedSlot({ html }){ 2 | return html` 3 | 4 | 5 | 6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-custom-heading.mjs: -------------------------------------------------------------------------------- 1 | export default function MyCustomHeading({ html }) { 2 | return html` 3 |

4 | 5 |

6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-empty-style.mjs: -------------------------------------------------------------------------------- 1 | export default function EmptyStyle({ html }) { 2 | return html``; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-external-script.mjs: -------------------------------------------------------------------------------- 1 | export default function MyExternalScript({ html, state }) { 2 | return html` 3 | 4 | 5 | 6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-heading.mjs: -------------------------------------------------------------------------------- 1 | export default function MyHeading({ html }) { 2 | return html` 3 |

4 | 5 |

6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-id.mjs: -------------------------------------------------------------------------------- 1 | export default function MyId({ state }) { 2 | const { id } = state?.attrs 3 | return ` 4 | 5 | ` 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-instance-id.mjs: -------------------------------------------------------------------------------- 1 | export default function MyInstanceID({ html, state }) { 2 | const { instanceID='' } = state 3 | 4 | return html` 5 |

${instanceID}

6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-link-node-first.mjs: -------------------------------------------------------------------------------- 1 | export default function MyLinkNodeFirst({ html }) { 2 | return html` 3 | 4 | 5 | ` 6 | } -------------------------------------------------------------------------------- /test/fixtures/templates/my-link-node-second.mjs: -------------------------------------------------------------------------------- 1 | export default function MyLinkNodeSecond({ html }) { 2 | return html` 3 | 4 | 5 | 6 | ` 7 | } -------------------------------------------------------------------------------- /test/fixtures/templates/my-link.mjs: -------------------------------------------------------------------------------- 1 | export default function MyLink({ html, state }) { 2 | const { href='', text='' } = state?.attrs 3 | return html` 4 | ${text} 5 | 16 | ` 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-list-container.mjs: -------------------------------------------------------------------------------- 1 | export default function MyListContainer({ html, state }) { 2 | const { items } = state?.attrs 3 | return html` 4 |

My List Container

5 | 6 |

7 | Title 8 |

9 |
10 | 11 |

Content List

12 |
13 | 24 | ` 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-list.mjs: -------------------------------------------------------------------------------- 1 | export default function MyList({ html, state }) { 2 | const items = state?.attrs?.items || [] 3 | const listItems = items && 4 | items.map && 5 | items.map(li => `
  • ${li.title}
  • `) 6 | .join('') 7 | return html` 8 | 9 |

    My list

    10 |
    11 | 14 | 25 | ` 26 | } 27 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-multiples.mjs: -------------------------------------------------------------------------------- 1 | export default function MyMultiples({ html }) { 2 | return html` 3 | 4 | My default text 5 |

    A smaller heading

    6 | Random text 7 | a code block 8 |
    9 | ` 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-outline.mjs: -------------------------------------------------------------------------------- 1 | export default function myOutline({ html }) { 2 | return html` 3 | 4 | ` 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-page.mjs: -------------------------------------------------------------------------------- 1 | export default function MyPage({ html, state }) { 2 | const { items=[] } = state?.attrs 3 | return html` 4 |

    My Page

    5 | 6 |

    YOLO

    7 |
    8 | ` 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-paragraph.mjs: -------------------------------------------------------------------------------- 1 | const MyParagraph = { 2 | init() { 3 | this.addEventListener('click', this.click) 4 | }, 5 | click(e) { 6 | console.log('E: ', e) 7 | }, 8 | render({ html }) { 9 | return html` 10 |

    11 | 12 | My default text 13 | 14 |

    15 | ` 16 | } 17 | } 18 | 19 | export default MyParagraph 20 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-pre-page.mjs: -------------------------------------------------------------------------------- 1 | export default function MyPrePage({ html, state }) { 2 | const { items=[] } = state?.attrs 3 | return html` 4 | ` 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-pre.mjs: -------------------------------------------------------------------------------- 1 | export default function MyMoreContent({ html, state }) { 2 | const { items=[] } = state?.attrs 3 | return html`
    ${items[0]}
    ` 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-slot-as.mjs: -------------------------------------------------------------------------------- 1 | export default function MySlotAs({ html }) { 2 | return html` 3 | 4 | stuff 5 | 6 | ` 7 | } -------------------------------------------------------------------------------- /test/fixtures/templates/my-store-data.mjs: -------------------------------------------------------------------------------- 1 | export default function MyStoreData({ html, state }) { 2 | const { attrs, store } = state 3 | const appIndex = attrs['app-index'] 4 | const userIndex = attrs['user-index'] 5 | const { id, name='' } = store?.apps?.[appIndex]?.users?.[userIndex] || {} 6 | return html` 7 |
    8 |

    ${name}

    9 |

    ${id}

    10 |
    11 | ` 12 | } -------------------------------------------------------------------------------- /test/fixtures/templates/my-style-import-first.mjs: -------------------------------------------------------------------------------- 1 | export default function MyStyleImportFirst({ html }) { 2 | return html` 3 | 4 | 5 | ` 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-style-import-second.mjs: -------------------------------------------------------------------------------- 1 | export default function MyStyleImportSecond({ html }) { 2 | return html` 3 | 4 | 5 | ` 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-super-heading.mjs: -------------------------------------------------------------------------------- 1 | export default function MySuperHeading({ html }) { 2 | return html` 3 | 4 | 5 | 6 | 7 | ` 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/templates/my-title.mjs: -------------------------------------------------------------------------------- 1 | export default function MyTitle({ html }) { 2 | return html` 3 | 4 | Default title 5 | 6 | 13 | ` 14 | } -------------------------------------------------------------------------------- /test/fixtures/templates/my-transform-script.mjs: -------------------------------------------------------------------------------- 1 | export default function MyTransformScript({ html }) { 2 | return html` 3 |

    My Transform Script

    4 | 12 | ` 13 | } -------------------------------------------------------------------------------- /test/fixtures/templates/my-transform-style.mjs: -------------------------------------------------------------------------------- 1 | export default function MyTransformStyle({ html }) { 2 | return html` 3 | 8 | 9 | 14 | 15 |

    My Transform Style

    16 | 24 | ` 25 | } -------------------------------------------------------------------------------- /test/fixtures/templates/my-wrapped-heading.mjs: -------------------------------------------------------------------------------- 1 | export default function MyCoolHeading({ html }) { 2 | return html` 3 | 4 | 5 | 6 | 7 | ` 8 | } 9 | -------------------------------------------------------------------------------- /test/is-custom-element.test.mjs: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import isCustomElement from '../lib/is-custom-element.mjs' 3 | 4 | test('isCustomElement', t=> { 5 | t.ok(isCustomElement, 'exists') 6 | t.end() 7 | }) 8 | 9 | test('should identify valid custom element tag names', t=> { 10 | t.ok(isCustomElement('tag-name')) 11 | t.ok(isCustomElement('tag-😬')) 12 | t.end() 13 | }) 14 | 15 | test('should identify invalid custom element tag names', t=> { 16 | t.ok(!isCustomElement('Tag-Name'), 'catches uppercase') 17 | t.ok(!isCustomElement('-tag-name'), 'catches starting dash') 18 | t.ok(!isCustomElement('1tag-name'), 'catches starting digit') 19 | t.ok(!isCustomElement('font-face'), 'catches reserved tag') 20 | t.end() 21 | }) 22 | --------------------------------------------------------------------------------