zero-query 1.0.1 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -9
- package/cli/help.js +2 -1
- package/cli/scaffold/default/app/app.js +3 -6
- package/cli/scaffold/default/global.css +12 -3
- package/cli/scaffold/default/index.html +8 -8
- package/cli/scaffold/minimal/app/app.js +3 -9
- package/cli/scaffold/minimal/global.css +12 -3
- package/cli/scaffold/minimal/index.html +3 -3
- package/cli/scaffold/ssr/app/app.js +18 -6
- package/cli/scaffold/ssr/app/components/about.js +42 -15
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -0
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -0
- package/cli/scaffold/ssr/app/routes.js +4 -2
- package/cli/scaffold/ssr/global.css +117 -1
- package/cli/scaffold/ssr/index.html +8 -2
- package/cli/scaffold/ssr/server/data/posts.js +144 -0
- package/cli/scaffold/ssr/server/index.js +117 -23
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +85 -20
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +5 -2
- package/index.js +5 -4
- package/package.json +1 -1
- package/src/component.js +17 -2
- package/src/router.js +61 -12
- package/src/ssr.js +100 -0
- package/tests/component.test.js +480 -0
- package/tests/router.test.js +65 -1
- package/tests/ssr.test.js +175 -0
- package/tests/test-minifier.js +4 -4
- package/types/misc.d.ts +23 -1
- package/types/router.d.ts +25 -0
- package/types/ssr.d.ts +33 -0
package/tests/ssr.test.js
CHANGED
|
@@ -693,3 +693,178 @@ describe('escapeHtml (exported)', () => {
|
|
|
693
693
|
expect(escapeHtml(42)).toBe('42');
|
|
694
694
|
});
|
|
695
695
|
});
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// SSRApp - renderShell
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
describe('SSRApp - renderShell', () => {
|
|
703
|
+
const shell = `<!DOCTYPE html>
|
|
704
|
+
<html lang="en">
|
|
705
|
+
<head>
|
|
706
|
+
<title>Default Title</title>
|
|
707
|
+
<meta name="description" content="default desc">
|
|
708
|
+
<meta property="og:title" content="Default OG">
|
|
709
|
+
<meta property="og:description" content="">
|
|
710
|
+
<meta property="og:type" content="website">
|
|
711
|
+
</head>
|
|
712
|
+
<body>
|
|
713
|
+
<z-outlet></z-outlet>
|
|
714
|
+
</body>
|
|
715
|
+
</html>`;
|
|
716
|
+
|
|
717
|
+
let app;
|
|
718
|
+
beforeEach(() => {
|
|
719
|
+
app = createSSRApp();
|
|
720
|
+
app.component('test-page', {
|
|
721
|
+
state: () => ({ greeting: 'Hello' }),
|
|
722
|
+
render() { return `<h1>${this.state.greeting}</h1>`; }
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('renders a component into <z-outlet>', async () => {
|
|
727
|
+
const html = await app.renderShell(shell, { component: 'test-page' });
|
|
728
|
+
expect(html).toContain('<z-outlet><test-page data-zq-ssr><h1>Hello</h1></test-page></z-outlet>');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('replaces the <title> tag', async () => {
|
|
732
|
+
const html = await app.renderShell(shell, { component: 'test-page', title: 'My App — Home' });
|
|
733
|
+
expect(html).toContain('<title>My App — Home</title>');
|
|
734
|
+
expect(html).not.toContain('Default Title');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('replaces the meta description', async () => {
|
|
738
|
+
const html = await app.renderShell(shell, { component: 'test-page', description: 'A great page' });
|
|
739
|
+
expect(html).toContain('<meta name="description" content="A great page">');
|
|
740
|
+
expect(html).not.toContain('default desc');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('replaces existing Open Graph tags', async () => {
|
|
744
|
+
const html = await app.renderShell(shell, {
|
|
745
|
+
component: 'test-page',
|
|
746
|
+
og: { title: 'OG Title', description: 'OG Desc', type: 'article' },
|
|
747
|
+
});
|
|
748
|
+
expect(html).toContain('<meta property="og:title" content="OG Title">');
|
|
749
|
+
expect(html).toContain('<meta property="og:description" content="OG Desc">');
|
|
750
|
+
expect(html).toContain('<meta property="og:type" content="article">');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('injects new OG tags when they do not exist in the shell', async () => {
|
|
754
|
+
const html = await app.renderShell(shell, {
|
|
755
|
+
component: 'test-page',
|
|
756
|
+
og: { image: 'https://example.com/img.png' },
|
|
757
|
+
});
|
|
758
|
+
expect(html).toContain('<meta property="og:image" content="https://example.com/img.png">');
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('injects window.__SSR_DATA__ when ssrData is provided', async () => {
|
|
762
|
+
const data = { component: 'test-page', params: {}, props: {} };
|
|
763
|
+
const html = await app.renderShell(shell, { component: 'test-page', ssrData: data });
|
|
764
|
+
expect(html).toContain('window.__SSR_DATA__=');
|
|
765
|
+
expect(html).toContain('"component":"test-page"');
|
|
766
|
+
// Script is injected before </head>
|
|
767
|
+
expect(html.indexOf('__SSR_DATA__')).toBeLessThan(html.indexOf('</head>'));
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('does not inject ssrData when not provided', async () => {
|
|
771
|
+
const html = await app.renderShell(shell, { component: 'test-page' });
|
|
772
|
+
expect(html).not.toContain('__SSR_DATA__');
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('escapes title and description for XSS safety', async () => {
|
|
776
|
+
const html = await app.renderShell(shell, {
|
|
777
|
+
component: 'test-page',
|
|
778
|
+
title: '<script>alert("xss")</script>',
|
|
779
|
+
description: '"injected" & <dangerous>',
|
|
780
|
+
});
|
|
781
|
+
expect(html).toContain('<script>');
|
|
782
|
+
expect(html).toContain('"injected" & <dangerous>');
|
|
783
|
+
expect(html).not.toContain('<script>alert');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('leaves title and description alone when not provided', async () => {
|
|
787
|
+
const html = await app.renderShell(shell, { component: 'test-page' });
|
|
788
|
+
expect(html).toContain('<title>Default Title</title>');
|
|
789
|
+
expect(html).toContain('content="default desc"');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('passes props to the rendered component', async () => {
|
|
793
|
+
app.component('greeting-page', {
|
|
794
|
+
render() { return `<p>Hi ${this.props.name}</p>`; }
|
|
795
|
+
});
|
|
796
|
+
const html = await app.renderShell(shell, { component: 'greeting-page', props: { name: 'Tony' } });
|
|
797
|
+
expect(html).toContain('<p>Hi Tony</p>');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('passes renderOptions through to renderToString', async () => {
|
|
801
|
+
const html = await app.renderShell(shell, {
|
|
802
|
+
component: 'test-page',
|
|
803
|
+
renderOptions: { hydrate: false },
|
|
804
|
+
});
|
|
805
|
+
expect(html).toContain('<test-page><h1>Hello</h1></test-page>');
|
|
806
|
+
expect(html).not.toContain('data-zq-ssr');
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('handles a missing component gracefully', async () => {
|
|
810
|
+
const html = await app.renderShell(shell, { component: 'nonexistent' });
|
|
811
|
+
expect(html).toContain('<!-- SSR error:');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('returns the shell untouched when no options are provided', async () => {
|
|
815
|
+
const html = await app.renderShell(shell);
|
|
816
|
+
expect(html).toContain('<title>Default Title</title>');
|
|
817
|
+
expect(html).toContain('<z-outlet></z-outlet>');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('escapes </script> in ssrData to prevent script injection', async () => {
|
|
821
|
+
const html = await app.renderShell(shell, {
|
|
822
|
+
component: 'test-page',
|
|
823
|
+
ssrData: { payload: '</script><script>alert(1)</script>' },
|
|
824
|
+
});
|
|
825
|
+
expect(html).not.toContain('</script><script>alert(1)');
|
|
826
|
+
expect(html).toContain('<\\/script>');
|
|
827
|
+
// The JSON should still be parseable when unescaped
|
|
828
|
+
const match = html.match(/window\.__SSR_DATA__=(.+?);/);
|
|
829
|
+
expect(match).toBeTruthy();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('escapes <!-- in ssrData to prevent HTML comment injection', async () => {
|
|
833
|
+
const html = await app.renderShell(shell, {
|
|
834
|
+
component: 'test-page',
|
|
835
|
+
ssrData: { payload: '<!-- injected -->' },
|
|
836
|
+
});
|
|
837
|
+
expect(html).not.toContain('<!-- injected');
|
|
838
|
+
expect(html).toContain('<\\!--');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('sanitizes OG keys to prevent ReDoS and attribute injection', async () => {
|
|
842
|
+
const html = await app.renderShell(shell, {
|
|
843
|
+
component: 'test-page',
|
|
844
|
+
og: {
|
|
845
|
+
'title" onload="alert(1)': 'attack', // attribute breakout attempt
|
|
846
|
+
'valid-key': 'safe value',
|
|
847
|
+
'': 'empty key should be skipped', // empty after sanitization
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
// Attribute injection should be neutralized (quotes stripped from key)
|
|
851
|
+
expect(html).not.toContain('onload=');
|
|
852
|
+
expect(html).toContain('og:titleonloadalert1');
|
|
853
|
+
// Valid key should work fine
|
|
854
|
+
expect(html).toContain('og:valid-key');
|
|
855
|
+
expect(html).toContain('safe value');
|
|
856
|
+
// Empty key should be skipped
|
|
857
|
+
const emptyOgCount = (html.match(/og:""/g) || []).length;
|
|
858
|
+
expect(emptyOgCount).toBe(0);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('handles $ substitution patterns in component output safely', async () => {
|
|
862
|
+
app.component('dollar-page', {
|
|
863
|
+
render() { return "<p>Price: $1.00 and $' and $` tricks</p>"; }
|
|
864
|
+
});
|
|
865
|
+
const html = await app.renderShell(shell, { component: 'dollar-page' });
|
|
866
|
+
expect(html).toContain("$1.00");
|
|
867
|
+
expect(html).toContain("$'");
|
|
868
|
+
expect(html).toContain("$`");
|
|
869
|
+
});
|
|
870
|
+
});
|
package/tests/test-minifier.js
CHANGED
|
@@ -8,7 +8,7 @@ function minifyCSS(css) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const tests = [
|
|
11
|
-
// Pseudo-classes with descendant combinator (the bug
|
|
11
|
+
// Pseudo-classes with descendant combinator (the bug was that the space before :not() was being removed, which is incorrect)
|
|
12
12
|
['.docs-section :not(pre) > code { color: red }', 'space before :not()'],
|
|
13
13
|
['.foo :has(.bar) { color: red }', 'space before :has()'],
|
|
14
14
|
['.foo :is(.a, .b) { color: red }', 'space before :is()'],
|
|
@@ -18,12 +18,12 @@ const tests = [
|
|
|
18
18
|
['.foo::before { content: "x" }', '::before'],
|
|
19
19
|
['.foo ::after { content: "x" }', 'space before ::after'],
|
|
20
20
|
|
|
21
|
-
// Pseudo-classes directly on element (no space
|
|
21
|
+
// Pseudo-classes directly on element (no space - should stay compact)
|
|
22
22
|
['a:hover { color: red }', ':hover no space'],
|
|
23
23
|
['li:nth-child(2n + 1) { color: red }', ':nth-child()'],
|
|
24
24
|
['input:focus-visible { outline: 1px }', ':focus-visible'],
|
|
25
25
|
|
|
26
|
-
// calc()
|
|
26
|
+
// calc() - spaces around operators are significant!
|
|
27
27
|
['.foo { width: calc(100% - 20px) }', 'calc() spaces'],
|
|
28
28
|
['.foo { width: calc(50vw + 2rem) }', 'calc() addition'],
|
|
29
29
|
['.foo { font-size: clamp(1rem, 2vw, 3rem) }', 'clamp()'],
|
|
@@ -145,7 +145,7 @@ assert('.bar :not(.a):not(.b) { color: red }',
|
|
|
145
145
|
// Edge case: content with double spaces inside quotes
|
|
146
146
|
const dblSpace = minifyCSS('.foo::before { content: "a b" }');
|
|
147
147
|
if (dblSpace.includes('content:"a b"')) {
|
|
148
|
-
console.log('WARN content double space
|
|
148
|
+
console.log('WARN content double space - "a b" collapsed to "a b" (cosmetic, not functional)');
|
|
149
149
|
} else {
|
|
150
150
|
console.log('PASS content double space preserved');
|
|
151
151
|
}
|
package/types/misc.d.ts
CHANGED
|
@@ -165,15 +165,37 @@ export function safeEval(expr: string, scope: object[]): any;
|
|
|
165
165
|
/**
|
|
166
166
|
* Supported event modifier strings for `@event` and `z-on:event` bindings.
|
|
167
167
|
* Modifiers are appended to the event name with dots, e.g. `@click.prevent.stop`.
|
|
168
|
+
*
|
|
169
|
+
* **Key modifiers** — named shortcuts (`.enter`, `.escape`, `.tab`, `.space`,
|
|
170
|
+
* `.delete`, `.up`, `.down`, `.left`, `.right`) plus any arbitrary key matched
|
|
171
|
+
* case-insensitively against `KeyboardEvent.key` (e.g. `.a`, `.f1`, `.+`).
|
|
172
|
+
*
|
|
173
|
+
* **System modifiers** — `.ctrl`, `.shift`, `.alt`, `.meta` require the
|
|
174
|
+
* corresponding modifier key to be held.
|
|
168
175
|
*/
|
|
169
176
|
export type EventModifier =
|
|
170
177
|
| 'prevent'
|
|
171
178
|
| 'stop'
|
|
172
179
|
| 'self'
|
|
173
180
|
| 'once'
|
|
181
|
+
| 'outside'
|
|
174
182
|
| 'capture'
|
|
175
183
|
| 'passive'
|
|
176
184
|
| `debounce`
|
|
177
185
|
| `debounce.${number}`
|
|
178
186
|
| `throttle`
|
|
179
|
-
| `throttle.${number}
|
|
187
|
+
| `throttle.${number}`
|
|
188
|
+
| 'enter'
|
|
189
|
+
| 'escape'
|
|
190
|
+
| 'tab'
|
|
191
|
+
| 'space'
|
|
192
|
+
| 'delete'
|
|
193
|
+
| 'up'
|
|
194
|
+
| 'down'
|
|
195
|
+
| 'left'
|
|
196
|
+
| 'right'
|
|
197
|
+
| 'ctrl'
|
|
198
|
+
| 'shift'
|
|
199
|
+
| 'alt'
|
|
200
|
+
| 'meta'
|
|
201
|
+
| (string & {});
|
package/types/router.d.ts
CHANGED
|
@@ -163,3 +163,28 @@ export function createRouter(config: RouterConfig): RouterInstance;
|
|
|
163
163
|
|
|
164
164
|
/** Get the currently active router instance. */
|
|
165
165
|
export function getRouter(): RouterInstance | null;
|
|
166
|
+
|
|
167
|
+
/** Result of matching a URL path against route definitions. */
|
|
168
|
+
export interface RouteMatch {
|
|
169
|
+
/** The matched component name, or the fallback if nothing matched. */
|
|
170
|
+
component: string;
|
|
171
|
+
/** Parsed `:param` values from the URL. */
|
|
172
|
+
params: Record<string, string>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Match a pathname against an array of route definitions.
|
|
177
|
+
* Returns `{ component, params }`. If no route matches, returns the
|
|
178
|
+
* fallback component (default `'not-found'`).
|
|
179
|
+
*
|
|
180
|
+
* DOM-free — works on both server and client.
|
|
181
|
+
*
|
|
182
|
+
* @param routes - Array of route definitions with `path` and `component`.
|
|
183
|
+
* @param pathname - URL path to match, e.g. `'/blog/my-post'`.
|
|
184
|
+
* @param fallback - Component name when nothing matches (default `'not-found'`).
|
|
185
|
+
*/
|
|
186
|
+
export function matchRoute(
|
|
187
|
+
routes: RouteDefinition[],
|
|
188
|
+
pathname: string,
|
|
189
|
+
fallback?: string,
|
|
190
|
+
): RouteMatch;
|
package/types/ssr.d.ts
CHANGED
|
@@ -51,6 +51,36 @@ export interface SSRApp {
|
|
|
51
51
|
og?: Record<string, string>;
|
|
52
52
|
};
|
|
53
53
|
}): Promise<string>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a component into an existing HTML shell template.
|
|
57
|
+
*
|
|
58
|
+
* Unlike `renderPage()` which generates a full document from scratch,
|
|
59
|
+
* `renderShell()` takes your own `index.html` (with nav, footer, custom
|
|
60
|
+
* markup) and injects the SSR-rendered component body plus metadata.
|
|
61
|
+
*
|
|
62
|
+
* Handles `<z-outlet>` injection, `<title>` replacement, meta description,
|
|
63
|
+
* Open Graph tags, and `window.__SSR_DATA__` hydration data.
|
|
64
|
+
*
|
|
65
|
+
* @param shell - HTML template string (your index.html content).
|
|
66
|
+
* @param options - Rendering and metadata options.
|
|
67
|
+
*/
|
|
68
|
+
renderShell(shell: string, options?: {
|
|
69
|
+
/** Registered component name to render into `<z-outlet>`. */
|
|
70
|
+
component?: string;
|
|
71
|
+
/** Props passed to the component. */
|
|
72
|
+
props?: Record<string, any>;
|
|
73
|
+
/** Page title — replaces `<title>`. */
|
|
74
|
+
title?: string;
|
|
75
|
+
/** Meta description — replaces `<meta name="description">`. */
|
|
76
|
+
description?: string;
|
|
77
|
+
/** Open Graph tags to replace or inject (e.g. `{ title, description, type, image }`). */
|
|
78
|
+
og?: Record<string, string>;
|
|
79
|
+
/** Data embedded as `window.__SSR_DATA__` for client-side hydration. */
|
|
80
|
+
ssrData?: any;
|
|
81
|
+
/** Options passed through to `renderToString()` (hydrate, mode). */
|
|
82
|
+
renderOptions?: { hydrate?: boolean; mode?: 'html' | 'fragment' };
|
|
83
|
+
}): Promise<string>;
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
/** Create an SSR application instance. */
|
|
@@ -67,3 +97,6 @@ export function renderToString(definition: ComponentDefinition, props?: Record<s
|
|
|
67
97
|
* Escape HTML entities - exposed for use in SSR templates.
|
|
68
98
|
*/
|
|
69
99
|
export function escapeHtml(str: string): string;
|
|
100
|
+
|
|
101
|
+
// Re-exported from router for SSR server convenience
|
|
102
|
+
export { matchRoute, RouteMatch } from './router';
|