@prairielearn/react 0.0.1 → 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 CHANGED
@@ -1,45 +1,84 @@
1
- # @prairielearn/react
1
+ # `@prairielearn/react`
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Utilities for rendering React components within PrairieLearn's HTML templating system, including static rendering and client-side hydration.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ ## Usage
6
6
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
7
+ ### Rendering static HTML
8
8
 
9
- ## Purpose
9
+ To render a non-interactive React component to an HTML-safe string, use `renderHtml`:
10
10
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@prairielearn/react`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
11
+ ```tsx
12
+ import { renderHtml } from '@prairielearn/react/server';
13
+ import { html } from '@prairielearn/html';
15
14
 
16
- ## What is OIDC Trusted Publishing?
15
+ function MyComponent() {
16
+ return <div>Hello, world!</div>;
17
+ }
17
18
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
19
+ const template = html`<div class="container">${renderHtml(<MyComponent />)}</div>`;
20
+ ```
19
21
 
20
- ## Setup Instructions
22
+ To render a complete document with a DOCTYPE declaration, use `renderHtmlDocument`:
21
23
 
22
- To properly configure OIDC trusted publishing for this package:
24
+ ```tsx
25
+ import { renderHtmlDocument } from '@prairielearn/react/server';
23
26
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
27
+ const htmlDoc = renderHtmlDocument(
28
+ <html>
29
+ <head>
30
+ <title>My Page</title>
31
+ </head>
32
+ <body>Content</body>
33
+ </html>,
34
+ );
35
+ ```
28
36
 
29
- ## DO NOT USE THIS PACKAGE
37
+ ### Rendering components for client-side hydration
30
38
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
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.
36
40
 
37
- ## More Information
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.
38
42
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
43
+ ```tsx
44
+ import { Hydrate } from '@prairielearn/react/server';
42
45
 
43
- ---
46
+ function InteractiveComponent({ name }: { name: string }) {
47
+ return <button onClick={() => alert(`Hello, ${name}!`)}>Click me</button>;
48
+ }
44
49
 
