Skip to content

Commit

Permalink
Change: use html parser for preprocess
Browse files Browse the repository at this point in the history
  • Loading branch information
eight04 committed Mar 4, 2022
1 parent 8524dd4 commit 23ddb74
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 47 deletions.
120 changes: 101 additions & 19 deletions src/partialHydration/__tests__/partialHydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`"{#if ({}).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({})}><DatePicker /></div>{/if}"`,
);
});

Expand All @@ -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 /></div>{/if}"`,
);
});

Expand All @@ -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 /></div>{/if}"`,
);
});

Expand All @@ -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 /></div>{/if}"`,
);
});
it('eager, root margin, threshold', async () => {
Expand All @@ -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 /></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();
});
it('text within component', async () => {
await expect(async () => {
Expand Down Expand Up @@ -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={JSON.stringify({})} 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(
`"{#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.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(
`"{#if ({}).loading === 'none'}<DatePicker {...({})} {foo}/>{#else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({})}><DatePicker {foo}/></div>{/if}"`,
);
});

it('ssr props spread', async () => {
expect(
(
await partialHydration.markup({
content: '<DatePicker hydrate-client {...foo} />',
})
).code,
).toMatchInlineSnapshot(
`"{#if ({}).loading === 'none'}<DatePicker {...({})} {...foo}/>{#else}<div class=\\"ejs-component\\" data-ejs-component=\\"DatePicker\\" data-ejs-props={JSON.stringify({})} data-ejs-options={JSON.stringify({})}><DatePicker {...foo}/></div>{/if}"`,
);
});

it('style props', async () => {
expect(
(
await partialHydration.markup({
content: '<DatePicker hydrate-client --foo="bar" />',
})
).code,
).toMatchInlineSnapshot(
`"{#if ({}).loading === 'none'}<DatePicker {...({})} style:--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('style props with expression', async () => {
expect(
(
await partialHydration.markup({
content: '<DatePicker hydrate-client --foo={bar} />',
})
).code,
).toMatchInlineSnapshot(
`"{#if ({}).loading === 'none'}<DatePicker {...({})} style:--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}"`,
);
});
});
53 changes: 40 additions & 13 deletions src/partialHydration/partialHydration.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import MagicString from 'magic-string';
import { parseTag } from '../utils/htmlParser';
import { inlinePreprocessedSvelteComponent } from './inlineSvelteComponent';

const extractHydrateOptions = (htmlString) => {
Expand All @@ -10,36 +12,61 @@ const extractHydrateOptions = (htmlString) => {
return '';
};

const createReplacementString = ({ input, name, props }) => {
const options = extractHydrateOptions(input);
return inlinePreprocessedSvelteComponent({ name, props, options });
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 `{#if (${options}).loading === 'none'}<${tag.name} {...(${clientProps})} ${styleProps} ${serverProps}/>` +
`{#else}<div class="ejs-component" data-ejs-component="${tag.name}" ` +
`data-ejs-props={JSON.stringify(${clientProps})} data-ejs-options={JSON.stringify(${options})}>` +
`<${tag.name} ${stylePropsRaw} ${serverProps}/></div>{/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);
let dirty = false;
const hydrateableComponentPattern = /<([a-zA-Z]+)[^>]+hydrate-client/gim;
const s = new MagicString(content);
for (const match of content.matchAll(hydrateableComponentPattern)) {
const tag = parseTag(content, match.index);
if (!tag.selfClosed) {
throw new Error("Hydratable component must be a self-closing tag");
}
s.overwrite(tag.start, tag.end, createReplacementString(content, tag));
dirty = true;
}

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)];
const wrappedComponents = [...content.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.`,
);
}
return output;
return dirty ? s.toString() : content;
};

const partialHydration = {
Expand Down
1 change: 1 addition & 0 deletions src/utils/__tests__/__snapshots__/htmlParser.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Object {
"start": 41,
"type": "Identifier",
},
"spread": false,
"start": 40,
},
},
Expand Down
49 changes: 34 additions & 15 deletions src/utils/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type Node = {

type TagNode = Node & {
name: string;
attrs: Array<AttrNode>;
attrs: Array<AttrNode|ExpressionNode>;
selfClosed: boolean;
start: number;
end: number;
Expand All @@ -20,10 +20,14 @@ type AttrNode = Node & {

type AttrValueNode = Node & {
value?: string;
exp?: any;
raw?: string;
}

type ExpressionNode = Node & {
exp: any;
spread: boolean;
}

export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
Expand All @@ -43,7 +47,23 @@ export const unescapeHtml = (str) =>
.replace(/\\"/gim, '"')
.replace(/&amp;/gim, '&');

export function parseAttrValue(content: string, index: number): AttrValueNode {
export function parseExpression(content: string, index: number): ExpressionNode {
const rx = /{\s*(\.\.\.)?/y;
rx.lastIndex = index;
const [prefix,, hasSpread] = rx.exec(content);
const exp = parseExpressionAt(content, index + prefix.length);
const rxEnd = /\s*}/y;
rxEnd.lastIndex = exp.end;
rxEnd.exec(content);
return {
start: index,
end: rxEnd.lastIndex,
exp,
spread: Boolean(hasSpread)
};
}

export function parseAttrValue(content: string, index: number): AttrValueNode | ExpressionNode {
let raw;
let rx;
if (content[index] === "'") {
Expand All @@ -55,15 +75,7 @@ export function parseAttrValue(content: string, index: number): AttrValueNode {
rx.lastIndex = index;
raw = content.match(rx)[1];
} else if (content[index] === "{") {
const node = parseExpressionAt(content, index + 1);
const rxEnd = /\s*}/y;
rxEnd.lastIndex = node.end;
content.match(rxEnd);
return {
start: index,
end: rxEnd.lastIndex,
exp: node
};
return parseExpression(content, index);
} else {
rx = /[^\s"'=<>\/\x7f-\x9f]+/y;
rx.lastIndex = index;
Expand Down Expand Up @@ -98,13 +110,20 @@ export function parseTag(content: string, index: number): TagNode {
};
}

export function parseAttrs(content: string, index: number): Array<AttrNode> {
const rx = /\s+([\w-:]+)(\s*=\s*)?/y;
export function parseAttrs(content: string, index: number): Array<AttrNode|ExpressionNode> {
const rx = /(\s+)(?:([\w-:]+)(\s*=\s*)?|{)/y;
rx.lastIndex = index;
const result = [];
let match;
while ((match = rx.exec(content))) {
const [, name, hasValue] = match;
const [, prefix, name, hasValue] = match;
if (!name) {
// expression
const node = parseExpression(content, match.index + prefix.length);
result.push(node);
rx.lastIndex = node.end;
continue;
}
const value = hasValue ? parseAttrValue(content, rx.lastIndex) : null;
result.push({
start: match.index,
Expand Down

0 comments on commit 23ddb74

Please sign in to comment.