Skip to content

Commit

Permalink
Adds @rules_prerender/preact.
Browse files Browse the repository at this point in the history
Refs #71.

This provides a small wrapper around `rules_prerender` to use Preact as a templating language. For the most part it is a relatively straightforward implementation.

Preact doesn't directly support `<template />` elements, but I was able to add support via a trivial `Template` component. I initially tried to use `htm` but ultimately decided against it because it does not have any validation out of the box right now. It's main benefit is not requiring a compiler, which would be compelling if `tsc` didn't already support standard JSX syntax out of the box.
  • Loading branch information
dgp1130 committed Mar 12, 2023
1 parent 66e8856 commit b545305
Show file tree
Hide file tree
Showing 15 changed files with 445 additions and 62 deletions.
1 change: 1 addition & 0 deletions .bazelignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Ignore deeply nested `node_modules/` directories.
node_modules
packages/declarative_shadow_dom/node_modules
packages/preact/node_modules
packages/rules_prerender/node_modules

# Ignore generated files.
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ jobs:
# the next one.
bazel build --config ci --config release \
//:rules_prerender_pkg_publish \
//packages/declarative_shadow_dom:pkg_publish
//packages/declarative_shadow_dom:pkg_publish \
//packages/preact:pkg_publish
# Tar the NPM packages prior to publish to make sure the file paths are correct.
tar -czf rules_prerender-${{ github.event.inputs.version }}.tar.gz \
Expand All @@ -68,6 +69,7 @@ jobs:
# Publish the packages.
bazel run --config ci --config release //:rules_prerender_pkg_publish
bazel run --config ci --config release //packages/declarative_shadow_dom:pkg_publish
bazel run --config ci --config release //packages/preact:pkg_publish
# Remove the token from the `.npmrc` file, it should no longer be needed.
sed -i "/${{ secrets.NPM_ACCESS_TOKEN }}/d" .npmrc
Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"lightningcss",
"npmjs",
"npmrc",
"preact",
"prerender",
"prerendered",
"prerendering",
Expand Down
6 changes: 6 additions & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ link_prerender_component(
visibility = ["//:__subpackages__"],
)

npm_link_package(
name = "node_modules/@rules_prerender/preact",
src = "//packages/preact:pkg",
visibility = ["//:__subpackages__"],
)

