@prairielearn/preact 1.0.0

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.
File without changes
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # `@prairielearn/preact`
2
+
3
+ Utilities for rendering Preact components within PrairieLearn's HTML templating system, including static rendering and client-side hydration.
4
+
5
+ ## Usage
6
+
7
+ ### Rendering static HTML
8
+
9
+ To render a non-interactive Preact component to an HTML-safe string, use `renderHtml`:
10
+
11
+ ```tsx
12
+ import { renderHtml } from '@prairielearn/preact/server';
13
+ import { html } from '@prairielearn/html';
14
+
15
+ function MyComponent() {
16
+ return <div>Hello, world!</div>;
17
+ }
18
+
19
+ const template = html`<div class="container">${renderHtml(<MyComponent />)}</div>`;
20
+ ```
21
+
22
+ To render a complete document with a DOCTYPE declaration, use `renderHtmlDocument`:
23
+
24
+ ```tsx
25
+ import { renderHtmlDocument } from '@prairielearn/preact/server';
26
+
27
+ const htmlDoc = renderHtmlDocument(
28
+ <html>
29
+ <head>
30
+ <title>My Page</title>
31
+ </head>
32
+ <body>Content</body>
33
+ </html>,
34
+ );
35
+ ```
36
+
37
+ ### Rendering components for client-side hydration
38
+
39
+ Interactive components that require client-side JavaScript must be wrapped in a `<Hydrate>` component. This sets up the necessary HTML structure and data attributes for hydration.
40
+
41
+ The root component must live in a module that can be imported on the client, and it must have a `displayName` property set. This is used to identify the component during hydration.
42
+
43
+ ```tsx
44
+ import { Hydrate } from '@prairielearn/preact/server';
45
+
46
+ function InteractiveComponent({ name }: { name: string }) {
47
+ return <button onClick={() => alert(`Hello, ${name}!`)}>Click me</button>;
48
+ }
49
+
50
+ InteractiveComponent.displayName = 'InteractiveComponent';
51
+ ```
52
+
53
+ When rendering the page, wrap the component in `<Hydrate>`:
54
+
55
+ ```tsx
56
+ <Hydrate>
57
+ <InteractiveComponent name="Alice" />
58
+ </Hydrate>
59
+ ```
60
+
61
+ Alternatively, you can use the `hydrateHtml` convenience function to produce an HTML-safe string directly:
62
+
63
+ ```tsx
64
+ import { hydrateHtml } from '@prairielearn/preact/server';
65
+ import { html } from '@prairielearn/html';
66
+
67
+ const template = html`
68
+ <div class="container">${hydrateHtml(<InteractiveComponent name="Alice" />)}</div>
69
+ `;
70
+ ```
71
+
72
+ This will render the component to HTML, serialize the component's props using `superjson`, and produce markup that the client can use to hydrate the component.
73
+
74
+ **Important**: all serialized props will be visible on the client, so avoid passing sensitive data. The main PrairieLearn application includes Zod schemas to strip down data structures before passing them to hydrated components (e.g. `apps/prairielearn/src/lib/client/safe-db-types.ts` and `apps/prairielearn/src/lib/client/page-context.ts`).
75
+
76
+ Hydration relies on `@prairielearn/compiled-assets` to produce the necessary client-side bundles, and there are conventions that must be followed. Specifically, you must create a file in `assets/scripts/esm-bundles/hydrated-components`, and the file's name must match the `displayName` of the component to be hydrated. For the above example, the file would be `assets/scripts/esm-bundles/hydrated-components/InteractiveComponent.ts`. It must contain a call to `registerHydratedComponent` with the component that will be hydrated:
77
+
78
+ ```ts
79
+ import { registerHydratedComponent } from '@prairielearn/preact/hydrated-component';
80
+
81
+ import { InteractiveComponent } from '../../../../src/components/InteractiveComponent.js';
82
+
83
+ registerHydratedComponent(InteractiveComponent);
84
+ ```
@@ -0,0 +1 @@
1
+ export {};
package/dist/debug.js ADDED
@@ -0,0 +1,10 @@
1
+ if (process.env.NODE_ENV !== 'production') {
2
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
3
+ require('preact/debug');
4
+ }
5
+ else {
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ require('preact/devtools');
8
+ }
9
+ export {};
10
+ //# sourceMappingURL=debug.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"debug.js","sourceRoot":"","sources":["../src/debug.ts"],"names":[],"mappings":"AAAA,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IAC1C,iEAAiE;IACjE,OAAO,CAAC,cAAc,CAAC,CAAC;AAC1B,CAAC;KAAM,CAAC;IACN,iEAAiE;IACjE,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAC7B,CAAC","sourcesContent":["if (process.env.NODE_ENV !== 'production') {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n require('preact/debug');\n} else {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n require('preact/devtools');\n}\n"]}
@@ -0,0 +1,8 @@
1
+ import '../debug.js';
2
+ import { type ComponentType } from '@prairielearn/preact-cjs';
3
+ /**
4
+ * Registers a Preact component for client-side hydration. The component should have a
5
+ * `displayName` property. If it's missing, or the name of the component bundle differs,
6
+ * you can provide a `nameOverride`.
7
+ */
8
+ export declare function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string): void;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx } from "@prairielearn/preact-cjs/jsx-runtime";
2
+ import '../debug.js';
3
+ import { observe } from 'selector-observer';
4
+ import superjson from 'superjson';
5
+ import { onDocumentReady } from '@prairielearn/browser-utils';
6
+ import { hydrate } from '@prairielearn/preact-cjs';
7
+ import { HydratedComponentsRegistry } from './registry.js';
8
+ // This file, if imported, will register a selector observer that will hydrate
9
+ // registered components on the client.
10
+ const registry = new HydratedComponentsRegistry();
11
+ /**
12
+ * Registers a Preact component for client-side hydration. The component should have a
13
+ * `displayName` property. If it's missing, or the name of the component bundle differs,
14
+ * you can provide a `nameOverride`.
15
+ */
16
+ export function registerHydratedComponent(component, nameOverride) {
17
+ // Each React component that will be hydrated on the page must be registered.
18
+ // Note that we don't try to use `component.name` since it can be minified or mangled.
19
+ const id = nameOverride ?? component.displayName;
20
+ if (!id) {
21
+ throw new Error('React fragment must have a displayName or nameOverride');
22
+ }
23
+ registry.setComponent(id, component);
24
+ }
25
+ onDocumentReady(() => {
26
+ observe('.js-hydrated-component', {
27
+ async add(el) {
28
+ const componentName = el.getAttribute('data-component');
29
+ if (!componentName) {
30
+ throw new Error('js-hydrated-component element must have a data-component attribute');
31
+ }
32
+ // If you forget to register a component with `registerHydratedComponent`, this is going to hang.
33
+ const Component = await registry.getComponent(componentName);
34
+ const dataElement = el.querySelector('script[data-component-props]');
35
+ if (!dataElement)
36
+ throw new Error('No data element found');
37
+ if (!dataElement.textContent)
38
+ throw new Error('Data element has no content');
39
+ const data = superjson.parse(dataElement.textContent);
40
+ const rootElement = el.querySelector('div[data-component-root]');
41
+ if (!rootElement)
42
+ throw new Error('No root element found');
43
+ hydrate(_jsx(Component, { ...data }), rootElement);
44
+ },
45
+ });
46
+ });
47
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hydrated-component/index.tsx"],"names":[],"mappings":";AAAA,OAAO,aAAa,CAAC;AAErB,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,SAAS,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,EAAsB,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAEvE,OAAO,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAE3D,8EAA8E;AAC9E,uCAAuC;AAEvC,MAAM,QAAQ,GAAG,IAAI,0BAA0B,EAAE,CAAC;AAElD;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CAAC,SAA6B,EAAE,YAAqB;IAC5F,6EAA6E;IAC7E,sFAAsF;IACtF,MAAM,EAAE,GAAG,YAAY,IAAI,SAAS,CAAC,WAAW,CAAC;IACjD,IAAI,CAAC,EAAE,EAAE,CAAC;QACR,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;IAC5E,CAAC;IACD,QAAQ,CAAC,YAAY,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;AACvC,CAAC;AAED,eAAe,CAAC,GAAG,EAAE;IACnB,OAAO,CAAC,wBAAwB,EAAE;QAChC,KAAK,CAAC,GAAG,CAAC,EAAE;YACV,MAAM,aAAa,GAAG,EAAE,CAAC,YAAY,CAAC,gBAAgB,CAAC,CAAC;YACxD,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAC;YACxF,CAAC;YAED,iGAAiG;YACjG,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;YAE7D,MAAM,WAAW,GAAG,EAAE,CAAC,aAAa,CAAC,8BAA8B,CAAC,CAAC;YACrE,IAAI,CAAC,WAAW;gBAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;YAC3D,IAAI,CAAC,WAAW,CAAC,WAAW;gBAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;YAC7E,MAAM,IAAI,GAAW,SAAS,CAAC,KAAK,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;YAE9D,MAAM,WAAW,GAAG,EAAE,CAAC,aAAa,CAAC,0BAA0B,CAAC,CAAC;YACjE,IAAI,CAAC,WAAW;gBAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;YAC3D,OAAO,CAAC,KAAC,SAAS,OAAK,IAAI,GAAI,EAAE,WAAW,CAAC,CAAC;QAChD,CAAC;KACF,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import '../debug.js';\n\nimport { observe } from 'selector-observer';\nimport superjson from 'superjson';\n\nimport { onDocumentReady } from '@prairielearn/browser-utils';\nimport { type ComponentType, hydrate } from '@prairielearn/preact-cjs';\n\nimport { HydratedComponentsRegistry } from './registry.js';\n\n// This file, if imported, will register a selector observer that will hydrate\n// registered components on the client.\n\nconst registry = new HydratedComponentsRegistry();\n\n/**\n * Registers a Preact component for client-side hydration. The component should have a\n * `displayName` property. If it's missing, or the name of the component bundle differs,\n * you can provide a `nameOverride`.\n */\nexport function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string) {\n // Each React component that will be hydrated on the page must be registered.\n // Note that we don't try to use `component.name` since it can be minified or mangled.\n const id = nameOverride ?? component.displayName;\n if (!id) {\n throw new Error('React fragment must have a displayName or nameOverride');\n }\n registry.setComponent(id, component);\n}\n\nonDocumentReady(() => {\n observe('.js-hydrated-component', {\n async add(el) {\n const componentName = el.getAttribute('data-component');\n if (!componentName) {\n throw new Error('js-hydrated-component element must have a data-component attribute');\n }\n\n // If you forget to register a component with `registerHydratedComponent`, this is going to hang.\n const Component = await registry.getComponent(componentName);\n\n const dataElement = el.querySelector('script[data-component-props]');\n if (!dataElement) throw new Error('No data element found');\n if (!dataElement.textContent) throw new Error('Data element has no content');\n const data: object = superjson.parse(dataElement.textContent);\n\n const rootElement = el.querySelector('div[data-component-root]');\n if (!rootElement) throw new Error('No root element found');\n hydrate(<Component {...data} />, rootElement);\n },\n });\n});\n"]}
@@ -0,0 +1,6 @@
1
+ import type { ComponentType } from 'preact';
2
+ export declare class HydratedComponentsRegistry {
3
+ private components;
4
+ setComponent(id: string, component: ComponentType<any>): void;
5
+ getComponent(id: string): Promise<ComponentType<any>>;
6
+ }
@@ -0,0 +1,22 @@
1
+ import { withResolvers } from '@prairielearn/utils';
2
+ export class HydratedComponentsRegistry {
3
+ components = {};
4
+ setComponent(id, component) {
5
+ if (this.components[id]?.resolved) {
6
+ throw new Error(`React fragment with id ${id} already resolved`);
7
+ }
8
+ if (!this.components[id]) {
9
+ this.components[id] = { ...withResolvers(), resolved: false };
10
+ }
11
+ this.components[id].resolve(component);
12
+ this.components[id].resolved = true;
13
+ }
14
+ getComponent(id) {
15
+ if (!this.components[id]) {
16
+ // This promise will be resolved later when the component is registered via `setFragment`.
17
+ this.components[id] = { ...withResolvers(), resolved: false };
18
+ }
19
+ return this.components[id].promise;
20
+ }
21
+ }
22
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/hydrated-component/registry.ts"],"names":[],"mappings":"AAEA,OAAO,EAA6B,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAM/E,MAAM,OAAO,0BAA0B;IAC7B,UAAU,GAAsE,EAAE,CAAC;IAE3F,YAAY,CAAC,EAAU,EAAE,SAA6B;QACpD,IAAI,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC;YAClC,MAAM,IAAI,KAAK,CAAC,0BAA0B,EAAE,mBAAmB,CAAC,CAAC;QACnE,CAAC;QAED,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC;YACzB,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,aAAa,EAAsB,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;QACpF,CAAC;QACD,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACvC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,QAAQ,GAAG,IAAI,CAAC;IACtC,CAAC;IAED,YAAY,CAAC,EAAU;QACrB,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC;YACzB,0FAA0F;YAC1F,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,aAAa,EAAsB,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;QACpF,CAAC;QAED,OAAO,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC;IACrC,CAAC;CACF","sourcesContent":["import type { ComponentType } from 'preact';\n\nimport { type PromiseWithResolvers, withResolvers } from '@prairielearn/utils';\n\ntype AugmentedPromiseWithResolvers<T> = PromiseWithResolvers<T> & {\n resolved: boolean;\n};\n\nexport class HydratedComponentsRegistry {\n private components: Record<string, AugmentedPromiseWithResolvers<ComponentType<any>>> = {};\n\n setComponent(id: string, component: ComponentType<any>) {\n if (this.components[id]?.resolved) {\n throw new Error(`React fragment with id ${id} already resolved`);\n }\n\n if (!this.components[id]) {\n this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };\n }\n this.components[id].resolve(component);\n this.components[id].resolved = true;\n }\n\n getComponent(id: string): Promise<ComponentType<any>> {\n if (!this.components[id]) {\n // This promise will be resolved later when the component is registered via `setFragment`.\n this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };\n }\n\n return this.components[id].promise;\n }\n}\n"]}
@@ -0,0 +1,10 @@
1
+ import { HtmlSafeString } from '@prairielearn/html';
2
+ import { type VNode } from '@prairielearn/preact-cjs';
3
+ /**
4
+ * Render a non-interactive Preact component that is embedded within a tagged template literal.
5
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
6
+ *
7
+ * @param vnode - A Preact VNode to render to HTML.
8
+ * @returns An `HtmlSafeString` containing the rendered HTML.
9
+ */
10
+ export declare function renderHtml(vnode: VNode | string): HtmlSafeString;
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ import { render } from 'preact-render-to-string/jsx';
2
+ import { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';
3
+ import {} from '@prairielearn/preact-cjs';
4
+ // These functions are separated from the other utilities so that they can be imported
5
+ // on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,
6
+ // which cannot be bundled for the browser.
7
+ /**
8
+ * Render a non-interactive Preact component that is embedded within a tagged template literal.
9
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
10
+ *
11
+ * @param vnode - A Preact VNode to render to HTML.
12
+ * @returns An `HtmlSafeString` containing the rendered HTML.
13
+ */
14
+ export function renderHtml(vnode) {
15
+ let pretty = false;
16
+ // In development mode, render HTML with pretty formatting. This is easier to
17
+ // debug, especially in test cases. This will only do anything on the server,
18
+ // but that's fine as we won't ever be looking at HTML that's rendered on the client.
19
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
20
+ pretty = true;
21
+ }
22
+ if (typeof vnode === 'string') {
23
+ return escapeHtml(new HtmlSafeString([vnode], []));
24
+ }
25
+ return unsafeHtml(render(vnode, {}, { pretty, jsx: false }));
26
+ }
27
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAErD,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAc,MAAM,0BAA0B,CAAC;AAEtD,sFAAsF;AACtF,2FAA2F;AAC3F,2CAA2C;AAE3C;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,KAAqB;IAC9C,IAAI,MAAM,GAAG,KAAK,CAAC;IAEnB,6EAA6E;IAC7E,6EAA6E;IAC7E,qFAAqF;IACrF,IAAI,OAAO,OAAO,KAAK,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;QAC5E,MAAM,GAAG,IAAI,CAAC;IAChB,CAAC;IAED,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,OAAO,UAAU,CAAC,IAAI,cAAc,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;IACrD,CAAC;IAED,OAAO,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;AAC/D,CAAC","sourcesContent":["import { render } from 'preact-render-to-string/jsx';\n\nimport { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';\nimport { type VNode } from '@prairielearn/preact-cjs';\n\n// These functions are separated from the other utilities so that they can be imported\n// on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,\n// which cannot be bundled for the browser.\n\n/**\n * Render a non-interactive Preact component that is embedded within a tagged template literal.\n * This function is intended to be used within a tagged template literal, e.g. html`...`.\n *\n * @param vnode - A Preact VNode to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function renderHtml(vnode: VNode | string): HtmlSafeString {\n let pretty = false;\n\n // In development mode, render HTML with pretty formatting. This is easier to\n // debug, especially in test cases. This will only do anything on the server,\n // but that's fine as we won't ever be looking at HTML that's rendered on the client.\n if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {\n pretty = true;\n }\n\n if (typeof vnode === 'string') {\n return escapeHtml(new HtmlSafeString([vnode], []));\n }\n\n return unsafeHtml(render(vnode, {}, { pretty, jsx: false }));\n}\n"]}
@@ -0,0 +1,33 @@
1
+ import { type HtmlSafeString } from '@prairielearn/html';
2
+ import { type ComponentChildren, type VNode } from '@prairielearn/preact-cjs';
3
+ /**
4
+ * Render an entire Preact page as an HTML document.
5
+ *
6
+ * @param content - A Preact VNode to render to HTML.
7
+ * @returns An HTML string containing the rendered content.
8
+ */
9
+ export declare function renderHtmlDocument(content: VNode): string;
10
+ interface HydrateProps {
11
+ /** The component to hydrate */
12
+ children: ComponentChildren;
13
+ /** Optional override for the component's name or displayName */
14
+ nameOverride?: string;
15
+ /** Whether to apply full height styles. */
16
+ fullHeight?: boolean;
17
+ }
18
+ /**
19
+ * A component that renders a Preact component for client-side hydration.
20
+ * All interactive components will need to be hydrated.
21
+ * This component is intended to be used within a non-interactive Preact component
22
+ * that will be rendered without hydration through `renderHtml`.
23
+ */
24
+ export declare function Hydrate({ children, nameOverride, fullHeight }: HydrateProps): VNode;
25
+ /**
26
+ * Renders a Preact component for client-side hydration and returns an HTML-safe string.
27
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
28
+ *
29
+ * @param content - A Preact VNode to render to HTML.
30
+ * @returns An `HtmlSafeString` containing the rendered HTML.
31
+ */
32
+ export declare function hydrateHtml<T>(content: VNode<T>): HtmlSafeString;
33
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,112 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@prairielearn/preact-cjs/jsx-runtime";
2
+ import clsx from 'clsx';
3
+ import { isFragment, isValidElement } from 'preact/compat';
4
+ import { render } from 'preact-render-to-string/jsx';
5
+ import superjson from 'superjson';
6
+ import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
7
+ import { AugmentedError } from '@prairielearn/error';
8
+ import { html } from '@prairielearn/html';
9
+ import { Fragment } from '@prairielearn/preact-cjs';
10
+ import { renderHtml } from './index.js';
11
+ // Based on https://pkg.go.dev/encoding/json#HTMLEscape
12
+ const ENCODE_HTML_RULES = {
13
+ '&': '\\u0026',
14
+ '>': '\\u003e',
15
+ '<': '\\u003c',
16
+ '\u2028': '\\u2028',
17
+ '\u2029': '\\u2029',
18
+ };
19
+ const MATCH_HTML = /[&><\u2028\u2029]/g;
20
+ /**
21
+ * Escape a value for use in a JSON string that will be rendered in HTML.
22
+ *
23
+ * @param value - The value to escape.
24
+ * @returns A JSON string with HTML-sensitive characters escaped.
25
+ */
26
+ function escapeJsonForHtml(value) {
27
+ return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);
28
+ }
29
+ /**
30
+ * Render an entire Preact page as an HTML document.
31
+ *
32
+ * @param content - A Preact VNode to render to HTML.
33
+ * @returns An HTML string containing the rendered content.
34
+ */
35
+ export function renderHtmlDocument(content) {
36
+ return `<!doctype html>\n${render(content, {}, { pretty: true, jsx: false })}`;
37
+ }
38
+ /**
39
+ * A component that renders a Preact component for client-side hydration.
40
+ * All interactive components will need to be hydrated.
41
+ * This component is intended to be used within a non-interactive Preact component
42
+ * that will be rendered without hydration through `renderHtml`.
43
+ */
44
+ export function Hydrate({ children, nameOverride, fullHeight = false }) {
45
+ if (!isValidElement(children)) {
46
+ throw new Error('<Hydrate> expects a single Preact component as its child');
47
+ }
48
+ if (isFragment(children)) {
49
+ throw new Error('<Hydrate> does not support fragments');
50
+ }
51
+ const content = children;
52
+ const { type: Component, props } = content;
53
+ if (typeof Component !== 'function') {
54
+ throw new Error('<Hydrate> expects a Preact component');
55
+ }
56
+ // Note that we don't use `Component.name` here because it can be minified or mangled.
57
+ const componentName = nameOverride ?? Component.displayName;
58
+ if (!componentName) {
59
+ // This is only defined in development, not in production when the function name is minified.
60
+ const componentDevName = Component.name || 'UnknownComponent';
61
+ throw new AugmentedError('<Hydrate> expects a component to have a displayName or nameOverride.', {
62
+ info: html `
63
+ <div>
64
+ <p>Make sure to add a displayName to the component:</p>
65
+ <pre><code>export const ${componentDevName} = ...;
66
+ // Add this line:
67
+ ${componentDevName}.displayName = '${componentDevName}';</code></pre>
68
+ </div>
69
+ `,
70
+ });
71
+ }
72
+ const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;
73
+ let compiledScriptSrc = '';
74
+ try {
75
+ compiledScriptSrc = compiledScriptPath(scriptPath);
76
+ }
77
+ catch (error) {
78
+ throw new AugmentedError(`Could not find script for component "${componentName}".`, {
79
+ info: html `
80
+ <div>
81
+ Make sure you create a script at
82
+ <code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the
83
+ component:
84
+ <pre><code>import { registerHydratedComponent } from '@prairielearn/preact/hydrated-component';
85
+
86
+ import { ${componentName} } from './path/to/component.js';
87
+
88
+ registerHydratedComponent(${componentName});</code></pre>
89
+ </div>
90
+ `,
91
+ cause: error,
92
+ });
93
+ }
94
+ const scriptPreloads = compiledScriptPreloadPaths(scriptPath);
95
+ return (_jsxs(Fragment, { children: [_jsx("script", { type: "module", src: compiledScriptSrc }), scriptPreloads.map((preloadPath) => (_jsx("link", { rel: "modulepreload", href: preloadPath }, preloadPath))), _jsxs("div", { "data-component": componentName, class: clsx('js-hydrated-component', { 'h-100': fullHeight }), children: [_jsx("script", { type: "application/json",
96
+ // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
97
+ dangerouslySetInnerHTML: {
98
+ __html: escapeJsonForHtml(props),
99
+ }, "data-component-props": true }), _jsx("div", { class: fullHeight ? 'h-100' : '', "data-component-root": true, children: _jsx(Component, { ...props }) })] })] }));
100
+ }
101
+ /**
102
+ * Renders a Preact component for client-side hydration and returns an HTML-safe string.
103
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
104
+ *
105
+ * @param content - A Preact VNode to render to HTML.
106
+ * @returns An `HtmlSafeString` containing the rendered HTML.
107
+ */
108
+ export function hydrateHtml(content) {
109
+ // Useful for adding Preact components to existing tagged-template pages.
110
+ return renderHtml(_jsx(Hydrate, { children: content }));
111
+ }
112
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.tsx"],"names":[],"mappings":"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","sourcesContent":["import clsx from 'clsx';\nimport { isFragment, isValidElement } from 'preact/compat';\nimport { render } from 'preact-render-to-string/jsx';\nimport superjson from 'superjson';\n\nimport { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';\nimport { AugmentedError } from '@prairielearn/error';\nimport { type HtmlSafeString, html } from '@prairielearn/html';\nimport { type ComponentChildren, Fragment, type VNode } from '@prairielearn/preact-cjs';\n\nimport { renderHtml } from './index.js';\n\n// Based on https://pkg.go.dev/encoding/json#HTMLEscape\nconst ENCODE_HTML_RULES: Record<string, string> = {\n '&': '\\\\u0026',\n '>': '\\\\u003e',\n '<': '\\\\u003c',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n};\nconst MATCH_HTML = /[&><\\u2028\\u2029]/g;\n\n/**\n * Escape a value for use in a JSON string that will be rendered in HTML.\n *\n * @param value - The value to escape.\n * @returns A JSON string with HTML-sensitive characters escaped.\n */\nfunction escapeJsonForHtml(value: any): string {\n return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);\n}\n\n/**\n * Render an entire Preact page as an HTML document.\n *\n * @param content - A Preact VNode to render to HTML.\n * @returns An HTML string containing the rendered content.\n */\nexport function renderHtmlDocument(content: VNode) {\n return `<!doctype html>\\n${render(content, {}, { pretty: true, jsx: false })}`;\n}\n\ninterface HydrateProps {\n /** The component to hydrate */\n children: ComponentChildren;\n /** Optional override for the component's name or displayName */\n nameOverride?: string;\n /** Whether to apply full height styles. */\n fullHeight?: boolean;\n}\n\n/**\n * A component that renders a Preact component for client-side hydration.\n * All interactive components will need to be hydrated.\n * This component is intended to be used within a non-interactive Preact component\n * that will be rendered without hydration through `renderHtml`.\n */\nexport function Hydrate({ children, nameOverride, fullHeight = false }: HydrateProps): VNode {\n if (!isValidElement(children)) {\n throw new Error('<Hydrate> expects a single Preact component as its child');\n }\n\n if (isFragment(children)) {\n throw new Error('<Hydrate> does not support fragments');\n }\n\n const content = children as VNode;\n const { type: Component, props } = content;\n if (typeof Component !== 'function') {\n throw new Error('<Hydrate> expects a Preact component');\n }\n\n // Note that we don't use `Component.name` here because it can be minified or mangled.\n const componentName = nameOverride ?? Component.displayName;\n if (!componentName) {\n // This is only defined in development, not in production when the function name is minified.\n const componentDevName = Component.name || 'UnknownComponent';\n throw new AugmentedError(\n '<Hydrate> expects a component to have a displayName or nameOverride.',\n {\n info: html`\n <div>\n <p>Make sure to add a displayName to the component:</p>\n <pre><code>export const ${componentDevName} = ...;\n// Add this line:\n${componentDevName}.displayName = '${componentDevName}';</code></pre>\n </div>\n `,\n },\n );\n }\n\n const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;\n let compiledScriptSrc = '';\n try {\n compiledScriptSrc = compiledScriptPath(scriptPath);\n } catch (error) {\n throw new AugmentedError(`Could not find script for component \"${componentName}\".`, {\n info: html`\n <div>\n Make sure you create a script at\n <code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the\n component:\n <pre><code>import { registerHydratedComponent } from '@prairielearn/preact/hydrated-component';\n\nimport { ${componentName} } from './path/to/component.js';\n\nregisterHydratedComponent(${componentName});</code></pre>\n </div>\n `,\n cause: error,\n });\n }\n const scriptPreloads = compiledScriptPreloadPaths(scriptPath);\n return (\n <Fragment>\n <script type=\"module\" src={compiledScriptSrc} />\n {scriptPreloads.map((preloadPath) => (\n <link key={preloadPath} rel=\"modulepreload\" href={preloadPath} />\n ))}\n <div\n data-component={componentName}\n class={clsx('js-hydrated-component', { 'h-100': fullHeight })}\n >\n <script\n type=\"application/json\"\n // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml\n dangerouslySetInnerHTML={{\n __html: escapeJsonForHtml(props),\n }}\n data-component-props\n />\n <div class={fullHeight ? 'h-100' : ''} data-component-root>\n <Component {...props} />\n </div>\n </div>\n </Fragment>\n );\n}\n\n/**\n * Renders a Preact component for client-side hydration and returns an HTML-safe string.\n * This function is intended to be used within a tagged template literal, e.g. html`...`.\n *\n * @param content - A Preact VNode to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function hydrateHtml<T>(content: VNode<T>): HtmlSafeString {\n // Useful for adding Preact components to existing tagged-template pages.\n return renderHtml(<Hydrate>{content}</Hydrate>);\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@prairielearn/preact",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/PrairieLearn/PrairieLearn.git",
8
+ "directory": "packages/preact"
9
+ },
10
+ "exports": {
11
+ ".": "./dist/index.js",
12
+ "./server": "./dist/server.js",
13
+ "./hydrated-component": "./dist/hydrated-component/index.js"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsc --watch --preserveWatchOutput"
18
+ },
19
+ "dependencies": {
20
+ "@prairielearn/browser-utils": "^2.2.13",
21
+ "@prairielearn/compiled-assets": "^3.2.6",
22
+ "@prairielearn/error": "^2.0.19",
23
+ "@prairielearn/html": "^4.0.19",
24
+ "@prairielearn/preact-cjs": "^1.1.3",
25
+ "@prairielearn/utils": "^2.0.0",
26
+ "clsx": "^2.1.1",
27
+ "preact": "^10.27.0",
28
+ "preact-render-to-string": "^6.6.1",
29
+ "selector-observer": "^2.1.6",
30
+ "superjson": "^2.2.2"
31
+ },
32
+ "devDependencies": {
33
+ "@prairielearn/tsconfig": "^0.0.0",
34
+ "@types/node": "^22.18.0",
35
+ "typescript": "^5.9.2"
36
+ }
37
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,7 @@
1
+ if (process.env.NODE_ENV !== 'production') {
2
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
3
+ require('preact/debug');
4
+ } else {
5
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
6
+ require('preact/devtools');
7
+ }
@@ -0,0 +1,52 @@
1
+ import '../debug.js';
2
+
3
+ import { observe } from 'selector-observer';
4
+ import superjson from 'superjson';
5
+
6
+ import { onDocumentReady } from '@prairielearn/browser-utils';
7
+ import { type ComponentType, hydrate } from '@prairielearn/preact-cjs';
8
+
9
+ import { HydratedComponentsRegistry } from './registry.js';
10
+
11
+ // This file, if imported, will register a selector observer that will hydrate
12
+ // registered components on the client.
13
+
14
+ const registry = new HydratedComponentsRegistry();
15
+
16
+ /**
17
+ * Registers a Preact component for client-side hydration. The component should have a
18
+ * `displayName` property. If it's missing, or the name of the component bundle differs,
19
+ * you can provide a `nameOverride`.
20
+ */
21
+ export function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string) {
22
+ // Each React component that will be hydrated on the page must be registered.
23
+ // Note that we don't try to use `component.name` since it can be minified or mangled.
24
+ const id = nameOverride ?? component.displayName;
25
+ if (!id) {
26
+ throw new Error('React fragment must have a displayName or nameOverride');
27
+ }
28
+ registry.setComponent(id, component);
29
+ }
30
+
31
+ onDocumentReady(() => {
32
+ observe('.js-hydrated-component', {
33
+ async add(el) {
34
+ const componentName = el.getAttribute('data-component');
35
+ if (!componentName) {
36
+ throw new Error('js-hydrated-component element must have a data-component attribute');
37
+ }
38
+
39
+ // If you forget to register a component with `registerHydratedComponent`, this is going to hang.
40
+ const Component = await registry.getComponent(componentName);
41
+
42
+ const dataElement = el.querySelector('script[data-component-props]');
43
+ if (!dataElement) throw new Error('No data element found');
44
+ if (!dataElement.textContent) throw new Error('Data element has no content');
45
+ const data: object = superjson.parse(dataElement.textContent);
46
+
47
+ const rootElement = el.querySelector('div[data-component-root]');
48
+ if (!rootElement) throw new Error('No root element found');
49
+ hydrate(<Component {...data} />, rootElement);
50
+ },
51
+ });
52
+ });
@@ -0,0 +1,32 @@
1
+ import type { ComponentType } from 'preact';
2
+
3
+ import { type PromiseWithResolvers, withResolvers } from '@prairielearn/utils';
4
+
5
+ type AugmentedPromiseWithResolvers<T> = PromiseWithResolvers<T> & {
6
+ resolved: boolean;
7
+ };
8
+
9
+ export class HydratedComponentsRegistry {
10
+ private components: Record<string, AugmentedPromiseWithResolvers<ComponentType<any>>> = {};
11
+
12
+ setComponent(id: string, component: ComponentType<any>) {
13
+ if (this.components[id]?.resolved) {
14
+ throw new Error(`React fragment with id ${id} already resolved`);
15
+ }
16
+
17
+ if (!this.components[id]) {
18
+ this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };
19
+ }
20
+ this.components[id].resolve(component);
21
+ this.components[id].resolved = true;
22
+ }
23
+
24
+ getComponent(id: string): Promise<ComponentType<any>> {
25
+ if (!this.components[id]) {
26
+ // This promise will be resolved later when the component is registered via `setFragment`.
27
+ this.components[id] = { ...withResolvers<ComponentType<any>>(), resolved: false };
28
+ }
29
+
30
+ return this.components[id].promise;
31
+ }
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { render } from 'preact-render-to-string/jsx';
2
+
3
+ import { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';
4
+ import { type VNode } from '@prairielearn/preact-cjs';
5
+
6
+ // These functions are separated from the other utilities so that they can be imported
7
+ // on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,
8
+ // which cannot be bundled for the browser.
9
+
10
+ /**
11
+ * Render a non-interactive Preact component that is embedded within a tagged template literal.
12
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
13
+ *
14
+ * @param vnode - A Preact VNode to render to HTML.
15
+ * @returns An `HtmlSafeString` containing the rendered HTML.
16
+ */
17
+ export function renderHtml(vnode: VNode | string): HtmlSafeString {
18
+ let pretty = false;
19
+
20
+ // In development mode, render HTML with pretty formatting. This is easier to
21
+ // debug, especially in test cases. This will only do anything on the server,
22
+ // but that's fine as we won't ever be looking at HTML that's rendered on the client.
23
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
24
+ pretty = true;
25
+ }
26
+
27
+ if (typeof vnode === 'string') {
28
+ return escapeHtml(new HtmlSafeString([vnode], []));
29
+ }
30
+
31
+ return unsafeHtml(render(vnode, {}, { pretty, jsx: false }));
32
+ }
package/src/server.tsx ADDED
@@ -0,0 +1,151 @@
1
+ import clsx from 'clsx';
2
+ import { isFragment, isValidElement } from 'preact/compat';
3
+ import { render } from 'preact-render-to-string/jsx';
4
+ import superjson from 'superjson';
5
+
6
+ import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
7
+ import { AugmentedError } from '@prairielearn/error';
8
+ import { type HtmlSafeString, html } from '@prairielearn/html';
9
+ import { type ComponentChildren, Fragment, type VNode } from '@prairielearn/preact-cjs';
10
+
11
+ import { renderHtml } from './index.js';
12
+
13
+ // Based on https://pkg.go.dev/encoding/json#HTMLEscape
14
+ const ENCODE_HTML_RULES: Record<string, string> = {
15
+ '&': '\\u0026',
16
+ '>': '\\u003e',
17
+ '<': '\\u003c',
18
+ '\u2028': '\\u2028',
19
+ '\u2029': '\\u2029',
20
+ };
21
+ const MATCH_HTML = /[&><\u2028\u2029]/g;
22
+
23
+ /**
24
+ * Escape a value for use in a JSON string that will be rendered in HTML.
25
+ *
26
+ * @param value - The value to escape.
27
+ * @returns A JSON string with HTML-sensitive characters escaped.
28
+ */
29
+ function escapeJsonForHtml(value: any): string {
30
+ return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);
31
+ }
32
+
33
+ /**
34
+ * Render an entire Preact page as an HTML document.
35
+ *
36
+ * @param content - A Preact VNode to render to HTML.
37
+ * @returns An HTML string containing the rendered content.
38
+ */
39
+ export function renderHtmlDocument(content: VNode) {
40
+ return `<!doctype html>\n${render(content, {}, { pretty: true, jsx: false })}`;
41
+ }
42
+
43
+ interface HydrateProps {
44
+ /** The component to hydrate */
45
+ children: ComponentChildren;
46
+ /** Optional override for the component's name or displayName */
47
+ nameOverride?: string;
48
+ /** Whether to apply full height styles. */
49
+ fullHeight?: boolean;
50
+ }
51
+
52
+ /**
53
+ * A component that renders a Preact component for client-side hydration.
54
+ * All interactive components will need to be hydrated.
55
+ * This component is intended to be used within a non-interactive Preact component
56
+ * that will be rendered without hydration through `renderHtml`.
57
+ */
58
+ export function Hydrate({ children, nameOverride, fullHeight = false }: HydrateProps): VNode {
59
+ if (!isValidElement(children)) {
60
+ throw new Error('<Hydrate> expects a single Preact component as its child');
61
+ }
62
+
63
+ if (isFragment(children)) {
64
+ throw new Error('<Hydrate> does not support fragments');
65
+ }
66
+
67
+ const content = children as VNode;
68
+ const { type: Component, props } = content;
69
+ if (typeof Component !== 'function') {
70
+ throw new Error('<Hydrate> expects a Preact component');
71
+ }
72
+
73
+ // Note that we don't use `Component.name` here because it can be minified or mangled.
74
+ const componentName = nameOverride ?? Component.displayName;
75
+ if (!componentName) {
76
+ // This is only defined in development, not in production when the function name is minified.
77
+ const componentDevName = Component.name || 'UnknownComponent';
78
+ throw new AugmentedError(
79
+ '<Hydrate> expects a component to have a displayName or nameOverride.',
80
+ {
81
+ info: html`
82
+ <div>
83
+ <p>Make sure to add a displayName to the component:</p>
84
+ <pre><code>export const ${componentDevName} = ...;
85
+ // Add this line:
86
+ ${componentDevName}.displayName = '${componentDevName}';</code></pre>
87
+ </div>
88
+ `,
89
+ },
90
+ );
91
+ }
92
+
93
+ const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;
94
+ let compiledScriptSrc = '';
95
+ try {
96
+ compiledScriptSrc = compiledScriptPath(scriptPath);
97
+ } catch (error) {
98
+ throw new AugmentedError(`Could not find script for component "${componentName}".`, {
99
+ info: html`
100
+ <div>
101
+ Make sure you create a script at
102
+ <code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the
103
+ component:
104
+ <pre><code>import { registerHydratedComponent } from '@prairielearn/preact/hydrated-component';
105
+
106
+ import { ${componentName} } from './path/to/component.js';
107
+
108
+ registerHydratedComponent(${componentName});</code></pre>
109
+ </div>
110
+ `,
111
+ cause: error,
112
+ });
113
+ }
114
+ const scriptPreloads = compiledScriptPreloadPaths(scriptPath);
115
+ return (
116
+ <Fragment>
117
+ <script type="module" src={compiledScriptSrc} />
118
+ {scriptPreloads.map((preloadPath) => (
119
+ <link key={preloadPath} rel="modulepreload" href={preloadPath} />
120
+ ))}
121
+ <div
122
+ data-component={componentName}
123
+ class={clsx('js-hydrated-component', { 'h-100': fullHeight })}
124
+ >
125
+ <script
126
+ type="application/json"
127
+ // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
128
+ dangerouslySetInnerHTML={{
129
+ __html: escapeJsonForHtml(props),
130
+ }}
131
+ data-component-props
132
+ />
133
+ <div class={fullHeight ? 'h-100' : ''} data-component-root>
134
+ <Component {...props} />
135
+ </div>
136
+ </div>
137
+ </Fragment>
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Renders a Preact component for client-side hydration and returns an HTML-safe string.
143
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
144
+ *
145
+ * @param content - A Preact VNode to render to HTML.
146
+ * @returns An `HtmlSafeString` containing the rendered HTML.
147
+ */
148
+ export function hydrateHtml<T>(content: VNode<T>): HtmlSafeString {
149
+ // Useful for adding Preact components to existing tagged-template pages.
150
+ return renderHtml(<Hydrate>{content}</Hydrate>);
151
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@prairielearn/tsconfig/tsconfig.package.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["node"],
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "@prairielearn/preact-cjs"
9
+ }
10
+ }