├── .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 | ${ label }
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 |
151 | Submit
152 |
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 | Submit
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 | Let's Go!
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 slotslot One
1093 | `
1094 | const expected = `
1095 |
1096 | unnamed slotslot 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 |
4 | Submit
5 |
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 |
--------------------------------------------------------------------------------