bzl_library(
name = "rules_prerender",
srcs = [
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"eslint": "^8.35.0",
"http-status-codes": "^2.1.4",
"husky": "^7.0.2",
"preact": "^10.13.1",
"tree-kill": "^1.2.2",
"typescript": "4.9.5",
"webdriverio": "^7.20.9"
Expand Down
55 changes: 55 additions & 0 deletions packages/preact/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
load("@aspect_rules_js//npm:defs.bzl", "npm_package")
load("@rules_prerender_npm//:defs.bzl", "npm_link_all_packages")
load("//tools/jasmine:defs.bzl", "jasmine_node_test")
load("//tools/publish:defs.bzl", "npm_publish")
load("//tools/stamping:defs.bzl", "stamp_package")
load("//tools/typescript:defs.bzl", "ts_project")

npm_link_all_packages(name = "node_modules")

stamp_package(name = "package")
npm_package(
name = "pkg",
srcs = [
"README.md",
":package",
":preact",
],
package = "@rules_prerender/preact",
visibility = ["//visibility:public"],

# TODO(#59): Remove when upstream is fixed.
# See: https://github.com/dgp1130/rules_prerender/issues/48#issuecomment-1425257276
include_external_repositories = ["rules_prerender"],
)
npm_publish(
name = "pkg_publish",
package = ":pkg",
npmrc = "//:.npmrc",
)

ts_project(
name = "preact",
srcs = ["index.mts"],
deps = [
":node_modules/preact-render-to-string",
"//:node_modules/preact",
"//:node_modules/rules_prerender",
],
)

ts_project(
name = "preact_test_lib",
srcs = ["index_test.mts"],
deps = [
":preact",
":node_modules/preact-render-to-string",
"//common/models:prerender_annotation",
"//:node_modules/@types/jasmine",
],
)

jasmine_node_test(
name = "preact_test",
deps = [":preact_test_lib"],
)
103 changes: 103 additions & 0 deletions packages/preact/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# `@rules_prerender/preact`

A `@rules_prerender` rendering engine for [Preact](https://preactjs.com/).

NOTE: This project is currently **experimental**. Feel free to install it to try
it out, give feedback, and suggest improvements! Just don't use it in production
quite yet.

## Installation

See [`@rules_prerender` installation instructions](https://github.com/dgp1130/rules_prerender#installation).

Once `@rules_prerender` is set up, install `@rules_prerender/preact` and
`preact`.

```bash
pnpm install preact @rules_prerender/preact --save-dev
```

Depend on this in Bazel just like any other NPM package, typically at
`//:node_modules/@rules_prerender/preact`.

If you are using TypeScript, also go through Preact's
[configuration instructions](https://preactjs.com/guide/v10/typescript/#typescript-configuration).

## Usage

```python
# my_site/BUILD.bazel

load("@aspect_bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin")
load("@rules_prerender//:index.bzl", "prerender_pages")

copy_to_bin(
name = "package",
srcs = ["package.json"],
)

prerender_pages(
name = "my_site",
srcs = ["my_site.tsx"],
# Need a `package.json` with `"type": "module"` to load compiled `*.tsx`
# files at runtime. Also needs to be copied to bin.
data = [":package"],
lib_deps = [
"//:node_modules/@rules_prerender/preact",
# JSX requires a Preact dependency, even if you don't import from it.
"//:node_modules/preact",
],
)
```

```tsx
import { PrerenderResource, renderToHtml } from '@rules_prerender/preact';

export default function* (): Generator<PrerenderResource, void, void> {
yield PrerenderResource.of('/index.html', renderToHtml(
<html>
<head>
<title>My Preact Page</title>
<meta charSet="utf8" />
</head>
<body>
<h2>Hello, World!</h2>
</body>
</html>
));
}
```

Prefer `includeScript()` and `inlineStyle()` from `@rules_prerender/preact`, as
they return `VNodes`.

```tsx
import { includeScript, inlineStyle } from '@rules_prerender/preact';
import { VNode } from 'preact';

export function Component(): VNode {
return <div>
{includeScript('./my_script.mjs', import.meta)}
{inlineStyle('./my_style.css', import.meta)}
</div>;
}
```

Preact doesn't directly support `<template />` elements, so
`@rules_prerender/preact` exports a `Template` component for this purpose. This
is useful for declarative shadow DOM.

```tsx
import { Template } from '@rules_prerender/preact';
import { VNode } from 'preact';

export function Component(): VNode {
return <div>
<Template shadowroot="open">
<div>Hello, World!</div>
<slot></slot>
</Template>
<div>Hello, Mars!</div>
</div>;
}
```
58 changes: 58 additions & 0 deletions packages/preact/index.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { VNode, createElement, ComponentChildren } from 'preact';
import { render } from 'preact-render-to-string';
import * as rulesPrerender from 'rules_prerender';

export { PrerenderResource } from 'rules_prerender';

/**
* Renders the given {@link VNode} to a {@link rulesPrerender.SafeHtml} value.
* The type is `VNode | VNode[]` to make the typings easier to work with, but it
* actually only supports a single {@link VNode} input of an `<html />` tag. The
* returned HTML is prefixed with `<!DOCTYPE html>`.
*
* @param node The {@link VNode} of an `<html />` element to render.
* @returns A {@link rulesPrerender.SafeHtml} object with input node, prefixed
* by `<!DOCTYPE html>`.
*/
export function renderToHtml(node: VNode | VNode[]): rulesPrerender.SafeHtml {
if (Array.isArray(node)) {
throw new Error(`Expected a single \`VNode\` of the \`<html />\` tag, but got an array of \`VNodes\`.`);
}
if (node.type !== 'html') {
throw new Error(`Expected a single \`VNode\` of the \`<html />\` tag, but got a \`<${node.type}>\` tag instead.`);
}

const html = render(node, {}, { pretty: true });
return rulesPrerender.unsafeTreatStringAsSafeHtml(
`<!DOCTYPE html>\n${html}`);
}

/**
* Returns a prerender annotation as a {@link VNode} to be included in
* prerendered HTML. This is used by the prerender build process to include the
* referenced client-side JavaScript file in the final bundle for the page.
*/
export function includeScript(path: string, meta: ImportMeta): VNode {
const annotation =
rulesPrerender.internalIncludeScriptAnnotation(path, meta);
return createElement('rules_prerender:annotation', {}, [ annotation ]);
}

/**
* Returns a prerender annotation as a {@link VNode} to be included in
* prerendered HTML. This is used by the prerender build process to inline the
* referenced CSS file at the annotation's location in the page.
*/
export function inlineStyle(importPath: string, meta: ImportMeta): VNode {
const annotation =
rulesPrerender.internalInlineStyleAnnotation(importPath, meta);
return createElement('rules_prerender:annotation', {}, [ annotation ]);
}

/** A component representing the native HTML `<template />` tag. */
export function Template({ children, ...attrs }: {
children?: ComponentChildren,
[attr: string]: unknown,
} = {}): VNode {
return createElement('template', { children, ...attrs });
}
84 changes: 84 additions & 0 deletions packages/preact/index_test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { createElement } from 'preact';
import { render } from 'preact-render-to-string';
import { renderToHtml, includeScript, inlineStyle, Template } from './index.mjs';
import { serialize } from '../../common/models/prerender_annotation.mjs';

describe('preact', () => {
describe('renderToHtml()', () => {
it('renders the given `VNode`', () => {
const html = renderToHtml(createElement('html', {}, [
createElement('body', {}, [
createElement('h2', {}, [
'Hello, World!',
]),
]),
]));

expect(html.getHtmlAsString())
.toContain(`<h2>Hello, World!</h2>`.trim());
});

it('renders the given `VNode` in HTML5', () => {
const html = renderToHtml(createElement('html', {}));
expect(html.getHtmlAsString().slice(0, '<!DOCTYPE html>'.length))
.toBe('<!DOCTYPE html>');
});

it('throws an error when given a `VNode` other than an `<html />` element', () => {
expect(() => renderToHtml(createElement('body', {}))).toThrowError(
/Expected a single `VNode` of the `<html \/>` tag/);
});
});

describe('includeScript()', () => {
it('renders a script annotation', () => {
const meta: ImportMeta = {
url: 'file:///bazel/.../execroot/my_wksp/bazel-out/k8-opt/bin/path/to/pkg/prerender.mjs',
};

const result = includeScript('./script.mjs', meta);
expect(render(result)).toBe(`
<rules_prerender:annotation>${
serialize({
type: 'script',
path: 'path/to/pkg/script.mjs',
}).replaceAll('"', '&quot;')
}</rules_prerender:annotation>
`.trim());
});
});

describe('inlineStyle()', () => {
it('renders a style annotation', () => {
const meta: ImportMeta = {
url: 'file:///bazel/.../execroot/my_wksp/bazel-out/k8-opt/bin/path/to/pkg/prerender.mjs',
};

const result = inlineStyle('./style.css', meta);
expect(render(result)).toBe(`
<rules_prerender:annotation>${
serialize({
type: 'style',
path: 'path/to/pkg/style.css',
}).replaceAll('"', '&quot;')
}</rules_prerender:annotation>
`.trim());
});
});

describe('Template', () => {
it('renders a `<template />` element', () => {
const html = render(Template({
shadowroot: 'open',
children: [
createElement('div', {}, [
'Hello, World!',
]),
],
}));

expect(html).toContain('<template shadowroot="open">');
expect(html).toContain('<div>Hello, World!</div>');
});
});
});
13 changes: 13 additions & 0 deletions packages/preact/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@rules_prerender/preact",
"version": "0.0.0-PLACEHOLDER",
"type": "module",
"main": "index.mjs",
"dependencies": {
"preact-render-to-string": "^5.2.6"
},
"peerDependencies": {
"preact": "^10.13.1",
"rules_prerender": "^0.0.0-PLACEHOLDER"
}
}
16 changes: 13 additions & 3 deletions packages/rules_prerender/index.mts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
/** @fileoverview Re-exports public symbols. */

export { PrerenderResource } from '../../common/models/prerender_resource.mjs';
export { includeScript } from './scripts.mjs';
export { inlineStyle, InlineStyleNotFoundError as InternalInlineStyleNotFoundError } from './styles.mjs';
export {
includeScript,
includeScriptAnnotation as internalIncludeScriptAnnotation,
} from './scripts.mjs';
export {
inlineStyle,
inlineStyleAnnotation as internalInlineStyleAnnotation,
InlineStyleNotFoundError as InternalInlineStyleNotFoundError,
} from './styles.mjs';

export { setMap as internalSetInlineStyleMap, resetMapForTesting as internalResetInlineStyleMapForTesting } from './inline_style_map.mjs';
export {
setMap as internalSetInlineStyleMap,
resetMapForTesting as internalResetInlineStyleMapForTesting,
} from './inline_style_map.mjs';

export { SafeHtml, isSafeHtml } from '../../common/safe_html/safe_html.mjs';
export { unsafeTreatStringAsSafeHtml } from '../../common/safe_html/unsafe_html.mjs';
Loading

0 comments on commit b545305

Please sign in to comment.