45
- **Maintained for OIDC setup purposes only**
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/react/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/react/hydrated-component';
80
+
81
+ import { InteractiveComponent } from '../../../../src/components/InteractiveComponent.js';
82
+
83
+ registerHydratedComponent(InteractiveComponent);
84
+ ```
@@ -0,0 +1,8 @@
1
+ import type { ComponentType } from 'react';
2
+ /**
3
+ * Registers a React component for client-side hydration. The component should have a
4
+ * `displayName` property. If it's missing, or the name of the component bundle differs,
5
+ * you can provide a `nameOverride`.
6
+ */
7
+ export declare function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string): void;
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hydrated-component/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAc3C;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,EAAE,YAAY,CAAC,EAAE,MAAM,QAQ7F","sourcesContent":["import type { ComponentType } from 'react';\nimport { hydrateRoot } from 'react-dom/client';\nimport { observe } from 'selector-observer';\nimport superjson from 'superjson';\n\nimport { onDocumentReady } from '@prairielearn/browser-utils';\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 React 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.previousElementSibling;\n if (!dataElement) throw new Error('No data element found');\n if (!dataElement.hasAttribute('data-component-props')) {\n throw new Error('Data element is missing data-component-props attribute');\n }\n if (!dataElement.textContent) throw new Error('Data element has no content');\n const data: object = superjson.parse(dataElement.textContent);\n\n hydrateRoot(el, <Component {...data} />);\n },\n });\n});\n"]}
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { hydrateRoot } from 'react-dom/client';
3
+ import { observe } from 'selector-observer';
4
+ import superjson from 'superjson';
5
+ import { onDocumentReady } from '@prairielearn/browser-utils';
6
+ import { HydratedComponentsRegistry } from './registry.js';
7
+ // This file, if imported, will register a selector observer that will hydrate
8
+ // registered components on the client.
9
+ const registry = new HydratedComponentsRegistry();
10
+ /**
11
+ * Registers a React component for client-side hydration. The component should have a
12
+ * `displayName` property. If it's missing, or the name of the component bundle differs,
13
+ * you can provide a `nameOverride`.
14
+ */
15
+ export function registerHydratedComponent(component, nameOverride) {
16
+ // Each React component that will be hydrated on the page must be registered.
17
+ // Note that we don't try to use `component.name` since it can be minified or mangled.
18
+ const id = nameOverride ?? component.displayName;
19
+ if (!id) {
20
+ throw new Error('React fragment must have a displayName or nameOverride');
21
+ }
22
+ registry.setComponent(id, component);
23
+ }
24
+ onDocumentReady(() => {
25
+ observe('.js-hydrated-component', {
26
+ async add(el) {
27
+ const componentName = el.getAttribute('data-component');
28
+ if (!componentName) {
29
+ throw new Error('js-hydrated-component element must have a data-component attribute');
30
+ }
31
+ // If you forget to register a component with `registerHydratedComponent`, this is going to hang.
32
+ const Component = await registry.getComponent(componentName);
33
+ const dataElement = el.previousElementSibling;
34
+ if (!dataElement)
35
+ throw new Error('No data element found');
36
+ if (!dataElement.hasAttribute('data-component-props')) {
37
+ throw new Error('Data element is missing data-component-props attribute');
38
+ }
39
+ if (!dataElement.textContent)
40
+ throw new Error('Data element has no content');
41
+ const data = superjson.parse(dataElement.textContent);
42
+ hydrateRoot(el, _jsx(Component, { ...data }));
43
+ },
44
+ });
45
+ });
46
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hydrated-component/index.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,SAAS,MAAM,WAAW,CAAC;AAElC,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE9D,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,EAAE;IAC9F,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;AAAA,CACtC;AAED,eAAe,CAAC,GAAG,EAAE,CAAC;IACpB,OAAO,CAAC,wBAAwB,EAAE;QAChC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE;YACZ,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,sBAAsB,CAAC;YAC9C,IAAI,CAAC,WAAW;gBAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;YAC3D,IAAI,CAAC,WAAW,CAAC,YAAY,CAAC,sBAAsB,CAAC,EAAE,CAAC;gBACtD,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;YAC5E,CAAC;YACD,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,WAAW,CAAC,EAAE,EAAE,KAAC,SAAS,OAAK,IAAI,GAAI,CAAC,CAAC;QAAA,CAC1C;KACF,CAAC,CAAC;AAAA,CACJ,CAAC,CAAC","sourcesContent":["import type { ComponentType } from 'react';\nimport { hydrateRoot } from 'react-dom/client';\nimport { observe } from 'selector-observer';\nimport superjson from 'superjson';\n\nimport { onDocumentReady } from '@prairielearn/browser-utils';\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 React 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.previousElementSibling;\n if (!dataElement) throw new Error('No data element found');\n if (!dataElement.hasAttribute('data-component-props')) {\n throw new Error('Data element is missing data-component-props attribute');\n }\n if (!dataElement.textContent) throw new Error('Data element has no content');\n const data: object = superjson.parse(dataElement.textContent);\n\n hydrateRoot(el, <Component {...data} />);\n },\n });\n});\n"]}
@@ -0,0 +1,7 @@
1
+ import type { ComponentType } from 'react';
2
+ export declare class HydratedComponentsRegistry {
3
+ private components;
4
+ setComponent(id: string, component: ComponentType<any>): void;
5
+ getComponent(id: string): Promise<ComponentType<any>>;
6
+ }
7
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/hydrated-component/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAQ3C,qBAAa,0BAA0B;IACrC,OAAO,CAAC,UAAU,CAAyE;IAE3F,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,QAUrD;IAED,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAOpD;CACF","sourcesContent":["import type { ComponentType } from 'react';\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,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,EAAE;QACtD,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;IAAA,CACrC;IAED,YAAY,CAAC,EAAU,EAA+B;QACpD,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;IAAA,CACpC;CACF","sourcesContent":["import type { ComponentType } from 'react';\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,11 @@
1
+ import type { ReactNode } from 'react';
2
+ import { HtmlSafeString } from '@prairielearn/html';
3
+ /**
4
+ * Render a non-interactive React 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 node - Contents to render to HTML.
8
+ * @returns An `HtmlSafeString` containing the rendered HTML.
9
+ */
10
+ export declare function renderHtml(node: ReactNode | string): HtmlSafeString;
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,EAAE,cAAc,EAA0B,MAAM,oBAAoB,CAAC;AAM5E;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,cAAc,CAMnE","sourcesContent":["import type { ReactNode } from 'react';\nimport { renderToString } from 'react-dom/server';\n\nimport { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';\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 React 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 node - Contents to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function renderHtml(node: ReactNode | string): HtmlSafeString {\n if (typeof node === 'string') {\n return escapeHtml(new HtmlSafeString([node], []));\n }\n\n return unsafeHtml(renderToString(node));\n}\n"]}
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ import { renderToString } from 'react-dom/server';
2
+ import { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';
3
+ // These functions are separated from the other utilities so that they can be imported
4
+ // on both the client and the server. `server.tsx` imports `@prairielearn/compiled-assets`,
5
+ // which cannot be bundled for the browser.
6
+ /**
7
+ * Render a non-interactive React component that is embedded within a tagged template literal.
8
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
9
+ *
10
+ * @param node - Contents to render to HTML.
11
+ * @returns An `HtmlSafeString` containing the rendered HTML.
12
+ */
13
+ export function renderHtml(node) {
14
+ if (typeof node === 'string') {
15
+ return escapeHtml(new HtmlSafeString([node], []));
16
+ }
17
+ return unsafeHtml(renderToString(node));
18
+ }
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAE5E,sFAAsF;AACtF,2FAA2F;AAC3F,2CAA2C;AAE3C;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,IAAwB,EAAkB;IACnE,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC7B,OAAO,UAAU,CAAC,IAAI,cAAc,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;IACpD,CAAC;IAED,OAAO,UAAU,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC;AAAA,CACzC","sourcesContent":["import type { ReactNode } from 'react';\nimport { renderToString } from 'react-dom/server';\n\nimport { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';\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 React 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 node - Contents to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function renderHtml(node: ReactNode | string): HtmlSafeString {\n if (typeof node === 'string') {\n return escapeHtml(new HtmlSafeString([node], []));\n }\n\n return unsafeHtml(renderToString(node));\n}\n"]}
@@ -0,0 +1,36 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ import { type HtmlSafeString } from '@prairielearn/html';
3
+ /**
4
+ * Render an entire React page as an HTML document.
5
+ *
6
+ * @param content - A React node to render to HTML.
7
+ * @returns An HTML string containing the rendered content.
8
+ */
9
+ export declare function renderHtmlDocument(content: ReactNode): string;
10
+ interface HydrateProps<T> {
11
+ /** The component to hydrate. */
12
+ children: ReactElement<T>;
13
+ /** Optional override for the component's name or displayName. */
14
+ nameOverride?: string;
15
+ /** Whether to apply full height styles. */
16
+ fullHeight?: boolean;
17
+ /** Optional CSS class to apply to the container. */
18
+ className?: string;
19
+ }
20
+ /**
21
+ * A component that renders a React component for client-side hydration.
22
+ * All interactive components will need to be hydrated.
23
+ * This component is intended to be used within a non-interactive React component
24
+ * that will be rendered without hydration through `renderHtml`.
25
+ */
26
+ export declare function Hydrate<T>({ children, nameOverride, className, fullHeight }: HydrateProps<T>): ReactNode;
27
+ /**
28
+ * Renders a React component for client-side hydration and returns an HTML-safe string.
29
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
30
+ *
31
+ * @param content - A React node to render to HTML.
32
+ * @returns An `HtmlSafeString` containing the rendered HTML.
33
+ */
34
+ export declare function hydrateHtml<T>(content: ReactElement<T>, props?: Omit<HydrateProps<T>, 'children'>): HtmlSafeString;
35
+ export {};
36
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.tsx"],"names":[],"mappings":"AACA,OAAO,EAAY,KAAK,YAAY,EAAE,KAAK,SAAS,EAAkB,MAAM,OAAO,CAAC;AAKpF,OAAO,EAAE,KAAK,cAAc,EAAQ,MAAM,oBAAoB,CAAC;AAwB/D;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAE7D;AAED,UAAU,YAAY,CAAC,CAAC;IACtB,gCAAgC;IAChC,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAC1B,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2CAA2C;IAC3C,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,EACzB,QAAQ,EACR,YAAY,EACZ,SAAS,EACT,UAAkB,EACnB,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,SAAS,CA+E7B;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAC3B,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,KAAK,GAAE,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,UAAU,CAAM,GAC5C,cAAc,CAGhB","sourcesContent":["import clsx from 'clsx';\nimport { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';\nimport superjson from 'superjson';\n\nimport { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';\nimport { AugmentedError } from '@prairielearn/error';\nimport { type HtmlSafeString, html } from '@prairielearn/html';\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 React page as an HTML document.\n *\n * @param content - A React node to render to HTML.\n * @returns An HTML string containing the rendered content.\n */\nexport function renderHtmlDocument(content: ReactNode): string {\n return `<!doctype html>\\n${renderHtml(content)}`;\n}\n\ninterface HydrateProps<T> {\n /** The component to hydrate. */\n children: ReactElement<T>;\n /** Optional override for the component's name or displayName. */\n nameOverride?: string;\n /** Whether to apply full height styles. */\n fullHeight?: boolean;\n /** Optional CSS class to apply to the container. */\n className?: string;\n}\n\n/**\n * A component that renders a React 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 React component\n * that will be rendered without hydration through `renderHtml`.\n */\nexport function Hydrate<T>({\n children,\n nameOverride,\n className,\n fullHeight = false,\n}: HydrateProps<T>): ReactNode {\n if (!isValidElement(children)) {\n throw new Error('<Hydrate> expects a single React component as its child');\n }\n\n if (children.type === Fragment) {\n throw new Error('<Hydrate> does not support fragments');\n }\n\n const { type: Component, props } = children;\n if (typeof Component !== 'function') {\n throw new Error('<Hydrate> expects a React 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 as any).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/react/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 <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={componentName}\n data-component-props\n />\n <div\n data-component={componentName}\n className={clsx('js-hydrated-component', { 'h-100': fullHeight }, className)}\n >\n <Component {...props} />\n </div>\n </Fragment>\n );\n}\n\n/**\n * Renders a React 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 React node to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function hydrateHtml<T>(\n content: ReactElement<T>,\n props: Omit<HydrateProps<T>, 'children'> = {},\n): HtmlSafeString {\n // Useful for adding React components to existing tagged-template pages.\n return renderHtml(<Hydrate {...props}>{content}</Hydrate>);\n}\n"]}
package/dist/server.js ADDED
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import clsx from 'clsx';
3
+ import { Fragment, isValidElement } from 'react';
4
+ import superjson from 'superjson';
5
+ import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
6
+ import { AugmentedError } from '@prairielearn/error';
7
+ import { html } from '@prairielearn/html';
8
+ import { renderHtml } from './index.js';
9
+ // Based on https://pkg.go.dev/encoding/json#HTMLEscape
10
+ const ENCODE_HTML_RULES = {
11
+ '&': '\\u0026',
12
+ '>': '\\u003e',
13
+ '<': '\\u003c',
14
+ '\u2028': '\\u2028',
15
+ '\u2029': '\\u2029',
16
+ };
17
+ const MATCH_HTML = /[&><\u2028\u2029]/g;
18
+ /**
19
+ * Escape a value for use in a JSON string that will be rendered in HTML.
20
+ *
21
+ * @param value - The value to escape.
22
+ * @returns A JSON string with HTML-sensitive characters escaped.
23
+ */
24
+ function escapeJsonForHtml(value) {
25
+ return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);
26
+ }
27
+ /**
28
+ * Render an entire React page as an HTML document.
29
+ *
30
+ * @param content - A React node to render to HTML.
31
+ * @returns An HTML string containing the rendered content.
32
+ */
33
+ export function renderHtmlDocument(content) {
34
+ return `<!doctype html>\n${renderHtml(content)}`;
35
+ }
36
+ /**
37
+ * A component that renders a React component for client-side hydration.
38
+ * All interactive components will need to be hydrated.
39
+ * This component is intended to be used within a non-interactive React component
40
+ * that will be rendered without hydration through `renderHtml`.
41
+ */
42
+ export function Hydrate({ children, nameOverride, className, fullHeight = false, }) {
43
+ if (!isValidElement(children)) {
44
+ throw new Error('<Hydrate> expects a single React component as its child');
45
+ }
46
+ if (children.type === Fragment) {
47
+ throw new Error('<Hydrate> does not support fragments');
48
+ }
49
+ const { type: Component, props } = children;
50
+ if (typeof Component !== 'function') {
51
+ throw new Error('<Hydrate> expects a React component');
52
+ }
53
+ // Note that we don't use `Component.name` here because it can be minified or mangled.
54
+ const componentName = nameOverride ?? Component.displayName;
55
+ if (!componentName) {
56
+ // This is only defined in development, not in production when the function name is minified.
57
+ const componentDevName = Component.name || 'UnknownComponent';
58
+ throw new AugmentedError('<Hydrate> expects a component to have a displayName or nameOverride.', {
59
+ info: html `
60
+ <div>
61
+ <p>Make sure to add a displayName to the component:</p>
62
+ <pre><code>export const ${componentDevName} = ...;
63
+ // Add this line:
64
+ ${componentDevName}.displayName = '${componentDevName}';</code></pre>
65
+ </div>
66
+ `,
67
+ });
68
+ }
69
+ const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;
70
+ let compiledScriptSrc = '';
71
+ try {
72
+ compiledScriptSrc = compiledScriptPath(scriptPath);
73
+ }
74
+ catch (error) {
75
+ throw new AugmentedError(`Could not find script for component "${componentName}".`, {
76
+ info: html `
77
+ <div>
78
+ Make sure you create a script at
79
+ <code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the
80
+ component:
81
+ <pre><code>import { registerHydratedComponent } from '@prairielearn/react/hydrated-component';
82
+
83
+ import { ${componentName} } from './path/to/component.js';
84
+
85
+ registerHydratedComponent(${componentName});</code></pre>
86
+ </div>
87
+ `,
88
+ cause: error,
89
+ });
90
+ }
91
+ const scriptPreloads = compiledScriptPreloadPaths(scriptPath);
92
+ return (_jsxs(Fragment, { children: [
93
+ _jsx("script", { type: "module", src: compiledScriptSrc }), scriptPreloads.map((preloadPath) => (_jsx("link", { rel: "modulepreload", href: preloadPath }, preloadPath))), _jsx("script", { type: "application/json",
94
+ // eslint-disable-next-line @eslint-react/dom/no-dangerously-set-innerhtml
95
+ dangerouslySetInnerHTML: {
96
+ __html: escapeJsonForHtml(props),
97
+ }, "data-component": componentName, "data-component-props": true }), _jsx("div", { "data-component": componentName, className: clsx('js-hydrated-component', { 'h-100': fullHeight }, className), children: _jsx(Component, { ...props }) })
98
+ ] }));
99
+ }
100
+ /**
101
+ * Renders a React component for client-side hydration and returns an HTML-safe string.
102
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
103
+ *
104
+ * @param content - A React node to render to HTML.
105
+ * @returns An `HtmlSafeString` containing the rendered HTML.
106
+ */
107
+ export function hydrateHtml(content, props = {}) {
108
+ // Useful for adding React components to existing tagged-template pages.
109
+ return renderHtml(_jsx(Hydrate, { ...props, children: content }));
110
+ }
111
+ //# 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 { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';\nimport superjson from 'superjson';\n\nimport { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';\nimport { AugmentedError } from '@prairielearn/error';\nimport { type HtmlSafeString, html } from '@prairielearn/html';\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 React page as an HTML document.\n *\n * @param content - A React node to render to HTML.\n * @returns An HTML string containing the rendered content.\n */\nexport function renderHtmlDocument(content: ReactNode): string {\n return `<!doctype html>\\n${renderHtml(content)}`;\n}\n\ninterface HydrateProps<T> {\n /** The component to hydrate. */\n children: ReactElement<T>;\n /** Optional override for the component's name or displayName. */\n nameOverride?: string;\n /** Whether to apply full height styles. */\n fullHeight?: boolean;\n /** Optional CSS class to apply to the container. */\n className?: string;\n}\n\n/**\n * A component that renders a React 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 React component\n * that will be rendered without hydration through `renderHtml`.\n */\nexport function Hydrate<T>({\n children,\n nameOverride,\n className,\n fullHeight = false,\n}: HydrateProps<T>): ReactNode {\n if (!isValidElement(children)) {\n throw new Error('<Hydrate> expects a single React component as its child');\n }\n\n if (children.type === Fragment) {\n throw new Error('<Hydrate> does not support fragments');\n }\n\n const { type: Component, props } = children;\n if (typeof Component !== 'function') {\n throw new Error('<Hydrate> expects a React 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 as any).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/react/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 <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={componentName}\n data-component-props\n />\n <div\n data-component={componentName}\n className={clsx('js-hydrated-component', { 'h-100': fullHeight }, className)}\n >\n <Component {...props} />\n </div>\n </Fragment>\n );\n}\n\n/**\n * Renders a React 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 React node to render to HTML.\n * @returns An `HtmlSafeString` containing the rendered HTML.\n */\nexport function hydrateHtml<T>(\n content: ReactElement<T>,\n props: Omit<HydrateProps<T>, 'children'> = {},\n): HtmlSafeString {\n // Useful for adding React components to existing tagged-template pages.\n return renderHtml(<Hydrate {...props}>{content}</Hydrate>);\n}\n"]}
package/package.json CHANGED
@@ -1,10 +1,39 @@
1
1
  {
2
2
  "name": "@prairielearn/react",
3
- "version": "0.0.1",
4
- "description": "OIDC trusted publishing setup package for @prairielearn/react",
5
- "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/PrairieLearn/PrairieLearn.git",
8
+ "directory": "packages/react"
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": "tsgo",
17
+ "dev": "tsgo --watch --preserveWatchOutput"
18
+ },
19
+ "dependencies": {
20
+ "@prairielearn/browser-utils": "^2.6.1",
21
+ "@prairielearn/compiled-assets": "^3.3.3",
22
+ "@prairielearn/error": "^2.0.24",
23
+ "@prairielearn/html": "^4.0.24",
24
+ "@prairielearn/utils": "^2.0.5",
25
+ "@types/react": "^19.2.8",
26
+ "@types/react-dom": "^19.2.3",
27
+ "clsx": "^2.1.1",
28
+ "react": "^19.2.3",
29
+ "react-dom": "^19.2.3",
30
+ "selector-observer": "^2.1.6",
31
+ "superjson": "^2.2.6"
32
+ },
33
+ "devDependencies": {
34
+ "@prairielearn/tsconfig": "^0.0.0",
35
+ "@types/node": "^22.19.5",
36
+ "@typescript/native-preview": "^7.0.0-dev.20260106.1",
37
+ "typescript": "^5.9.3"
38
+ }
10
39
  }
@@ -0,0 +1,52 @@
1
+ import type { ComponentType } from 'react';
2
+ import { hydrateRoot } from 'react-dom/client';
3
+ import { observe } from 'selector-observer';
4
+ import superjson from 'superjson';
5
+
6
+ import { onDocumentReady } from '@prairielearn/browser-utils';
7
+
8
+ import { HydratedComponentsRegistry } from './registry.js';
9
+
10
+ // This file, if imported, will register a selector observer that will hydrate
11
+ // registered components on the client.
12
+
13
+ const registry = new HydratedComponentsRegistry();
14
+
15
+ /**
16
+ * Registers a React component for client-side hydration. The component should have a
17
+ * `displayName` property. If it's missing, or the name of the component bundle differs,
18
+ * you can provide a `nameOverride`.
19
+ */
20
+ export function registerHydratedComponent(component: ComponentType<any>, nameOverride?: string) {
21
+ // Each React component that will be hydrated on the page must be registered.
22
+ // Note that we don't try to use `component.name` since it can be minified or mangled.
23
+ const id = nameOverride ?? component.displayName;
24
+ if (!id) {
25
+ throw new Error('React fragment must have a displayName or nameOverride');
26
+ }
27
+ registry.setComponent(id, component);
28
+ }
29
+
30
+ onDocumentReady(() => {
31
+ observe('.js-hydrated-component', {
32
+ async add(el) {
33
+ const componentName = el.getAttribute('data-component');
34
+ if (!componentName) {
35
+ throw new Error('js-hydrated-component element must have a data-component attribute');
36
+ }
37
+
38
+ // If you forget to register a component with `registerHydratedComponent`, this is going to hang.
39
+ const Component = await registry.getComponent(componentName);
40
+
41
+ const dataElement = el.previousElementSibling;
42
+ if (!dataElement) throw new Error('No data element found');
43
+ if (!dataElement.hasAttribute('data-component-props')) {
44
+ throw new Error('Data element is missing data-component-props attribute');
45
+ }
46
+ if (!dataElement.textContent) throw new Error('Data element has no content');
47
+ const data: object = superjson.parse(dataElement.textContent);
48
+
49
+ hydrateRoot(el, <Component {...data} />);
50
+ },
51
+ });
52
+ });
@@ -0,0 +1,32 @@
1
+ import type { ComponentType } from 'react';
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,23 @@
1
+ import type { ReactNode } from 'react';
2
+ import { renderToString } from 'react-dom/server';
3
+
4
+ import { HtmlSafeString, escapeHtml, unsafeHtml } from '@prairielearn/html';
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 React 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 node - Contents to render to HTML.
15
+ * @returns An `HtmlSafeString` containing the rendered HTML.
16
+ */
17
+ export function renderHtml(node: ReactNode | string): HtmlSafeString {
18
+ if (typeof node === 'string') {
19
+ return escapeHtml(new HtmlSafeString([node], []));
20
+ }
21
+
22
+ return unsafeHtml(renderToString(node));
23
+ }
package/src/server.tsx ADDED
@@ -0,0 +1,157 @@
1
+ import clsx from 'clsx';
2
+ import { Fragment, type ReactElement, type ReactNode, isValidElement } from 'react';
3
+ import superjson from 'superjson';
4
+
5
+ import { compiledScriptPath, compiledScriptPreloadPaths } from '@prairielearn/compiled-assets';
6
+ import { AugmentedError } from '@prairielearn/error';
7
+ import { type HtmlSafeString, html } from '@prairielearn/html';
8
+
9
+ import { renderHtml } from './index.js';
10
+
11
+ // Based on https://pkg.go.dev/encoding/json#HTMLEscape
12
+ const ENCODE_HTML_RULES: Record<string, string> = {
13
+ '&': '\\u0026',
14
+ '>': '\\u003e',
15
+ '<': '\\u003c',
16
+ '\u2028': '\\u2028',
17
+ '\u2029': '\\u2029',
18
+ };
19
+ const MATCH_HTML = /[&><\u2028\u2029]/g;
20
+
21
+ /**
22
+ * Escape a value for use in a JSON string that will be rendered in HTML.
23
+ *
24
+ * @param value - The value to escape.
25
+ * @returns A JSON string with HTML-sensitive characters escaped.
26
+ */
27
+ function escapeJsonForHtml(value: any): string {
28
+ return superjson.stringify(value).replaceAll(MATCH_HTML, (c) => ENCODE_HTML_RULES[c] || c);
29
+ }
30
+
31
+ /**
32
+ * Render an entire React page as an HTML document.
33
+ *
34
+ * @param content - A React node to render to HTML.
35
+ * @returns An HTML string containing the rendered content.
36
+ */
37
+ export function renderHtmlDocument(content: ReactNode): string {
38
+ return `<!doctype html>\n${renderHtml(content)}`;
39
+ }
40
+
41
+ interface HydrateProps<T> {
42
+ /** The component to hydrate. */
43
+ children: ReactElement<T>;
44
+ /** Optional override for the component's name or displayName. */
45
+ nameOverride?: string;
46
+ /** Whether to apply full height styles. */
47
+ fullHeight?: boolean;
48
+ /** Optional CSS class to apply to the container. */
49
+ className?: string;
50
+ }
51
+
52
+ /**
53
+ * A component that renders a React 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 React component
56
+ * that will be rendered without hydration through `renderHtml`.
57
+ */
58
+ export function Hydrate<T>({
59
+ children,
60
+ nameOverride,
61
+ className,
62
+ fullHeight = false,
63
+ }: HydrateProps<T>): ReactNode {
64
+ if (!isValidElement(children)) {
65
+ throw new Error('<Hydrate> expects a single React component as its child');
66
+ }
67
+
68
+ if (children.type === Fragment) {
69
+ throw new Error('<Hydrate> does not support fragments');
70
+ }
71
+
72
+ const { type: Component, props } = children;
73
+ if (typeof Component !== 'function') {
74
+ throw new Error('<Hydrate> expects a React component');
75
+ }
76
+
77
+ // Note that we don't use `Component.name` here because it can be minified or mangled.
78
+ const componentName = nameOverride ?? (Component as any).displayName;
79
+ if (!componentName) {
80
+ // This is only defined in development, not in production when the function name is minified.
81
+ const componentDevName = Component.name || 'UnknownComponent';
82
+ throw new AugmentedError(
83
+ '<Hydrate> expects a component to have a displayName or nameOverride.',
84
+ {
85
+ info: html`
86
+ <div>
87
+ <p>Make sure to add a displayName to the component:</p>
88
+ <pre><code>export const ${componentDevName} = ...;
89
+ // Add this line:
90
+ ${componentDevName}.displayName = '${componentDevName}';</code></pre>
91
+ </div>
92
+ `,
93
+ },
94
+ );
95
+ }
96
+
97
+ const scriptPath = `esm-bundles/hydrated-components/${componentName}.ts`;
98
+ let compiledScriptSrc = '';
99
+ try {
100
+ compiledScriptSrc = compiledScriptPath(scriptPath);
101
+ } catch (error) {
102
+ throw new AugmentedError(`Could not find script for component "${componentName}".`, {
103
+ info: html`
104
+ <div>
105
+ Make sure you create a script at
106
+ <code>esm-bundles/hydrated-components/${componentName}.ts</code> registering the
107
+ component:
108
+ <pre><code>import { registerHydratedComponent } from '@prairielearn/react/hydrated-component';
109
+
110
+ import { ${componentName} } from './path/to/component.js';
111
+
112
+ registerHydratedComponent(${componentName});</code></pre>
113
+ </div>
114
+ `,
115
+ cause: error,
116
+ });
117
+ }
118
+ const scriptPreloads = compiledScriptPreloadPaths(scriptPath);
119
+ return (
120
+ <Fragment>
121
+ <script type="module" src={compiledScriptSrc} />
122
+ {scriptPreloads.map((preloadPath) => (
123
+ <link key={preloadPath} rel="modulepreload" href={preloadPath} />
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={componentName}
132
+ data-component-props
133
+ />
134
+ <div
135
+ data-component={componentName}
136
+ className={clsx('js-hydrated-component', { 'h-100': fullHeight }, className)}
137
+ >
138
+ <Component {...props} />
139
+ </div>
140
+ </Fragment>
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Renders a React component for client-side hydration and returns an HTML-safe string.
146
+ * This function is intended to be used within a tagged template literal, e.g. html`...`.
147
+ *
148
+ * @param content - A React node to render to HTML.
149
+ * @returns An `HtmlSafeString` containing the rendered HTML.
150
+ */
151
+ export function hydrateHtml<T>(
152
+ content: ReactElement<T>,
153
+ props: Omit<HydrateProps<T>, 'children'> = {},
154
+ ): HtmlSafeString {
155
+ // Useful for adding React components to existing tagged-template pages.
156
+ return renderHtml(<Hydrate {...props}>{content}</Hydrate>);
157
+ }
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
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
9
+ }
10
+ }