├── .github
└── workflows
│ ├── release.yml
│ ├── test.yml
│ └── version.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── lib
├── binding.js
├── codegen.js
├── compiler-shared.js
├── compiler.js
├── deps.js
├── end-of-head.js
├── escape.js
├── hydration.d.ts
├── hydration.js
├── mod.d.ts
├── mod.js
├── ocean.d.ts
├── ocean.js
├── optimize.js
├── parts.js
├── serialize.js
├── shared.js
├── shim-lit.js
├── shim-stencil.js
└── shim.js
├── readme.md
├── test.sh
└── test
├── deps.js
├── helpers.js
├── hydration-idle.test.js
├── hydration-load.test.js
├── hydration-media.test.js
├── hydration-visible.test.js
├── libraries
├── atomico.test.js
├── grim.test.js
├── haunted.test.js
├── lit.test.js
├── petite-vue.test.js
├── preact.test.js
├── prism.test.js
├── stencil-el
│ ├── index.esm.js
│ ├── p-3e6d962c.js
│ ├── p-952c5330.entry.js
│ └── stencil-el.esm.js
├── stencil.test.js
├── uce.test.js
└── wafer.test.js
├── plugins.test.js
├── polyfill.test.js
├── relative.test.js
├── render.test.js
├── worker.html
├── worker.js
└── worker.test.js
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Deploy version
2 |
3 | on:
4 | push:
5 | tags: v*.*.*
6 |
7 | jobs:
8 | deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Check out repository code
12 | uses: actions/checkout@v2
13 | - name: Use Deno
14 | uses: denoland/setup-deno@v1
15 | with:
16 | deno-version: v1.x
17 | - name: Bundle
18 | run: deno bundle lib/mod.js lib/mod.bundle.js
19 | - name: Publish
20 | uses: matthewp/cdn-spooky-deploy-action@v3.beta.2
21 | with:
22 | key_id: ${{ secrets.AWS_ACCESS_KEY_ID }}
23 | access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY}}
24 | pkg: 'ocean'
25 | source: 'lib'
26 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Testing
2 |
3 | on: [push]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v1
11 | - name: Use Deno
12 | uses: denoland/setup-deno@v1
13 | with:
14 | deno-version: v1.13.2
15 | - name: Bundle
16 | run: deno bundle lib/mod.js lib/mod.bundle.js
17 | - name: Install Puppeteer
18 | run: PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/puppeteer@9.0.1/install.ts
19 | - name: Run the tests
20 | run: ./test.sh all
--------------------------------------------------------------------------------
/.github/workflows/version.yml:
--------------------------------------------------------------------------------
1 |
2 | name: Create version
3 |
4 | on:
5 | workflow_dispatch:
6 | inputs:
7 | version:
8 | description: 'what version are you creating (ex: 1.0.0)'
9 | required: true
10 |
11 | jobs:
12 | version:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Check out repository code
16 | uses: actions/checkout@v2
17 | - name: Use Deno
18 | uses: denoland/setup-deno@v1
19 | with:
20 | deno-version: v1.x
21 | - name: Create Tag
22 | run: |
23 | git config user.email "matthew@matthewphillips.info"
24 | git config user.name "Matthew Phillips"
25 | deno run --allow-read --allow-write --allow-run \
26 | https://cdn.spooky.click/spooky-release/0.0.6/cmd.js \
27 | --pkg ocean --version ${{ github.event.inputs.version }} --files readme.md
28 | git push origin v${{ github.event.inputs.version }}
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lib/mod.bundle.js
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to this repository
2 |
3 | __Ocean__ is a runtime agnostic library for rendering web component code to HTML. However the library is developed using [Deno](https://deno.land/) tooling.
4 |
5 | ## Prerequisites
6 |
7 | 1. A recent version of [Deno](https://deno.land/#installation).
8 | 2. That's it! Deployment happens in CI so you don't need anything special for that.
9 |
10 | ## Creating a release
11 |
12 | Ocean is hosted on a custom CDN that is deployed in a GitHub Action whenever a *tag* is created.
13 |
14 | You can create a new version through the GitHub Actions UI if you have the right permissions.
15 |
16 | 1. Go to the __Actions__ tab in the *matthewp/ocean* repo.
17 | 2. Select __Create version__ from the left side.
18 |
19 | 
20 |
21 | 3. Find the __Run workflow__ drop down and click it.
22 |
23 | 
24 |
25 | 4. Type in the version you want to create and then click __Run workflow__.
26 |
27 | 
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 2-Clause License
2 |
3 | Copyright (c) 2021, Matthew Phillips
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 |
--------------------------------------------------------------------------------
/lib/binding.js:
--------------------------------------------------------------------------------
1 |
2 | export class TextBinding {
3 | set(node, val) {
4 | node.data = val;
5 | }
6 | }
7 |
8 | export class AttributeBinding {
9 | constructor(name) {
10 | this.name = name;
11 | }
12 | set(node, val) {
13 | node.setAttribute(this.name, val);
14 | }
15 | }
16 |
17 | export class PropertyBinding {
18 | constructor(name) {
19 | this.name = name;
20 | }
21 | set(node, val) {
22 | Reflect.set(node, this.name, val);
23 | }
24 | }
--------------------------------------------------------------------------------
/lib/codegen.js:
--------------------------------------------------------------------------------
1 | import { ComponentPart, URLContextPart, TextPart } from './parts.js';
2 | import { nonClosingElements } from './shared.js';
3 | import {
4 | commentPlaceholder,
5 | urlContextPrefix,
6 | prefix
7 | } from './compiler-shared.js';
8 | import { escapeAttributeValue } from './escape.js';
9 |
10 | const interpolationExp = new RegExp(commentPlaceholder + '|' +
11 | ``, 'g');
12 |
13 | function htmlValue(htmlStr) {
14 | return {
15 | type: 'html',
16 | value: htmlStr
17 | };
18 | }
19 |
20 | function * holeValue(state) {
21 | yield {
22 | type: 'hole'
23 | };
24 | state.i++;
25 | }
26 |
27 | function * urlContextValue(path) {
28 | yield {
29 | type: 'url-context',
30 | value: path
31 | };
32 | }
33 |
34 | function * multiInterpolation(str, state) {
35 | interpolationExp.lastIndex = 0;
36 | let match = interpolationExp.exec(str);
37 | let strIndex = 0;
38 | while(match) {
39 | let html = str.substr(strIndex, match.index - strIndex);
40 | yield htmlValue(html);
41 | let matchedPoint = match[0];
42 | if(matchedPoint === commentPlaceholder) {
43 | yield * holeValue(state);
44 | } else if(matchedPoint.includes(urlContextPrefix)) {
45 | yield * urlContextValue(match[1]);
46 | }
47 | strIndex = match.index + matchedPoint.length;
48 | match = interpolationExp.exec(str);
49 | }
50 | let html = str.substr(strIndex);
51 | yield htmlValue(html);
52 |
53 | /*let insertions = str.split(commentPlaceholder);
54 | let i = 0, len = insertions.length;
55 | do {
56 | if(i > 0) {
57 | yield * holeValue(state);
58 | }
59 | yield htmlValue(insertions[i]);
60 | } while(++i < len);
61 | */
62 | }
63 |
64 | function * walkFragment(frag, state) {
65 | for(let node of frag.childNodes) {
66 | yield * walk(node, state);
67 | }
68 | }
69 |
70 | function * walkElement(node, state) {
71 | let customElements = node.ownerDocument.defaultView.customElements;
72 | if(customElements.get(node.localName)) {
73 | yield {
74 | type: 'component',
75 | value: node
76 | }
77 | return;
78 | }
79 |
80 | yield htmlValue(`<${node.localName}`);
81 | for(let {name, value} of node.attributes) {
82 | if(value === '') {
83 | yield htmlValue(` ${name}`);
84 | } else {
85 | yield htmlValue(` ${name}="`);
86 | let escaped = escapeAttributeValue(value);
87 | yield * multiInterpolation(escaped, state);
88 | yield htmlValue('"');
89 | }
90 | }
91 | yield htmlValue(`>`);
92 | for(let child of node.childNodes) {
93 | yield * walk(child, state);
94 | }
95 | if(!nonClosingElements.has(node.localName)) {
96 | yield htmlValue(`${node.localName}>`);
97 | }
98 | }
99 |
100 | function * walk(entryNode, state) {
101 | let node = entryNode;
102 |
103 | switch(node.nodeType) {
104 | // Element
105 | case 1: {
106 | yield * walkElement(node, state);
107 | break;
108 | }
109 | // Text Node
110 | case 3: {
111 | //
for some reason only has text children
112 | yield * multiInterpolation(node.data, state);
113 | break;
114 | }
115 | // Comment Node
116 | case 8: {
117 | if(node.data === prefix) {
118 | yield * holeValue(state);
119 | } else if(node.data === urlContextPrefix) {
120 | yield * urlContextValue();
121 | } else {
122 | yield htmlValue(``);
123 | }
124 |
125 | break;
126 | }
127 | // DocumentFragment
128 | case 11: {
129 | yield * walkFragment(node, state);
130 | break;
131 | }
132 | }
133 | }
134 |
135 | export class Codegen {
136 | createTemplates(frag, doctype) {
137 | let templates = [];
138 | let buffer = '';
139 |
140 | function closeBuffer(state) {
141 | if(buffer) {
142 | templates.push(new TextPart(buffer, state.li, state.i));
143 | buffer = '';
144 | }
145 | }
146 |
147 | let state = { i: 0, li: 0 };
148 | for(let { type, value } of walk(frag, state)) {
149 | switch(type) {
150 | case 'html': {
151 | buffer += value;
152 | break;
153 | }
154 | case 'hole': {
155 | templates.push(new TextPart(buffer, state.i, state.i + 1));
156 | buffer = '';
157 | break;
158 | }
159 | case 'component': {
160 | closeBuffer(state);
161 | templates.push(new ComponentPart(value, state));
162 | break;
163 | }
164 | case 'url-context': {
165 | closeBuffer(state);
166 | templates.push(new URLContextPart(value));
167 | break;
168 | }
169 | }
170 | state.li = state.i;
171 | }
172 |
173 | if(buffer) {
174 | templates.push(new TextPart(buffer, state.i + 1));
175 | }
176 |
177 | if(doctype.match && templates[0] instanceof TextPart) {
178 | templates[0].addDoctype(doctype);
179 | }
180 | return templates;
181 | }
182 | }
--------------------------------------------------------------------------------
/lib/compiler-shared.js:
--------------------------------------------------------------------------------
1 | export const prefix = 'öcean';
2 | export const commentPlaceholder = ``;
3 | export const urlContextPrefix = `${prefix}-url-context`;
4 | export const urlContextCommentPlaceholder = p => ``;
--------------------------------------------------------------------------------
/lib/compiler.js:
--------------------------------------------------------------------------------
1 | import { outdent } from './deps.js';
2 | import { Codegen } from './codegen.js';
3 | import { Optimizer } from './optimize.js';
4 | import { commentPlaceholder } from './compiler-shared.js';
5 |
6 | class Template {
7 | constructor(parts) {
8 | this.parts = parts;
9 | }
10 | async * render(values, context) {
11 | let parts = this.parts;
12 |
13 | for(let part of parts) {
14 | let partValues = values.slice(part.start, part.end);
15 | yield * part.render(partValues, context);
16 | }
17 | }
18 | }
19 |
20 | class Doctype {
21 | constructor(raw) {
22 | this.raw = raw;
23 | this.match = /()/i.exec(raw);
24 | this.source = this.match ? this.match[0] : null;
25 | this.start = this.match ? this.match.index : null;
26 | this.end = this.match ? this.match.index + this.source.length : null;
27 | }
28 | remove() {
29 | if(!this.match) {
30 | return this.raw;
31 | }
32 | return this.raw.slice(0, this.start) + this.raw.slice(this.end);
33 | }
34 | replace(part) {
35 | if(!this.match) {
36 | return part;
37 | }
38 | return part.slice(0, this.start) + this.source + part.slice(this.start);
39 | }
40 | }
41 |
42 | export class Compiler {
43 | constructor(opts) {
44 | this.document = opts.document;
45 | this.polyfillURL = opts.polyfillURL;
46 | this.optimizer = new Optimizer({
47 | document: this.document,
48 | elements: opts.elements,
49 | hydrator: opts.hydrator,
50 | plugins: opts.plugins,
51 | settings: opts.settings
52 | });
53 | this.codegen = new Codegen();
54 | }
55 |
56 | compile(parts, values) {
57 | let document = this.document;
58 | let replacedValues = Array.from({ length: values.length }, _ => commentPlaceholder);
59 | let raw = outdent(parts, ...replacedValues);
60 | let doctype = new Doctype(raw);
61 | raw = doctype.remove();
62 |
63 | let div = document.createElement('div');
64 | div.innerHTML = raw;
65 | let frag = document.createDocumentFragment();
66 | frag.append(...div.childNodes);
67 |
68 | this.optimizer.optimize(frag);
69 | let templates = this.codegen.createTemplates(frag, doctype);
70 |
71 | return new Template(templates);
72 | }
73 | }
--------------------------------------------------------------------------------
/lib/deps.js:
--------------------------------------------------------------------------------
1 | export { outdent } from 'https://deno.land/x/outdent@v0.8.0/mod.ts';
2 | export { relative as urlRelative } from 'https://cdn.spooky.click/url-relative/1.0.0/mod.ts';
--------------------------------------------------------------------------------
/lib/end-of-head.js:
--------------------------------------------------------------------------------
1 | import { elementsBeforeBody } from './shared.js';
2 |
3 | export class EndOfHead {
4 | constructor(document) {
5 | this.ownerDocument = document;
6 | this.head = null;
7 | this.firstNonHead = null;
8 | this.foundElementsBeforeBody = false;
9 | }
10 |
11 | get found() {
12 | return !!(this.head || this.firstNonHead);
13 | }
14 |
15 | find(root) {
16 | let doc = root.ownerDocument;
17 | let walker = doc.createTreeWalker(frag, 133, null, false);
18 | let currentNode = root;
19 | while(currentNode) {
20 | if(this.visit(node)) {
21 | break;
22 | }
23 | currentNode = walker.nextNode();
24 | }
25 | }
26 |
27 | visit(node) {
28 | if(this.found || node.nodeType !== 1) {
29 | return;
30 | }
31 | let name = node.localName;
32 | if(node.localName === 'head') {
33 | this.head = node;
34 | return true;
35 | }
36 | if(!elementsBeforeBody.has(name)) {
37 | this.firstNonHead = node;
38 | return true;
39 | } else {
40 | this.foundElementsBeforeBody = true;
41 | }
42 | return false;
43 | }
44 |
45 | // Inject a node at the end of the head element
46 | append(node) {
47 | if(this.head) {
48 | this.head.insertBefore(node, this.head.lastChild);
49 | } else if(this.firstNonHead) {
50 | this.firstNonHead.parentNode.insertBefore(node, this.firstNonHead);
51 | }
52 | }
53 | }
--------------------------------------------------------------------------------
/lib/escape.js:
--------------------------------------------------------------------------------
1 | // From https://github.com/WebReflection/linkedom/blob/a2347651a9c6bc44c272af0c9fd83f4931cbab2d/esm/shared/text-escaper.js
2 | const {replace} = '';
3 |
4 | // escape
5 | const ca = /[<>&\xA0]/g;
6 |
7 | const esca = {
8 | '\xA0': ' ',
9 | '&': '&',
10 | '<': '<',
11 | '>': '>'
12 | };
13 |
14 | const pe = m => esca[m];
15 |
16 | /**
17 | * Safely escape HTML entities such as `&`, `<`, `>` only.
18 | * @param {string} es the input to safely escape
19 | * @returns {string} the escaped input, and it **throws** an error if
20 | * the input type is unexpected, except for boolean and numbers,
21 | * converted as string.
22 | */
23 | export const escapeHTML = es => replace.call(es, ca, pe);
24 |
25 | export const escapeAttributeValue = value => value.replace(/"/g, '"')
--------------------------------------------------------------------------------
/lib/hydration.d.ts:
--------------------------------------------------------------------------------
1 | export interface Hydrator {
2 | condition: string;
3 | tagName: string;
4 | renderMultiple: boolean;
5 | script(): string;
6 | }
7 |
8 | export declare class HydrateLoad {
9 | condition: 'load';
10 |
11 | constructor();
12 | }
13 |
14 | export declare class HydrateIdle implements Hydrator {
15 | condition: 'idle';
16 | tagName: string;
17 | renderMultiple: boolean;
18 |
19 | constructor(tagName: string);
20 | script(): string;
21 | }
22 |
23 | export declare class HydrateMedia implements Hydrator {
24 | condition: 'media';
25 | tagName: string;
26 | renderMultiple: boolean;
27 |
28 | constructor(tagName: string, mediaAttr: string);
29 | script(): string;
30 | }
31 |
32 | export declare class HydrateVisible implements Hydrator {
33 | condition: 'visible';
34 | tagName: string;
35 | renderMultiple: boolean;
36 |
37 | constructor(tagName: string);
38 | script(): string;
39 | }
--------------------------------------------------------------------------------
/lib/hydration.js:
--------------------------------------------------------------------------------
1 | ///
2 | import { urlContextCommentPlaceholder } from './compiler-shared.js';
3 |
4 | export class HydrateLoad {
5 | constructor() {
6 | this.condition = 'load';
7 | }
8 |
9 | inject(head, _tagName, src) {
10 | let script = head.ownerDocument.createElement('script');
11 | script.setAttribute('src', urlContextCommentPlaceholder(src));
12 | script.setAttribute('type', 'module');
13 | head.append(script);
14 | }
15 | }
16 |
17 | export class HydrateIdle {
18 | constructor(tagName = 'ocean-hydrate-idle') {
19 | this.tagName = tagName;
20 |
21 | this.condition = 'idle';
22 | this.renderMultiple = false;
23 | }
24 | script() {
25 | return /* js */ `customElements.define("${this.tagName}",class extends HTMLElement{connectedCallback(){let e=this.getAttribute("src");this.parentNode.removeChild(this),requestIdleCallback((()=>import(e)))}});`;
26 | }
27 | }
28 |
29 | export class HydrateMedia {
30 | constructor(tagName = 'ocean-hydrate-media', mediaAttr = 'ocean-query') {
31 | this.tagName = tagName;
32 | this.mediaAttr = mediaAttr;
33 | this.condition = 'media';
34 | this.renderMultiple = true;
35 | }
36 | keys(node) {
37 | return [node.getAttribute(this.mediaAttr)];
38 | }
39 | mutate(hydrationEl, node) {
40 | let query = node.getAttribute(this.mediaAttr);
41 | hydrationEl.setAttribute('query', query);
42 | }
43 | script() {
44 | return /* js */ `customElements.define("${this.tagName}",class extends HTMLElement{connectedCallback(){let e=this.getAttribute("src");this.parentNode.removeChild(this);let t=matchMedia(this.getAttribute("query")),a=()=>import(e);t.matches?a():t.addEventListener("change",a,{once:!0})}});`;
45 | }
46 | }
47 |
48 | export class HydrateVisible {
49 | constructor(tagName = 'ocean-hydrate-visible') {
50 | this.tagName = tagName;
51 | this.condition = 'visible';
52 | this.renderMultiple = true;
53 | }
54 | script() {
55 | return /* js */ `customElements.define("${this.tagName}",class extends HTMLElement{connectedCallback(){let e=this.getAttribute("src"),t=this.previousElementSibling;this.parentNode.removeChild(this);let s=new IntersectionObserver((([t])=>{t.isIntersecting&&(s.disconnect(),import(e))}));s.observe(t)}});`;
56 | }
57 | }
58 |
59 | function validate(hydrators) {
60 | for(let hydrator of hydrators) {
61 | if(hydrator.tagName) {
62 | if(!('condition' in hydrator)) {
63 | throw new Error("This hydrator needs a 'condition' property.");
64 | }
65 | if(!('renderMultiple' in hydrator)) {
66 | throw new Error("This hydrator is missing the required 'renderMultiple' property.");
67 | }
68 | } else if(hydrator.inject) {
69 |
70 | } else {
71 | throw new Error('Unrecognized hydrator format');
72 | }
73 | }
74 | return hydrators;
75 | }
76 |
77 | const defaultHydrators = Object.freeze([HydrateIdle, HydrateLoad, HydrateMedia, HydrateVisible]);
78 |
79 | function getHydrators(hydrationMethod, providedHydrators = []) {
80 | switch(hydrationMethod) {
81 | case 'none': {
82 | return [];
83 | }
84 | case 'partial': {
85 | let hydrators = providedHydrators.length ? providedHydrators : defaultHydrators.map(Hydrator => new Hydrator());
86 | return validate(hydrators);
87 | }
88 | case 'full': {
89 | return [HydrateLoad];
90 | }
91 | default: {
92 | throw new Error(`Invalid hydration method [${method}]`);
93 | }
94 | }
95 | }
96 |
97 | function makeKey(...strings) {
98 | return strings.map(s => '[' + s + ']').join('-');
99 | }
100 |
101 | class TemplateHydration {
102 | constructor(hydration) {
103 | this.hydration = hydration;
104 | this.scripts = new Set();
105 | this.elementCache = new Set();
106 | }
107 |
108 | async addScript(hydrator, head, node) {
109 | let doc = node.ownerDocument;
110 | let script = doc.createElement('script');
111 | let code = hydrator.script();
112 | if(code.includes('\n')) {
113 | throw new Error('Hydrators must produce minified scripts.');
114 | }
115 | script.textContent = code;
116 | head.append(script);
117 | }
118 |
119 | addElement(hydrator, node, src) {
120 | let doc = node.ownerDocument;
121 | let hydrationEl = doc.createElement(hydrator.tagName);
122 | hydrationEl.setAttribute('src', urlContextCommentPlaceholder(src));
123 | if(hydrator.mutate) {
124 | hydrator.mutate(hydrationEl, node);
125 | }
126 | node.after(hydrationEl);
127 | }
128 |
129 | computeElementCacheKey(hydrator, node) {
130 | let keyParts = [hydrator.tagName, node.localName];
131 | if('keys' in hydrator) {
132 | keyParts.push(...hydrator.keys(node));
133 | }
134 | let cacheKey = makeKey(...keyParts);
135 | return cacheKey;
136 | }
137 |
138 | run(hydrator, head, node, src) {
139 | if(hydrator.tagName) {
140 | let condition = hydrator.condition;
141 |
142 | let cacheKey = this.computeElementCacheKey(hydrator, node);
143 | if(hydrator.renderMultiple || !this.elementCache.has(cacheKey)) {
144 | this.addElement(hydrator, node, src);
145 | this.elementCache.add(cacheKey);
146 | }
147 |
148 | if(!this.scripts.has(condition)) {
149 | this.addScript(hydrator, head, node);
150 | this.scripts.add(condition);
151 | }
152 | } else if(hydrator.inject) {
153 | hydrator.inject(head, node.localName, src);
154 | }
155 | }
156 |
157 | handle(head, node, src) {
158 | let { hydrationAttr, hydratorMap, hydrators, method } = this.hydration;
159 |
160 | switch(method) {
161 | case 'full': {
162 | this.run(hydrators[0], head, node, src);
163 | break;
164 | }
165 | case 'partial': {
166 | let condition = node.getAttribute(hydrationAttr);
167 | let hydrator = hydratorMap.get(condition);
168 | if(!hydrator) {
169 | throw new Error(`No hydrator provided for [${condition}]`);
170 | }
171 | node.removeAttribute(hydrationAttr);
172 | this.run(hydrator, head, node, src);
173 | break;
174 | }
175 | }
176 | }
177 | }
178 |
179 | export class Hydration {
180 | #method;
181 |
182 | constructor(hydrationMethod, hydrationAttr, providedHydrators) {
183 | this.hydrationAttr = hydrationAttr || 'ocean-hydrate';
184 | this.method = hydrationMethod || 'partial';
185 | this.hydrators = getHydrators(this.method, providedHydrators);
186 | this.hydratorMap = new Map(this.hydrators.filter(h => h.condition).map(h => [h.condition, h]));
187 | }
188 | set method(val) {
189 | this.#method = val;
190 | }
191 | get method() {
192 | return this.#method;
193 | }
194 | createInstance() {
195 | return new TemplateHydration(this);
196 | }
197 | }
--------------------------------------------------------------------------------
/lib/mod.d.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | HydrateIdle,
3 | HydrateLoad,
4 | HydrateMedia,
5 | HydrateVisible
6 | } from './hydration.js';
7 |
8 | import type { Ocean } from './ocean.js';
9 |
10 | export {
11 | HydrateIdle,
12 | HydrateLoad,
13 | HydrateMedia,
14 | HydrateVisible,
15 | Ocean
16 | };
--------------------------------------------------------------------------------
/lib/mod.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | export {
4 | HydrateIdle,
5 | HydrateLoad,
6 | HydrateMedia,
7 | HydrateVisible
8 | } from './hydration.js';
9 |
10 | export { Ocean } from './ocean.js';
--------------------------------------------------------------------------------
/lib/ocean.d.ts:
--------------------------------------------------------------------------------
1 | import type { Hydrator } from './hydration.js';
2 |
3 | export interface OceanOptions {
4 | document: any;
5 | hydration: 'full' | 'partial' | 'none';
6 | hydrators: Hydrator[];
7 | polyfillURL: string;
8 | }
9 |
10 | declare class Ocean {
11 | polyfillURL: string;
12 | elements: Map;
13 |
14 | constructor(opts?: OceanOptions);
15 |
16 | html(strings: string[], ...values: any[]): AsyncIterator;
17 | relativeTo(url: URL): (strings: string[], ...values: any[]) => AsyncIterator;
18 | }
19 |
20 | export {
21 | Ocean
22 | }
--------------------------------------------------------------------------------
/lib/ocean.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { Compiler } from './compiler.js';
4 | import { Hydration } from './hydration.js';
5 |
6 | function must(opts, key) {
7 | if(!(key in opts)) {
8 | throw new Error(`The option [${key}] is missing.`);
9 | }
10 | return Reflect.get(opts, key);
11 | }
12 |
13 | export class Ocean {
14 | constructor(opts = {}) {
15 | this.document = must(opts, 'document');
16 | this.plugins = opts.plugins || [];
17 | this.hydrator = new Hydration(opts.hydration, opts.hydrationAttr, opts.hydrators);
18 |
19 | this.templateCache = new WeakMap();
20 | this.elements = new Map();
21 |
22 | this.settings = {
23 | polyfillURL: opts.polyfillURL || null
24 | };
25 | this.compiler = new Compiler({
26 | document: this.document,
27 | elements: this.elements,
28 | hydrator: this.hydrator,
29 | plugins: this.plugins,
30 | settings: this.settings,
31 | });
32 |
33 | this.html = this.html.bind(this);
34 | this.relativeTo = this.relativeTo.bind(this);
35 | }
36 |
37 | set polyfillURL(val) {
38 | this.settings.polyfillURL = val;
39 | }
40 |
41 | get polyfillURL() {
42 | return this.settings.polyfilURL;
43 | }
44 |
45 | #getTemplate(strings, values) {
46 | let template;
47 | if(this.templateCache.has(strings)) {
48 | template = this.templateCache.get(strings);
49 | } else {
50 | template = this.compiler.compile(strings, values);
51 | this.templateCache.set(strings, template);
52 | }
53 | return template;
54 | }
55 |
56 | async * html(strings, ...values) {
57 | yield * this.#getTemplate(strings, values).render(values);
58 | }
59 |
60 | async * #htmlRelative(url, strings, ...values) {
61 | let template = this.#getTemplate(strings, values);
62 | yield * template.render(values, {url});
63 | }
64 |
65 | relativeTo(_url) {
66 | let url = (_url instanceof URL) ? _url : new URL(_url.toString());
67 | return this.#htmlRelative.bind(this, url);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/lib/optimize.js:
--------------------------------------------------------------------------------
1 | import { EndOfHead } from './end-of-head.js';
2 | import { urlContextCommentPlaceholder } from './compiler-shared.js';
3 |
4 | export class Optimizer {
5 | #polyfillURL;
6 |
7 | constructor(opts) {
8 | this.document = opts.document;
9 | this.customElements = this.document.defaultView.customElements;
10 | this.elements = opts.elements;
11 | this.hydrator = opts.hydrator;
12 | this.settings = opts.settings;
13 | this.plugins = opts.plugins;
14 | }
15 |
16 | optimize(frag) {
17 | let document = this.document;
18 | let customElements = this.customElements;
19 | let elements = this.elements;
20 | let polyfillURL = this.settings.polyfillURL;
21 | let plugins = this.plugins.map(p => p());
22 | let hydrator = this.hydrator.createInstance();
23 |
24 | let foundShadow = false;
25 | let eoh = new EndOfHead(document);
26 | let walker = document.createTreeWalker(frag, 133, null, false);
27 | let currentNode = frag;
28 | while(currentNode) {
29 | switch(currentNode.nodeType) {
30 | case 1: {
31 | let name = currentNode.localName;
32 | eoh.visit(currentNode);
33 |
34 | for(let plugin of plugins) {
35 | plugin.handle(currentNode, eoh);
36 | }
37 |
38 | if(customElements.get(name)) {
39 | if(currentNode.shadowRoot) {
40 | foundShadow = true;
41 | }
42 | if(elements.has(name)) {
43 | hydrator.handle(eoh, currentNode, elements.get(name));
44 | }
45 | }
46 | break;
47 | }
48 | }
49 |
50 | currentNode = walker.nextNode();
51 | }
52 | if(foundShadow && eoh.foundElementsBeforeBody && polyfillURL) {
53 | let script = document.createElement('script');
54 | script.setAttribute('type', 'module');
55 | script.textContent = this.inlinePolyfill();
56 | eoh.append(script);
57 | }
58 | }
59 | inlinePolyfill() {
60 | return /* js */ `const o=(new DOMParser).parseFromString('',"text/html",{includeShadowRoots:!0}).querySelector("p");o&&o.shadowRoot||async function(){const{hydrateShadowRoots:o}=await import("${urlContextCommentPlaceholder(this.settings.polyfillURL)}");o(document.body)}()`;
61 | }
62 | }
--------------------------------------------------------------------------------
/lib/parts.js:
--------------------------------------------------------------------------------
1 | import { serialize } from './serialize.js';
2 | import { commentPlaceholder, prefix } from './compiler-shared.js';
3 | import { AttributeBinding, PropertyBinding, TextBinding } from './binding.js';
4 | import { urlRelative } from './deps.js';
5 |
6 | const asyncRenderSymbol = Symbol.for('ocean.asyncRender');
7 |
8 | function isPrimitive(val) {
9 | if (typeof val === 'object') {
10 | return val === null;
11 | }
12 | return typeof val !== 'function';
13 | }
14 |
15 | function isThenable(val) {
16 | return typeof val.then === 'function';
17 | }
18 |
19 | async function * iterable(value) {
20 | if(isPrimitive(value) || isThenable(value)) {
21 | yield (value || '');
22 | } else if(Array.isArray(value)) {
23 | for(let inner of value) {
24 | yield * iterable(inner);
25 | }
26 | } else {
27 | yield * value;
28 | }
29 | }
30 |
31 | class Part {
32 | constructor(start, end) {
33 | this.start = start;
34 | this.end = end;
35 | }
36 | }
37 |
38 | export class TextPart extends Part {
39 | constructor(text, start, end) {
40 | super(start, end);
41 | this.text = text;
42 | }
43 |
44 | addDoctype(doctype) {
45 | this.text = doctype.replace(this.text);
46 | }
47 |
48 | async * render(values) {
49 | yield this.text;
50 | for(let value of values) {
51 | yield * iterable(value);
52 | }
53 | }
54 | }
55 |
56 | export class ComponentPart {
57 | constructor(node, state) {
58 | let start = state.i;
59 | this.node = node;
60 | this.document = node.ownerDocument;
61 | this.start = start;
62 | this.bindings = new Map();
63 | this.hasBindings = false;
64 | this.process(node, state);
65 | this.end = state.i;
66 | }
67 | process(node, state) {
68 | let document = this.document;
69 | let bindings = this.bindings;
70 |
71 | let walker = document.createTreeWalker(node, 133, null, false);
72 | let currentNode = node;
73 | let index = 0;
74 | while(currentNode) {
75 | switch(currentNode.nodeType) {
76 | case 1: {
77 | let nodeBindings = [];
78 | for(let attr of currentNode.attributes) {
79 | if(attr.value === commentPlaceholder) {
80 | if(attr.name.startsWith('.')) {
81 | nodeBindings.push(new PropertyBinding(attr.name.substr(1)));
82 | currentNode.removeAttribute(attr.name);
83 | } else {
84 | nodeBindings.push(new AttributeBinding(attr.name));
85 | }
86 | state.i++;
87 | this.end++;
88 | }
89 | }
90 | if (nodeBindings.length) {
91 | bindings.set(index, nodeBindings);
92 | }
93 | break;
94 | }
95 | case 8: {
96 | if(currentNode.data === prefix) {
97 | currentNode.replaceWith(document.createTextNode(''));
98 | bindings.set(index, [new TextBinding()]);
99 | state.i++;
100 | this.end++;
101 | }
102 | break;
103 | }
104 | }
105 | index++;
106 | currentNode = walker.nextNode();
107 | }
108 | this.hasBindings = this.bindings.size > 0;
109 | }
110 | async hydrate(values) {
111 | let resolved = await Promise.all(values);
112 | let document = this.document;
113 | let el = this.node.cloneNode(true);
114 |
115 | if(this.hasBindings) {
116 | let bindings = this.bindings;
117 | let walker = document.createTreeWalker(el, -1);
118 | let currentNode = el;
119 | let index = 0;
120 | let valueIndex = 0;
121 |
122 | while(currentNode) {
123 | if(bindings.has(index)) {
124 | for (let binding of bindings.get(index)) {
125 | let value = resolved[valueIndex];
126 | binding.set(currentNode, value);
127 | valueIndex++;
128 | }
129 | }
130 | index++;
131 | currentNode = walker.nextNode();
132 | }
133 | }
134 |
135 | return el;
136 | }
137 | async * render(values) {
138 | let el = await this.hydrate(values);
139 | let document = this.document;
140 | document.body.appendChild(el);
141 | if(asyncRenderSymbol in el) {
142 | await el[asyncRenderSymbol]();
143 | }
144 | yield * serialize(el);
145 | document.body.removeChild(el);
146 | }
147 | }
148 |
149 | export class ContextPart extends Part {
150 | constructor(prop) {
151 | super(0, 0);
152 | this.prop = prop;
153 | }
154 | }
155 |
156 | export class URLContextPart extends ContextPart {
157 | constructor(pathOrURL) {
158 | super('url');
159 | this.pathOrURL = pathOrURL;
160 | }
161 |
162 | async * render(_values, context) {
163 | if(!context || !('url' in context)) {
164 | yield this.pathOrURL;
165 | return;
166 | }
167 |
168 | let url = context.url;
169 | let resolvedURL = new URL(this.pathOrURL, url);
170 | let relPath = urlRelative(url, resolvedURL);
171 | yield relPath;
172 | }
173 | }
--------------------------------------------------------------------------------
/lib/serialize.js:
--------------------------------------------------------------------------------
1 | import { voidElements } from './shared.js';
2 | import { escapeHTML, escapeAttributeValue } from './escape.js';
3 | const oceanSerializeSymbol = Symbol.for('ocean.serialize');
4 |
5 | function * serializeFragment(frag) {
6 | for(let node of frag.childNodes) {
7 | yield * serialize(node);
8 | }
9 | }
10 |
11 | function isInsideScript(node) {
12 | return node.parentNode && node.parentNode.localName === 'script';
13 | }
14 |
15 | function isInsideStyle(node) {
16 | return node.parentNode && node.parentNode.localName === 'style';
17 | }
18 |
19 | function * serializeElement(el) {
20 | if(oceanSerializeSymbol in el) {
21 | yield * el[oceanSerializeSymbol]();
22 | return;
23 | }
24 |
25 | yield `<${el.localName}`;
26 | for(let {name, value} of el.attributes) {
27 | if(value === '') {
28 | yield ` ${name}`;
29 | } else {
30 | yield ` ${name}="${escapeAttributeValue(value)}"`;
31 | }
32 | }
33 | yield `>`;
34 |
35 | if(voidElements.has(el.localName)) {
36 | return;
37 | }
38 |
39 | if(el.shadowRoot) {
40 | yield ``;
41 | yield * serializeFragment(el.shadowRoot);
42 | yield ``;
43 | }
44 |
45 | for(let child of el.childNodes) {
46 | yield * serialize(child);
47 | }
48 |
49 | yield `${el.localName}>`;
50 | }
51 |
52 | export function * serialize(node) {
53 | switch(node.nodeType) {
54 | case 1: {
55 | yield * serializeElement(node);
56 | break;
57 | }
58 | case 3: {
59 | if(isInsideScript(node) || isInsideStyle(node))
60 | yield node.data;
61 | else
62 | yield escapeHTML(node.data);
63 | break;
64 | }
65 | case 8: {
66 | yield ``;
67 | break;
68 | }
69 | case 10: {
70 | yield ``;
71 | yield * serializeAll(node.childNodes);
72 | break;
73 | }
74 | case 11: {
75 | yield * serializeFragment(node);
76 | break;
77 | }
78 | default: {
79 | throw new Error('Unable to serialize nodeType ' + node.nodeType);
80 | }
81 | }
82 | }
83 |
84 | export function * serializeAll(nodes) {
85 | for(let node of nodes) {
86 | yield * serialize(node);
87 | }
88 | }
--------------------------------------------------------------------------------
/lib/shared.js:
--------------------------------------------------------------------------------
1 | export const voidElements = new Set(['area', 'base', 'br', 'col', 'command',
2 | 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source',
3 | 'track', 'wbr']);
4 |
5 | export const nonClosingElements = new Set([
6 | ...voidElements,
7 | 'html'
8 | ]);
9 |
10 | export const validHeadElements = new Set(['!doctype', 'title', 'meta', 'link',
11 | 'style', 'script', 'noscript', 'base']);
12 |
13 | export const elementsBeforeBody = new Set([
14 | ...validHeadElements,
15 | 'html',
16 | 'head'
17 | ]);
--------------------------------------------------------------------------------
/lib/shim-lit.js:
--------------------------------------------------------------------------------
1 | import { LitElementRenderer } from 'https://cdn.spooky.click/lit-labs-ssr-bundle/1.0.2/mod.js';
2 |
3 | export function isLit(Ctr) {
4 | return !!Ctr._$litElement$;
5 | }
6 |
7 | export function * litRender() {
8 | const instance = new LitElementRenderer(this.localName);
9 |
10 | // LitElementRenderer creates a new element instance, so copy over.
11 | for(let attr of this.attributes) {
12 | instance.setAttribute(attr.name, attr.value);
13 | }
14 |
15 | yield `<${this.localName}`;
16 | yield* instance.renderAttributes();
17 | yield `>`;
18 | const shadowContents = instance.renderShadow({});
19 | if (shadowContents !== undefined) {
20 | yield '';
21 | yield* shadowContents;
22 | yield '';
23 | }
24 | yield this.innerHTML;
25 | yield `${this.localName}>`;
26 | }
27 |
28 | export function shimLit(Ctr) {
29 | Ctr.prototype.connectedCallback = Function.prototype;
30 | Ctr.prototype[Symbol.for('ocean.serialize')] = litRender;
31 | }
--------------------------------------------------------------------------------
/lib/shim-stencil.js:
--------------------------------------------------------------------------------
1 |
2 | export function isStencil(Ctr) {
3 | return typeof Ctr.prototype.componentOnReady === 'function';
4 | }
5 |
6 | export function shimStencil(name, Ctr) {
7 | Ctr.prototype[Symbol.for('ocean.asyncRender')] = Ctr.prototype.componentOnReady;
8 |
9 | // Stencil does a weird thing that breaks localName, so let's fix it.
10 | const connectedCallback = Ctr.prototype.connectedCallback;
11 | Ctr.prototype.connectedCallback = function() {
12 | if(!this.localName) {
13 | this.localName = name;
14 | }
15 | return connectedCallback.apply(this, arguments);
16 | }
17 | }
--------------------------------------------------------------------------------
/lib/shim.js:
--------------------------------------------------------------------------------
1 | import { unshim, domShimSymbol } from 'https://cdn.spooky.click/dom-shim/1.3.0/mod.js?global&props=customElements,document,window,Document,Element,HTMLElement,HTMLTemplateElement,Node,requestAnimationFrame,Text';
2 | import { isLit, shimLit } from './shim-lit.js';
3 | import { isStencil, shimStencil } from './shim-stencil.js';
4 |
5 | const { document } = self[domShimSymbol];
6 |
7 | const window = document.defaultView;
8 | const DOMParser = window.DOMParser;
9 |
10 | const ShadowPrototype = document.createElement('div').attachShadow({mode:'open'}).constructor.prototype;
11 | let _innerhtml = Symbol('ocean.innerhtmlshim');
12 | Object.defineProperty(ShadowPrototype, 'innerHTML', {
13 | get() {
14 | return this[_innerhtml];
15 | },
16 | set(val) {
17 | this[_innerhtml] = val;
18 | let parser = new DOMParser();
19 | let doc = parser.parseFromString(val, 'text/html');
20 | this.replaceChildren(...doc.childNodes);
21 | }
22 | });
23 |
24 | const customElementsDefine = window.customElements.define;
25 | function overrideElementShim(name, Ctr) {
26 | if(isLit(Ctr)) {
27 | shimLit(Ctr);
28 | } else if(isStencil(Ctr)) {
29 | shimStencil(name, Ctr);
30 | }
31 | return customElementsDefine.apply(this, arguments);
32 | }
33 |
34 | Object.defineProperty(window.customElements, 'define', {
35 | value: overrideElementShim
36 | });
37 |
38 | const url = new URL(import.meta.url);
39 | if(!url.searchParams.has('global')) {
40 | unshim();
41 | }
42 |
43 | export {
44 | unshim
45 | };
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # 🌊 Ocean
2 |
3 | Web component HTML rendering that includes:
4 |
5 | * Rendering to [Declarative Shadow DOM](https://web.dev/declarative-shadow-dom/), requiring no JavaScript in the client.
6 | * Automatic inclusion of the Declarative Shadow DOM polyfill for browsers without support.
7 | * Streaming HTML responses.
8 | * Compatibility with the most popular web component libraries (see a compatibility list below).
9 | * Lazy [partial hydration](https://www.jameshill.dev/articles/partial-hydration/) via special attributes: hydrate on page load, CPU idle, element visibility, or media queries. Or create your own hydrator.
10 |
11 | ---
12 |
13 | __Table of Contents__
14 |
15 | * __[Overview](#overview)__
16 | * __[Modules](#modules)__
17 | * __[Main module](#main-module)__
18 | * __[DOM shim](#dom-shim)__
19 | * __[Hydration](#hydration)__
20 | * __[Full hydration](#full-hydration)__
21 | * __[Partial hydration](#partial-hydration)__
22 | * __[Relative links](#relative-links)__
23 | * __[Plugins](#plugins)__
24 | * __[Compatibility](#compatibility)__
25 |
26 | ## Overview
27 |
28 | An *ocean* is an environment for rendering web component code. It provides an `html` function that looks like the ones you're used to from libraries like [uhtml](https://github.com/WebReflection/uhtml) and [Lit](https://lit.dev/). Instead of creating reactive DOM in the client like those libraries, Ocean's `html` returns an *async iterator* that will stream out HTML strings.
29 |
30 | Ocean is somewhat low-level and is meant to be used with a higher-level framework. Typical usage looks like this:
31 |
32 | ```js
33 | import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';
34 | import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
35 |
36 | const { HTMLElement, customElements, document } = globalThis;
37 |
38 | class AppRoot extends HTMLElement {
39 | constructor() {
40 | super();
41 | this.attachShadow({ mode: 'open' });
42 | }
43 | connectedCallback() {
44 | let div = document.createElement('div');
45 | div.textContent = `This is an app!`;
46 | this.shadowRoot.append(div);
47 | }
48 | }
49 |
50 | customElements.define('app-root', AppRoot);
51 |
52 | const { html } = new Ocean({
53 | document,
54 | polyfillURL: '/webcomponents/declarative-shadow-dom.js'
55 | });
56 |
57 | let iterator = html`
58 |
59 |
60 | My app
61 |
62 |
63 | `;
64 |
65 | let code = '';
66 | for await(let chunk of iterator) {
67 | code += chunk;
68 | }
69 | console.log(chunk); // HTML string
70 | ```
71 |
72 | The above will generate the following HTML:
73 |
74 | ```html
75 |
76 |
77 | My app
78 |
79 |
80 |
81 |
82 | This is an app!
83 |
84 |
85 | ```
86 |
87 | ## Modules
88 |
89 | Ocean comes with its main module and a DOM shim for compatible with custom element code.
90 |
91 | ### Main module
92 |
93 | The main module for Ocean is available in two forms: bundled and unbundled.
94 |
95 | * If you are using Ocean in a browser context, such as a service worker, use the bundled version.
96 | * If you are using Ocean in [Deno](https://deno.land/), use the unbundled version.
97 |
98 | #### Unbundled
99 |
100 | ```js
101 | import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
102 | ```
103 |
104 | #### Bundled
105 |
106 | ```js
107 | import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.bundle.js';
108 | ```
109 |
110 | ### DOM shim
111 |
112 | Ocean's DOM shim is backed by [linkedom](https://github.com/WebReflection/linkedom), a fast DOM layer. The shim also bridges compatibility with popular web component libraries.
113 |
114 | It's important to import the DOM shim as one of the first imports in your app.
115 |
116 | ```js
117 | import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';
118 | ```
119 |
120 | Notice that this includes in the `?global` query parameter. This makes the shim available on globals; you get `document`, `customElements`, and other commonly used global variables.
121 |
122 | If you do not want to shim the global environment you can omit the `?global` query parameter and instead get the globals yourself from the symbol `Symbol.for('dom-shim.defaultView')`. This is advanced usage.
123 |
124 | ```js
125 | import 'https://cdn.spooky.click/ocean/1.3.1/shim.js';
126 |
127 | const root = globalThis[Symbol.for('dom-shim.defaultView')];
128 | const { HTMLElement, customElements, document } = root;
129 | ```
130 |
131 | ## Hydration
132 |
133 | Partial hydration is the practice of only hydrating (via running client JavaScript) components that are needed for interactivity. Ocean *does not* automatically add scripts for components by default. However Ocean does support both full and partial hydration. This means you can omit the component script tags from your HTML and Ocean will automatically add them for you.
134 |
135 | In order to add script tags you have to provide Ocean a map of tag names to URLs to load. You do this through the `elements` [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) that is returned from the constructor.
136 |
137 | ```js
138 | let { html, elements } = new Ocean({
139 | document
140 | });
141 |
142 | elements.set('app-sidebar', '/elements/app-sidebar.js');
143 | ```
144 |
145 | > *Note*: Ocean only adds script tags for elements that are *server rendered*. If you are not server rendering an element you will need to add the appropriate script tags yourself.
146 |
147 | ### Full hydration
148 |
149 | Full hydration means added script tags to the `` for any components that are server rendered. You can enable full hydration by passing this in the constructor:
150 |
151 | ```js
152 | let { html, elements } = new Ocean({
153 | document,
154 | hydration: 'full'
155 | });
156 |
157 | elements.set('app-sidebar', '/elements/app-sidebar.js');
158 |
159 | customElements.define('app-sidebar', class extends HTMLElement {
160 | constructor() {
161 | super();
162 | this.attachShadow({ mode: 'open' });
163 | }
164 | connectedCallback() {
165 | let div = document.createElement('div');
166 | div.textContent = `My sidebar...`;
167 | this.shadowRoot.append(div);
168 | }
169 | });
170 | ```
171 |
172 | Then when you render this element, it will include the script tags:
173 |
174 | ```js
175 | let iterator = html`
176 |
177 |
178 | My app
179 |
180 |
181 | `;
182 |
183 | let out = '';
184 | for(let chunk of iterator) {
185 | out += chunk;
186 | }
187 | ```
188 |
189 | Will produce this HTML:
190 |
191 | ```html
192 |
193 |
194 | My app
195 |
196 |
197 |
198 |
199 | My sidebar...
200 |
201 |
202 | ```
203 |
204 | ### Partial hydration
205 |
206 | By default Ocean uses partial hydration. In partial hydration script tags are only added when you explicitly tell Ocean to hydration an element. This means that by default elements will be rendered to HTML only, and never iteractive on the client.
207 |
208 | This allows you to use the web component libraries you love both to produce static HTML and for interactive content.
209 |
210 | To declare an element to be hydrated, use the `ocean-hydrate` attribute on any element. The value should be one of:
211 |
212 | * __load__: Hydrate when the page loads. Ocean will add a `');
265 | });
266 |
267 | Deno.test('Text is not serialized inside of style tags', async () => {
268 | let { html } = new Ocean({ document });
269 | customElements.define('my-text-in-style-el', class extends HTMLElement {
270 | constructor() {
271 | super();
272 | this.attachShadow({ mode: 'open'});
273 | }
274 | connectedCallback() {
275 | this.shadowRoot.innerHTML = `
276 |
277 | `;
278 | }
279 | });
280 | let iter = html``;
281 | let out = await consume(iter);
282 | assertStringIncludes(out, '', 'no escaping in style tags');
283 | });
284 |
285 | Deno.test('Attribute values are escaped in HTML', async () => {
286 | let { html } = new Ocean({ document });
287 | let iter = html``;
288 | let out = await consume(iter);
289 | assertEquals(out, ``);
290 | });
291 |
292 | Deno.test('Attribute values are escaped in custom elements', async () => {
293 | let { html } = new Ocean({ document });
294 | class AttributeElement extends HTMLElement {
295 | constructor() {
296 | super();
297 | this.attachShadow({ mode: 'open' });
298 | }
299 | connectedCallback() {
300 | let el = document.createElement('div');
301 | el.setAttribute('a', '"a');
302 | this.shadowRoot.append(el);
303 | }
304 | }
305 | customElements.define('attribute-el', AttributeElement);
306 | let iter = html``;
307 | let out = await consume(iter);
308 | assertStringIncludes(out, ``, 'attribute values escaped');
309 | });
310 |
311 | Deno.test('Can take an array of HTML content', async () => {
312 | let { html } = new Ocean({ document });
313 | let iter = html`${[1, 2, 3].map(n => html`- ${n}
`)}
`;
314 | let out = await consume(iter);
315 | assertEquals(out, ``);
316 | });
--------------------------------------------------------------------------------
/test/worker.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Web worker test
4 |
--------------------------------------------------------------------------------
/test/worker.js:
--------------------------------------------------------------------------------
1 | import '../lib/shim.js?global';
2 | import { Ocean } from '../lib/mod.bundle.js';
3 |
4 | console.log(Ocean);
5 |
6 | try {
7 | postMessage({ type: 'result', result: { ok: true } });
8 | } catch {
9 | postMessage({ type: 'result', result: { ok: false } });
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/test/worker.test.js:
--------------------------------------------------------------------------------
1 | import puppeteer from 'https://deno.land/x/puppeteer@9.0.1/mod.ts';
2 | import { serve } from 'https://deno.land/std@0.103.0/http/server.ts';
3 | import { readAll } from 'https://deno.land/std@0.103.0/io/util.ts';
4 | import { assert, assertEquals } from './deps.js';
5 |
6 | class Server {
7 | #next;
8 | #nextResolve;
9 | #nextReject;
10 | constructor() {
11 | this.server = serve({ port: 8082 });
12 | this.#next = Promise.resolve();
13 | this.listener = this.listen();
14 | }
15 | close() {
16 | return this.server.close();
17 | }
18 | async handle(requestEvent) {
19 | let url = new URL(requestEvent.url, 'http://example.com');
20 | if(url.pathname === '/result') {
21 | let body = new TextDecoder().decode(await readAll(requestEvent.body));
22 | let json = JSON.parse(body);
23 | await requestEvent.respond({ status: 200, body: 'OK' });
24 | this.#nextResolve(json.result);
25 | } else if(url.pathname === '/error') {
26 | let body = new TextDecoder().decode(await readAll(requestEvent.body));
27 | let json = JSON.parse(body);
28 | await requestEvent.respond({ status: 200 });
29 | this.#nextReject(json);
30 | } else if(url.pathname === '/favicon.ico') {
31 | await requestEvent.respond({ status: 404 });
32 | } else {
33 | let fileUrl = new URL('..' + url.pathname, import.meta.url);
34 | let fileText = await Deno.readTextFile(fileUrl);
35 | let bytes = new TextEncoder().encode(fileText);
36 | await requestEvent.respond({
37 | status: 200,
38 | headers: new Headers({
39 | 'content-type': fileUrl.pathname.endsWith('.html') ? 'text/html' : 'application/javascript',
40 | 'content-length': bytes.byteLength
41 | }),
42 | body: bytes
43 | });
44 | }
45 | }
46 | async listen() {
47 | for await (const req of this.server) {
48 | this.handle(req);
49 | }
50 | }
51 | next() {
52 | this.#next = new Promise((resolve, reject) => {
53 | this.#nextResolve = resolve;
54 | this.#nextReject = reject;
55 | });
56 | return this.#next;
57 | }
58 | }
59 |
60 | let browser;
61 | async function getBrowser() {
62 | if(browser) return browser;
63 | browser = await puppeteer.launch({ headless: true });
64 | return browser;
65 | }
66 |
67 | Deno.test({
68 | name: 'Can run in a worker',
69 | ignore: Deno.env.get('OCEAN_CORE') == 1,
70 | fn: async () => {
71 | let server = new Server();
72 | let browser = await getBrowser();
73 | let page = await browser.newPage();
74 | await page.goto('http://localhost:8082/test/worker.html');
75 | try {
76 | let result = await server.next();
77 | assertEquals(result.ok, true);
78 | } catch(_err) {
79 | assert(false, 'Got an error');
80 | } finally {
81 | await browser.close();
82 | await server.close();
83 | }
84 | }
85 | });
86 |
87 |
--------------------------------------------------------------------------------