Skip to content

Commit

Permalink
Updates annotation format to use a <rules_prerender:annotation /> t…
Browse files Browse the repository at this point in the history
…ag instead of a comment.

Refs #71.

Preact can't easily render comments, meaning it is tricky to support `includeScript()` and `inlineStyle()` with Preact when they use comments. Instead, we render `<rules_prerender:annotation>...</rules_prerender:annotation>`. The `rules_prerender:` technically makes it an XML namespace, though without any explicit declaration. It should be fine since these elements never make it to the browser. Since this is no a full `HTMLElement` and not a `Node`, we can simplify some of the implementation and drop `UpdateableNode`.

For now, I left the actual JSON content in the tag's content. We no longer really need the `RULES_PRERENDER_DO_NOT_DEPEND_OR_ELSE` prefix given that `rules_prerender:annotation` already identifies the relevant tag. I might remove that in a future commit. Later on we might also want to support multiple child nodes for something like SSR. Of course it might be better to just escape the HTML content inside the element.

Most of the changes here are adjusting tests, the actual change in `packages/rules_prerender/{scripts,styles}.mts` is relatively straightforward and just required some updates to `prerender_annotation_walker.mts` to extract the new format.
  • Loading branch information
dgp1130 committed Mar 12, 2023
1 parent 22e61e3 commit a40f86c
Show file tree
Hide file tree
Showing 14 changed files with 228 additions and 317 deletions.
142 changes: 28 additions & 114 deletions common/prerender_annotation_walker.mts
Original file line number Diff line number Diff line change
@@ -1,141 +1,55 @@
import { CommentNode, HTMLElement, Node } from 'node-html-parser';
import { HTMLElement } from 'node-html-parser';
import { parseAnnotation, PrerenderAnnotation } from './models/prerender_annotation.mjs';

