-
Notifications
You must be signed in to change notification settings - Fork 53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Enhance: use html parser in preprocessor #240
Changes from 11 commits
8524dd4
23ddb74
bc34819
765af2e
afb01e4
e9e1fe5
70c9b40
9b00c02
4c025b7
20761ab
e5cbe3f
7a1cee4
f1f1834
bd64afb
4012327
f128807
7276e16
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,8 +8,8 @@ describe('#partialHydration', () => { | |
content: '<DatePicker hydrate-client={{ a: "b" }} />', | ||
}) | ||
).code, | ||
).toEqual( | ||
`<div class="ejs-component" data-ejs-component="DatePicker" data-ejs-props={JSON.stringify({ a: "b" })} data-ejs-options={JSON.stringify({"loading":"lazy","element":"div"})} />`, | ||
).toMatchInlineSnapshot( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Switch to inline snapshot so it is easier to update. |
||
`"<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({ a: \\"b\\" })} data-ejs-options=\\"\\"><DatePicker {...({ a: \\"b\\" })}/></div>"`, | ||
); | ||
}); | ||
|
||
|
@@ -20,8 +20,8 @@ describe('#partialHydration', () => { | |
content: '<DatePicker hydrate-client={{ a: "c" }} hydrate-options={{ "loading": "lazy" }}/>', | ||
}) | ||
).code, | ||
).toEqual( | ||
`<div class="ejs-component" data-ejs-component="DatePicker" data-ejs-props={JSON.stringify({ a: "c" })} data-ejs-options={JSON.stringify({"loading":"lazy","element":"div"})} />`, | ||
).toMatchInlineSnapshot( | ||
`"{#if ({ \\"loading\\": \\"lazy\\" }).loading === 'none'}<DatePicker {...({ a: \\"c\\" })}/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({ a: \\"c\\" })} data-ejs-options={JSON.stringify({ \\"loading\\": \\"lazy\\" })}><DatePicker {...({ a: \\"c\\" })}/></div>{/if}"`, | ||
); | ||
}); | ||
|
||
|
@@ -32,8 +32,8 @@ describe('#partialHydration', () => { | |
content: '<DatePicker hydrate-client={{ a: "c" }} hydrate-options={{ "timeout": 2000 }}/>', | ||
}) | ||
).code, | ||
).toEqual( | ||
`<div class="ejs-component" data-ejs-component="DatePicker" data-ejs-props={JSON.stringify({ a: "c" })} data-ejs-options={JSON.stringify({"loading":"lazy","element":"div","timeout":2000})} />`, | ||
).toMatchInlineSnapshot( | ||
`"{#if ({ \\"timeout\\": 2000 }).loading === 'none'}<DatePicker {...({ a: \\"c\\" })}/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({ a: \\"c\\" })} data-ejs-options={JSON.stringify({ \\"timeout\\": 2000 })}><DatePicker {...({ a: \\"c\\" })}/></div>{/if}"`, | ||
); | ||
}); | ||
|
||
|
@@ -44,8 +44,8 @@ describe('#partialHydration', () => { | |
content: '<DatePicker hydrate-client={{ a: "b" }} hydrate-options={{ "loading": "eager" }} />', | ||
}) | ||
).code, | ||
).toEqual( | ||
`<div class="ejs-component" data-ejs-component="DatePicker" data-ejs-props={JSON.stringify({ a: "b" })} data-ejs-options={JSON.stringify({"loading":"eager","element":"div"})} />`, | ||
).toMatchInlineSnapshot( | ||
`"{#if ({ \\"loading\\": \\"eager\\" }).loading === 'none'}<DatePicker {...({ a: \\"b\\" })}/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({ a: \\"b\\" })} data-ejs-options={JSON.stringify({ \\"loading\\": \\"eager\\" })}><DatePicker {...({ a: \\"b\\" })}/></div>{/if}"`, | ||
); | ||
}); | ||
it('eager, root margin, threshold', async () => { | ||
|
@@ -56,18 +56,16 @@ describe('#partialHydration', () => { | |
'<DatePicker hydrate-client={{ a: "b" }} hydrate-options={{ "loading": "eager", "rootMargin": "500px", "threshold": 0 }} />', | ||
}) | ||
).code, | ||
).toEqual( | ||
`<div class="ejs-component" data-ejs-component="DatePicker" data-ejs-props={JSON.stringify({ a: "b" })} data-ejs-options={JSON.stringify({"loading":"eager","element":"div","rootMargin":"500px","threshold":0})} />`, | ||
).toMatchInlineSnapshot( | ||
`"{#if ({ \\"loading\\": \\"eager\\", \\"rootMargin\\": \\"500px\\", \\"threshold\\": 0 }).loading === 'none'}<DatePicker {...({ a: \\"b\\" })}/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({ a: \\"b\\" })} data-ejs-options={JSON.stringify({ \\"loading\\": \\"eager\\", \\"rootMargin\\": \\"500px\\", \\"threshold\\": 0 })}><DatePicker {...({ a: \\"b\\" })}/></div>{/if}"`, | ||
); | ||
}); | ||
it('open string', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client="string />', | ||
}) | ||
).code, | ||
).toEqual(`<DatePicker hydrate-client="string />`); | ||
await expect(async () => { | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client="string />', | ||
}); | ||
}).rejects.toThrow(); | ||
Comment on lines
+64
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not valid svelte syntax so I just let preprocessor throw. |
||
}); | ||
it('text within component', async () => { | ||
await expect(async () => { | ||
|
@@ -105,8 +103,92 @@ describe('#partialHydration', () => { | |
content: `<Clock hydrate-client={{}} hydrate-options={{ "loading": "eager", "preload": true }} /><Block hydrate-client={{}} hydrate-options={{ "loading": "lazy" }} /><Alock hydrate-client={{}} hydrate-options={{ "loading": "lazy" }} />`, | ||
}) | ||
).code, | ||
).toEqual( | ||
`<div class="ejs-component" data-ejs-component="Clock" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({"loading":"eager","element":"div","preload":true})} /><div class="ejs-component" data-ejs-component="Block" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({"loading":"lazy","element":"div"})} /><div class="ejs-component" data-ejs-component="Alock" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({"loading":"lazy","element":"div"})} />`, | ||
).toMatchInlineSnapshot( | ||
`"{#if ({ \\"loading\\": \\"eager\\", \\"preload\\": true }).loading === 'none'}<Clock {...({})}/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"Clock\\" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({ \\"loading\\": \\"eager\\", \\"preload\\": true })}><Clock {...({})}/></div>{/if}{#if ({ \\"loading\\": \\"lazy\\" }).loading === 'none'}<Block {...({})}/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"Block\\" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({ \\"loading\\": \\"lazy\\" })}><Block {...({})}/></div>{/if}{#if ({ \\"loading\\": \\"lazy\\" }).loading === 'none'}<Alock {...({})}/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"Alock\\" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({ \\"loading\\": \\"lazy\\" })}><Alock {...({})}/></div>{/if}"`, | ||
); | ||
}); | ||
|
||
it('options as identifier', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client hydrate-options={foo} />', | ||
}) | ||
).code, | ||
).toMatchInlineSnapshot( | ||
`"{#if (foo).loading === 'none'}<DatePicker/>{:else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props=\\"\\" data-ejs-options={JSON.stringify(foo)}><DatePicker/></div>{/if}"`, | ||
); | ||
}); | ||
|
||
it('ssr props', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client foo={bar} />', | ||
}) | ||
).code, | ||
).toMatchInlineSnapshot( | ||
`"<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props=\\"\\" data-ejs-options=\\"\\"><DatePicker foo={bar}/></div>"`, | ||
); | ||
}); | ||
|
||
it.skip('ssr props expression in string', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client foo="123/{"bar"}/456" />', | ||
}) | ||
).code, | ||
).toMatchInlineSnapshot( | ||
`"{#if ({}).loading === 'none'}<DatePicker {...({})} foo={bar}/>{#else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({})}><DatePicker foo={bar}/></div>{/if}"`, | ||
); | ||
}); | ||
|
||
it('ssr props no name', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client {foo} />', | ||
}) | ||
).code, | ||
).toMatchInlineSnapshot( | ||
`"<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props=\\"\\" data-ejs-options=\\"\\"><DatePicker {foo}/></div>"`, | ||
); | ||
}); | ||
|
||
it('ssr props spread', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client {...foo} />', | ||
}) | ||
).code, | ||
).toMatchInlineSnapshot( | ||
`"<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props=\\"\\" data-ejs-options=\\"\\"><DatePicker {...foo}/></div>"`, | ||
); | ||
}); | ||
|
||
it('style props', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client --foo="bar" />', | ||
}) | ||
).code, | ||
).toMatchInlineSnapshot( | ||
`"<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props=\\"\\" data-ejs-options=\\"\\" style:--foo=\\"bar\\"><DatePicker/></div>"`, | ||
); | ||
}); | ||
|
||
it('style props with expression', async () => { | ||
expect( | ||
( | ||
await partialHydration.markup({ | ||
content: '<DatePicker hydrate-client --foo={bar} />', | ||
}) | ||
).code, | ||
).toMatchInlineSnapshot( | ||
`"<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props=\\"\\" data-ejs-options=\\"\\" style:--foo={bar}><DatePicker/></div>"`, | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,45 +1,67 @@ | ||
import { inlinePreprocessedSvelteComponent } from './inlineSvelteComponent'; | ||
import MagicString from 'magic-string'; | ||
import { parseTag } from '../utils/htmlParser'; | ||
|
||
const extractHydrateOptions = (htmlString) => { | ||
const hydrateOptionsPattern = /hydrate-options={([^]*?})}/gim; | ||
const stringifyExpression = (s) => (s ? `{JSON.stringify(${s})}` : '""'); | ||
|
||
const optionsMatch = hydrateOptionsPattern.exec(htmlString); | ||
if (optionsMatch) { | ||
return optionsMatch[1]; | ||
const createReplacementString = (content, tag) => { | ||
let options = ''; | ||
let clientProps = ''; | ||
let styleProps = ''; | ||
let stylePropsRaw = ''; | ||
let serverProps = ''; | ||
for (const attr of tag.attrs) { | ||
if (/^hydrate-client$/i.test(attr.name)) { | ||
if (attr.value) { | ||
clientProps = content.slice(attr.value.exp.start, attr.value.exp.end); | ||
} | ||
} else if (/^hydrate-options$/i.test(attr.name)) { | ||
options = content.slice(attr.value.exp.start, attr.value.exp.end); | ||
} else if (/^--/i.test(attr.name)) { | ||
stylePropsRaw += ` ${content.slice(attr.start, attr.end)}`; | ||
styleProps += ` style:${attr.name}=${content.slice(attr.value.start, attr.value.end)}`; | ||
} else { | ||
serverProps += ` ${content.slice(attr.start, attr.end)}`; | ||
} | ||
} | ||
return ''; | ||
}; | ||
|
||
const createReplacementString = ({ input, name, props }) => { | ||
const options = extractHydrateOptions(input); | ||
return inlinePreprocessedSvelteComponent({ name, props, options }); | ||
const spreadClientProps = clientProps ? ` {...(${clientProps})}` : ''; | ||
// FIXME: it should be possible to merge three attributes into one | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally, we should be able to generate simpler code in the preprocessor e.g. `<div ejs-component=${stringifyExpression(`["${tag.name}",${clientProps},${options}]`)}>` Then we just have to search for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Definitely. This would be a huge simplification. |
||
// FIXME: use hydrateOptions.element instead of 'div' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If this is needed, we can try to find the element via regex: const rx = /\belement['"]?\s*:\s*['"]([^'"]+)/i;
const element = options.match(rx)?.[1]; This will work if the options is written in the source:
But won't work in other cases: <script>
const o = {element: 'span'};
</script>
<Component hydrate-client hydrate-options={o} /> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This was the implementation. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another solution is to use a special tag e.g. <ejswrapper ... ></ejswrapper> And replace it with the real tag name in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @eight04 Not strongly opinionated either way. Whatever you think is best. |
||
const wrapper = | ||
`<div class="ejs-component" data-ejs-component="${tag.name}"` + | ||
` data-ejs-props=${stringifyExpression(clientProps)}` + | ||
` data-ejs-options=${stringifyExpression(options)}` + | ||
`${styleProps}>` + | ||
`<${tag.name}${spreadClientProps}${serverProps}/></div>`; | ||
if (!options) { | ||
return wrapper; | ||
} | ||
return ( | ||
`{#if (${options}).loading === 'none'}<${tag.name}${spreadClientProps}${stylePropsRaw}${serverProps}/>` + | ||
`{:else}${wrapper}{/if}` | ||
); | ||
}; | ||
|
||
export const preprocessSvelteContent = (content) => { | ||
// Note: this regex only supports self closing components. | ||
// Slots aren't supported for client hydration either. | ||
const hydrateableComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client={([^]*?})}[^/>]*\/>/gim; | ||
const matches = [...content.matchAll(hydrateableComponentPattern)]; | ||
|
||
const output = matches.reduce((out, match) => { | ||
const [wholeMatch, name, props] = match; | ||
const replacement = createReplacementString({ input: wholeMatch, name, props }); | ||
return out.replace(wholeMatch, replacement); | ||
}, content); | ||
|
||
const wrappingComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client={([^]*?})}[^/>]*>[^>]*<\/([a-zA-Z]+)>/gim; | ||
// <Map hydrate-client={{}} ></Map> | ||
// <Map hydrate-client={{}}></Map> | ||
// <Map hydrate-client={{}}>Foo</Map> | ||
|
||
const wrappedComponents = [...output.matchAll(wrappingComponentPattern)]; | ||
|
||
if (wrappedComponents && wrappedComponents.length > 0) { | ||
throw new Error( | ||
`Elder.js only supports self-closing syntax on hydrated components. This means <Foo /> not <Foo></Foo> or <Foo>Something</Foo>. Offending component: ${wrappedComponents[0][0]}. Slots and child components aren't supported during hydration as it would result in huge HTML payloads. If you need this functionality try wrapping the offending component in a parent component without slots or child components and hydrate the parent component.`, | ||
); | ||
let dirty = false; | ||
const hydrateableComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client/gim; | ||
const s = new MagicString(content); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great work here. Huge simplification. |
||
for (const match of content.matchAll(hydrateableComponentPattern)) { | ||
const tag = parseTag(content, match.index); | ||
if (!tag.selfClosed) { | ||
throw new Error( | ||
`Elder.js only supports self-closing syntax on hydrated components. This means <Foo /> not <Foo></Foo> or <Foo>Something</Foo>. Offending component: ${content.slice( | ||
tag.start, | ||
tag.end, | ||
)}. Slots and child components aren't supported during hydration as it would result in huge HTML payloads. If you need this functionality try wrapping the offending component in a parent component without slots or child components and hydrate the parent component.`, | ||
); | ||
} | ||
const repl = createReplacementString(content, tag); | ||
s.overwrite(tag.start, tag.end, repl); | ||
dirty = true; | ||
} | ||
return output; | ||
return dirty ? s.toString() : content; | ||
}; | ||
|
||
const partialHydration = { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
escapeHtml
andreplaceSpecialCharacters
are moved tohtmlParser.ts
.