/**
* A reference to a `node-html-parser` `Node` which contains a
* {@link PrerenderAnnotation} and can be updated (removed from / replaced in the tree).
*/
export class AnnotationNode {
export interface AnnotationEl {
/** The annotation this node contains. */
public annotation: PrerenderAnnotation;
annotation: PrerenderAnnotation;

private updateableNode: UpdateableNode<CommentNode>;

private constructor({ annotation, updateableNode }: {
annotation: PrerenderAnnotation,
updateableNode: UpdateableNode<CommentNode>,
}) {
this.annotation = annotation;
this.updateableNode = updateableNode;
}

/** Returns an `AnnotationNode` for the given annotation and its node. */
public static from(
annotation: PrerenderAnnotation,
updateableNode: UpdateableNode<CommentNode>,
): AnnotationNode {
return new AnnotationNode({ annotation, updateableNode });
}

/**
* Removes this annotation's node from the tree.
* @throws if the annotation's node has already been updated.
*/
public remove(): void {
this.updateableNode.remove();
}

/**
* Replaces this annotation's node in the DOM tree with the given node.
* @throws if the node has already been updated.
*/
public replace(replacement: Node): void {
this.updateableNode.replace(replacement);
}
}

/**
* A reference to a `node-html-parser` `Node` which can be updated (removed / replaced).
* This is useful a `Node` cannot normally be removed or replaced without also knowing
* its parent, which can be annoying to track.
*/
class UpdateableNode<T extends Node> {
/** The `node-html-parser` `Node` this references. */
public node: T;

private parent: HTMLElement;
private updated = false;

private constructor({ node, parent }: { node: T, parent: HTMLElement }) {
this.node = node;
this.parent = parent;
}

/** Returns an `UpdateableNode` for the given node and its parent. */
public static from<T extends Node>(
{ node, parent }: { node: T, parent: HTMLElement },
): UpdateableNode<T> {
return new UpdateableNode({ node, parent });
}

/**
* Removes this node from the tree.
* @throws if the node has already been updated.
*/
public remove(): void {
if (this.updated) throw new Error(`Node was already updated, cannot remove it.`);
this.updated = true;

this.parent.removeChild(this.node);
}

/**
* Replaces this node in the DOM tree with the given node.
* @throws if the node has already been updated.
*/
public replace(replacement: Node): void {
if (this.updated) throw new Error(`Node was already updated, cannot replace it.`);
this.updated = true;

this.parent.exchangeChild(this.node, replacement);
}
/** The element containing the annotation. */
el: HTMLElement;
}

/** Returns a {@link Generator} of all annotations in the tree. */
export function walkAllAnnotations(root: HTMLElement):
Generator<AnnotationNode, void, void> {
return walkAnnotations(walkComments(walk(root)));
Generator<AnnotationEl, void, void> {
return walkAnnotations(walk(root));
}

/**
* Parses the given {@link Generator} of {@link CommentNode}s, filtering out any which
* aren't annotations and returning a new {@link Generator} of {@link AnnotationNode}.
* Parses the given {@link Generator} of {@link HTMLElement}s, filtering
* out any which aren't annotations and returning a new {@link Generator} of
* {@link AnnotationEl}.
*/
function* walkAnnotations(
nodes: Generator<UpdateableNode<CommentNode>, void, void>,
): Generator<AnnotationNode, void, void> {
for (const updateableNode of nodes) {
const annotation = parseAnnotation(updateableNode.node.text);
if (!annotation) continue;
function* walkAnnotations(els: Generator<HTMLElement, void, void>):
Generator<AnnotationEl, void, void> {
for (const el of els) {
// Root element has a `null` `tagName`.
if (el.tagName?.toLowerCase() !== 'rules_prerender:annotation') continue;

yield AnnotationNode.from(annotation, updateableNode);
}
}
// Parse the annotation.
const annotation = parseAnnotation(el.textContent);
if (!annotation) throw new Error(`Failed to parse annotation:\n${el.outerHTML}`);

/**
* Filters the given {@link Generator} of {@link Node}s to limit to only
* {@link CommentNode}s.
*/
function* walkComments(nodes: Generator<UpdateableNode<Node>, void, void>):
Generator<UpdateableNode<CommentNode>> {
for (const updateableNode of nodes) {
if (updateableNode.node instanceof CommentNode) {
yield updateableNode;
}
yield { annotation, el };
}
}

/**
* Returns a {@link Generator} of {@link UpdateableNode}s for each descendant of the
* given root and their parent. The root node without a parent is dropped.
* Returns a {@link Generator} of {@link UpdateableElement}s for each descendant
* of the given root and their parent. The root node without a parent is
* dropped.
*/
function* walk(node: Node, parent?: HTMLElement):
Generator<UpdateableNode<Node>, void, void> {
if (parent) yield UpdateableNode.from({ node, parent });
function* walk(el: HTMLElement): Generator<HTMLElement, void, void> {
yield el;

if (node instanceof HTMLElement) {
for (const child of node.childNodes) {
yield* walk(child, node);
for (const child of el.childNodes) {
// Ignore `Nodes`, all annotations are `HTMLElements`.
if (child instanceof HTMLElement) {
yield* walk(child);
}
}
}
80 changes: 21 additions & 59 deletions common/prerender_annotation_walker_test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ describe('prerender_annotation_walker', () => {
<title>Test Page</title>
</head>
<body>
<!-- ${annotation1} -->
<!-- ${annotation2} -->
<!-- ${annotation3} -->
<rules_prerender:annotation>${annotation1}</rules_prerender:annotation>
<rules_prerender:annotation>${annotation2}</rules_prerender:annotation>
<rules_prerender:annotation>${annotation3}</rules_prerender:annotation>
</body>
</html>
`.trim(), { comment: true });
`.trim());

const annotations = Array.from(walkAllAnnotations(root))
.map((node) => node.annotation);
Expand All @@ -42,15 +42,15 @@ describe('prerender_annotation_walker', () => {
<title>Test Page</title>
</head>
<body>
<!-- ${annotation} -->
<rules_prerender:annotation>${annotation}</rules_prerender:annotation>
</body>
</html>
`.trim(), { comment: true });
`.trim());

const nodes = Array.from(walkAllAnnotations(root));
expect(nodes.length).toBe(1);
const node = nodes[0]!;
node.remove();
const annotationEls = Array.from(walkAllAnnotations(root));
expect(annotationEls.length).toBe(1);
const { el } = annotationEls[0]!;
el.remove();

const extracted = root.toString();
expect(extracted).toBe(`
Expand All @@ -76,15 +76,15 @@ describe('prerender_annotation_walker', () => {
<title>Test Page</title>
</head>
<body>
<!-- ${annotation} -->
<rules_prerender:annotation>${annotation}</rules_prerender:annotation>
</body>
</html>
`.trim(), { comment: true });
`.trim());

const nodes = Array.from(walkAllAnnotations(root));
expect(nodes.length).toBe(1);
const node = nodes[0]!;
node.replace(new HTMLElement(
const annotationEls = Array.from(walkAllAnnotations(root));
expect(annotationEls.length).toBe(1);
const { el } = annotationEls[0]!;
el.replaceWith(new HTMLElement(
'script' /* tagName */,
{} /* keyAttrs */,
'' /* rawAttrs */,
Expand All @@ -106,59 +106,21 @@ describe('prerender_annotation_walker', () => {
`.trim());
});

it('throws an error when a node is removed after being updated', () => {
const annotation = createAnnotation({ type: 'script', path: 'wksp/foo.js' });

it('throws an error when given an invalid annotation', () => {
const root = parse(`
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<!-- ${annotation} -->
<rules_prerender:annotation>not an annotation</rules_prerender:annotation>
</body>
</html>
`.trim(), { comment: true });

const nodes = Array.from(walkAllAnnotations(root));
expect(nodes.length).toBe(1);
const node = nodes[0]!;
node.remove(); // Update the node.

// Try to update it again.
expect(() => node.remove())
.toThrowError('Node was already updated, cannot remove it.');
});

it('throws an error when a node is replaced after being updated', () => {
const annotation = createAnnotation({ type: 'script', path: 'wksp/foo.js' });

const root = parse(`
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<!-- ${annotation} -->
</body>
</html>
`.trim(), { comment: true });

const nodes = Array.from(walkAllAnnotations(root));
expect(nodes.length).toBe(1);
const node = nodes[0]!;
node.remove(); // Update the node.
`.trim());

// Try to update it again.
expect(() => node.replace(new HTMLElement(
'script' /* tagName */,
{} /* keyAttrs */,
'' /* rawAttrs */,
null /* parentNode */,
[0, 0] /* range */,
))).toThrowError('Node was already updated, cannot replace it.');
expect(() => Array.from(walkAllAnnotations(root)))
.toThrowError(/Failed to parse annotation/);
});
});
});
3 changes: 2 additions & 1 deletion examples/site/components/footer/footer_test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ describe('footer', () => {
.split('\n')
.map((line) => line.trim())
.join(' ');
expect(footerText).toBe('Made with Bazel and rules_prerender.');
expect(footerText)
.toContain('Made with Bazel and rules_prerender.');
});
});
});
13 changes: 9 additions & 4 deletions packages/declarative_shadow_dom/declarative_shadow_dom_test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import { polyfillDeclarativeShadowDom } from './declarative_shadow_dom.mjs';
describe('declarative_shadow_dom', () => {
describe('polyfillDeclarativeShadowDom()', () => {
it('returns an annotation to include the declarative shadow DOM polyfill', () => {
expect(polyfillDeclarativeShadowDom()).toBe(`<!-- ${createAnnotation({
type: 'script',
path: 'packages/declarative_shadow_dom/declarative_shadow_dom_polyfill.mjs',
})} -->`);
expect(polyfillDeclarativeShadowDom())
.toBe(`
<rules_prerender:annotation>${
createAnnotation({
type: 'script',
path: 'packages/declarative_shadow_dom/declarative_shadow_dom_polyfill.mjs',
})
}</rules_prerender:annotation>
`.trim());
});
});
});
4 changes: 3 additions & 1 deletion packages/rules_prerender/scripts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ export function includeScript(filePath: string, meta: ImportMeta): string {
type: 'script',
path: resolved,
});
return `<!-- ${annotation} -->`;
return `<rules_prerender:annotation>${
annotation
}</rules_prerender:annotation>`;
}
36 changes: 24 additions & 12 deletions packages/rules_prerender/scripts_test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ describe('scripts', () => {
};
const annotation = includeScript('./baz.mjs', meta);

expect(annotation).toBe(`<!-- ${createAnnotation({
type: 'script',
path: 'path/to/pkg/baz.mjs',
})} -->`);
expect(annotation).toBe(`
<rules_prerender:annotation>${
createAnnotation({
type: 'script',
path: 'path/to/pkg/baz.mjs',
})
}</rules_prerender:annotation>
`.trim());
});

it('creates an annotation for a script in a sub directory of the given `import.meta`', () => {
Expand All @@ -21,10 +25,14 @@ describe('scripts', () => {
};
const annotation = includeScript('./some/subdir/foo.mjs', meta);

expect(annotation).toBe(`<!-- ${createAnnotation({
type: 'script',
path: 'path/to/pkg/some/subdir/foo.mjs',
})} -->`);
expect(annotation).toBe(`
<rules_prerender:annotation>${
createAnnotation({
type: 'script',
path: 'path/to/pkg/some/subdir/foo.mjs',
})
}</rules_prerender:annotation>
`.trim());
});

it('creates an annotation for a script in a parent directory of the given `import.meta`', () => {
Expand All @@ -33,10 +41,14 @@ describe('scripts', () => {
};
const annotation = includeScript('../../foo.mjs', meta);

expect(annotation).toBe(`<!-- ${createAnnotation({
type: 'script',
path: 'path/foo.mjs',
})} -->`);
expect(annotation).toBe(`
<rules_prerender:annotation>${
createAnnotation({
type: 'script',
path: 'path/foo.mjs',
})
}</rules_prerender:annotation>
`.trim());
});

it('creates annotations for relative paths with common JS extensions', () => {
Expand Down
Loading

0 comments on commit a40f86c

Please sign in to comment.