@cocreate/render 1.53.0 → 2.0.1
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.
- package/.github/FUNDING.yml +1 -1
- package/.github/workflows/automated.yml +18 -7
- package/CHANGELOG.md +698 -652
- package/CONTRIBUTING.md +6 -6
- package/README.md +48 -18
- package/demo/fetch.string.html +2 -2
- package/demo/index.html +1 -1
- package/demo/render.array.html +1 -1
- package/demo/render.html +1 -1
- package/demo/render.object.html +1 -1
- package/demo/renderKey.html +1 -1
- package/docs/index.html +7 -7
- package/package.json +22 -28
- package/src/client/index.css +2 -0
- package/src/{index.js → client/index.js} +253 -149
- package/src/client/package.json +3 -0
- package/src/server/index.js +778 -0
- package/src/server/package.json +3 -0
- package/test/{render-order.test.cjs → client/render-order.test.cjs} +1 -1
- package/test/client/render-value-ast.test.cjs +58 -0
- package/test/server/deferred-file.test.js +14 -0
- package/test/server/file-access.test.js +9 -0
- package/test/server/package.json +3 -0
- package/test/server/stylesheet-links.test.js +103 -0
- package/test/server/theme.test.js +21 -0
- package/test/server/value-transformations.test.js +87 -0
- package/webpack.config.js +1 -1
- package/src/index.css +0 -1
|
@@ -4,7 +4,7 @@ const { join } = require('node:path');
|
|
|
4
4
|
const test = require('node:test');
|
|
5
5
|
const vm = require('node:vm');
|
|
6
6
|
|
|
7
|
-
const source = readFileSync(join(__dirname, '
|
|
7
|
+
const source = readFileSync(join(__dirname, '../../src/client/index.js'), 'utf8');
|
|
8
8
|
const renderTemplate = source.slice(source.indexOf('async function renderTemplate('), source.indexOf('function cloneTemplate('));
|
|
9
9
|
const insertElement = source.slice(source.indexOf('function insertElement('), source.indexOf('async function renderValues('));
|
|
10
10
|
const regions = ['us-east-1', 'us-east-2', 'us-west-1', 'me-south-1'];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { readFileSync } = require('node:fs');
|
|
3
|
+
const { join } = require('node:path');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
|
|
7
|
+
const source = readFileSync(join(__dirname, '../../src/client/index.js'), 'utf8');
|
|
8
|
+
const functions = source.slice(source.indexOf('async function renderValue('), source.indexOf('async function renderKey('));
|
|
9
|
+
let context;
|
|
10
|
+
let received;
|
|
11
|
+
|
|
12
|
+
test.before(async () => {
|
|
13
|
+
const { astAsync } = await import('../../CoCreate-utils/src/modules/ast/index.js');
|
|
14
|
+
const { getValueFromObject } = await import('../../CoCreate-utils/src/modules/getValueFromObject.js');
|
|
15
|
+
const window = { markdown: { render(value) { received = value; return '<p>Rendered</p>\n'; } } };
|
|
16
|
+
context = vm.createContext({ astAsync, getValueFromObject, renderedNodes: new Map(), window, console });
|
|
17
|
+
vm.runInContext(functions, context);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function render(expression, content = 'Message') {
|
|
21
|
+
return context.renderValue({ nodeType: 1 }, { window: context.window, object: { content } }, expression, 'object', {}, 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('AST returns HTML directly instead of looking it up as a data key', async () => {
|
|
25
|
+
const content = '**Hello**, with (parentheses), "$value" and `code`';
|
|
26
|
+
assert.equal(await render('{{$(window.markdown.render(object.content))}}', content), '<p>Rendered</p>\n');
|
|
27
|
+
assert.equal(received, content);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('nested placeholders resolve before the surrounding expression', async () => {
|
|
31
|
+
assert.equal(await render('{{$(window.markdown.render("{{object.content}}"))}}', '**Hello**'), '<p>Rendered</p>\n');
|
|
32
|
+
assert.equal(received, '**Hello**');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('ordinary record placeholders retain their existing lookup behavior', async () => {
|
|
36
|
+
assert.equal(await render('Message: {{object.content}}', 'Original'), 'Message: Original');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('AST preserves false, zero and empty-string results', async () => {
|
|
40
|
+
assert.equal(await render('{{$(false)}}'), 'false');
|
|
41
|
+
assert.equal(await render('{{$(0)}}'), '0');
|
|
42
|
+
assert.equal(await render('{{$("")}}'), '');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
test('template expressions await asynchronous transformations before returning HTML', async () => {
|
|
47
|
+
let release;
|
|
48
|
+
const ready = new Promise(resolve => { release = resolve; });
|
|
49
|
+
context.window.markdown.renderAsync = async value => {
|
|
50
|
+
await ready;
|
|
51
|
+
assert.equal(value, "Initial message");
|
|
52
|
+
return "<p>Ready and highlighted</p>";
|
|
53
|
+
};
|
|
54
|
+
const output = render('{{$(window.markdown.renderAsync(object.content))}}', "Initial message");
|
|
55
|
+
release();
|
|
56
|
+
assert.equal(await output, "<p>Ready and highlighted</p>");
|
|
57
|
+
delete context.window.markdown.renderAsync;
|
|
58
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {HTML} from '../../src/server/index.js';
|
|
4
|
+
test('304 skips nested content, removes rendered, and continues rendering siblings',async()=>{
|
|
5
|
+
const calls=[];
|
|
6
|
+
const result=await HTML({file:{src:'<main><section src="/deferred.html" rendered render-error></section><aside src="/sibling.html"></aside></main>',pathname:'/index.html'},urlObject:new URL('https://example.test/'),resolveFile:async path=>{
|
|
7
|
+
calls.push(path);
|
|
8
|
+
return path==='/deferred.html'?{status:304,src:'<div src="/must-not-fetch.html">DEFERRED</div>'}:{status:200,src:'SIBLING'};
|
|
9
|
+
}});
|
|
10
|
+
assert.deepEqual(calls,['/deferred.html','/sibling.html']);
|
|
11
|
+
assert.match(result,/<section[^>]*src="\/deferred.html"[^>]*error="304"/);
|
|
12
|
+
assert.doesNotMatch(result.match(/<section[^>]*>/)[0],/\srendered(?:\s|=|>)/);
|
|
13
|
+
assert.doesNotMatch(result,/DEFERRED/);assert.match(result,/SIBLING/);
|
|
14
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {HTML} from '../../src/server/index.js';
|
|
4
|
+
test('SSR without an authorized resolver cannot include private or legacy-public content',async()=>{
|
|
5
|
+
for(const file of [{headers:{'Cache-Control':'public, must-revalidate'}},{public:true},{headers:{'Cache-Control':'private'}},{headers:{'Cache-Control':'no-store, must-revalidate'}}]){
|
|
6
|
+
const result=await HTML({file:{src:'<div src="/secret.html" render-error></div>',pathname:'/index.html'},urlObject:new URL('https://example.test/'),crud:{send:async()=>({object:[{...file,src:'SECRET-CONTENT'}]})}});
|
|
7
|
+
assert.doesNotMatch(result,/SECRET-CONTENT/);assert.match(result,/error="403"/);
|
|
8
|
+
}
|
|
9
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { HTML } from '../../src/server/index.js';
|
|
4
|
+
|
|
5
|
+
const link = '<link rel="stylesheet" href="/component.css">';
|
|
6
|
+
const render = (src, options = {}) => HTML({
|
|
7
|
+
file: { src, pathname: '/index.html' },
|
|
8
|
+
urlObject: new URL('https://example.com/index.html'),
|
|
9
|
+
...options
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
for (const src of [link, `<section>${link}<p>Content</p></section>`, `<html><body>${link}</body></html>`]) {
|
|
13
|
+
test(`preserves links without a head: ${src}`, async () => {
|
|
14
|
+
assert.equal(await render(src), src);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('preserves links from recursively fetched fragments without creating a head', async () => {
|
|
19
|
+
const result = await render('<main><div src="/component.html"></div></main>', {
|
|
20
|
+
resolveFile: async () => ({ src: `<section>${link}<p>Content</p></section>` })
|
|
21
|
+
});
|
|
22
|
+
assert.doesNotMatch(result, /<head\b/i);
|
|
23
|
+
assert.ok(result.includes(`<section>${link}<p>Content</p></section>`));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('preserves links from data fragments without creating a head', async () => {
|
|
27
|
+
const result = await render('<main><div array="components" key="src"></div></main>', {
|
|
28
|
+
crud: { send: async () => ({ object: [{ src: `<section>${link}</section>` }] }) }
|
|
29
|
+
});
|
|
30
|
+
assert.doesNotMatch(result, /<head\b/i);
|
|
31
|
+
assert.ok(result.includes(`<section>${link}</section>`));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('moves and deduplicates nested fragment stylesheets into an existing head', async () => {
|
|
35
|
+
const result = await render(`<html><head>${link}</head><body><div src="/component.html"></div></body></html>`, {
|
|
36
|
+
resolveFile: async (pathname) => ({ src: pathname === '/component.html'
|
|
37
|
+
? `<section>${link}<div src="/nested.html"></div></section>`
|
|
38
|
+
: '<link rel="stylesheet" href="/nested.css"><p>Nested</p>' })
|
|
39
|
+
});
|
|
40
|
+
const head = result.match(/<head>([\s\S]*?)<\/head>/)[1];
|
|
41
|
+
assert.ok(head.includes(link));
|
|
42
|
+
assert.ok(head.includes('href="/nested.css"'));
|
|
43
|
+
assert.equal(result.split('href="/component.css"').length - 1, 1);
|
|
44
|
+
assert.doesNotMatch(result.match(/<body>([\s\S]*?)<\/body>/)[1], /<link\b/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for (const hasHead of [true, false]) {
|
|
48
|
+
for (const useFileResolver of [true, false]) {
|
|
49
|
+
test(`processes src → CRUD → src → CRUD with head=${hasHead}, resolver=${useFileResolver}`, async () => {
|
|
50
|
+
const calls = [];
|
|
51
|
+
const files = {
|
|
52
|
+
'/component.html': `<section>${link}<div array="components" object="first" key="src"></div></section>`,
|
|
53
|
+
'/nested.html': '<article><link rel="stylesheet" href="/nested.css"><div array="components" object="second" key="src"></div></article>'
|
|
54
|
+
};
|
|
55
|
+
const records = {
|
|
56
|
+
first: '<aside><link rel="stylesheet" href="/data.css"><div src="/nested.html"></div></aside>',
|
|
57
|
+
second: `<footer>${link}<strong>Deep content</strong></footer>`
|
|
58
|
+
};
|
|
59
|
+
const resolveFile = async (pathname) => {
|
|
60
|
+
calls.push(pathname);
|
|
61
|
+
assert.ok(files[pathname], `Unexpected file: ${pathname}`);
|
|
62
|
+
return { src: files[pathname], headers: { "Cache-Control": "public" } };
|
|
63
|
+
};
|
|
64
|
+
const options = {
|
|
65
|
+
...(useFileResolver ? { resolveFile } : {}),
|
|
66
|
+
crud: {
|
|
67
|
+
send: async (request) => {
|
|
68
|
+
if (request.array === 'files') {
|
|
69
|
+
return { object: [await resolveFile(request.filter.query.pathname)] };
|
|
70
|
+
}
|
|
71
|
+
const id = request.object._id;
|
|
72
|
+
calls.push(id);
|
|
73
|
+
assert.ok(records[id], `Unexpected record: ${id}`);
|
|
74
|
+
return { object: [{ src: records[id] }] };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const content = '<main><div src="/component.html"></div></main>';
|
|
79
|
+
const root = hasHead
|
|
80
|
+
? `<html><head><title>Root</title></head><body>${content}</body></html>`
|
|
81
|
+
: content;
|
|
82
|
+
const result = await render(root, options);
|
|
83
|
+
assert.deepEqual(calls, ['/component.html', 'first', '/nested.html', 'second']);
|
|
84
|
+
assert.ok(result.includes('<strong>Deep content</strong>'));
|
|
85
|
+
assert.doesNotMatch(result, /error="/);
|
|
86
|
+
if (hasHead) {
|
|
87
|
+
const head = result.match(/<head>([\s\S]*?)<\/head>/)[1];
|
|
88
|
+
for (const href of ['/component.css', '/data.css', '/nested.css']) {
|
|
89
|
+
assert.ok(head.includes(`href="${href}"`));
|
|
90
|
+
assert.equal(result.split(`href="${href}"`).length - 1, 1);
|
|
91
|
+
}
|
|
92
|
+
assert.equal(result.match(/<head>/g).length, 1);
|
|
93
|
+
assert.doesNotMatch(result.match(/<body>([\s\S]*?)<\/body>/)[1], /<link\b/);
|
|
94
|
+
} else {
|
|
95
|
+
assert.doesNotMatch(result, /<(head|html|body)\b/i);
|
|
96
|
+
assert.ok(result.includes(`<section>${link}`));
|
|
97
|
+
assert.ok(result.includes(`<footer>${link}`));
|
|
98
|
+
assert.ok(result.includes('<aside><link rel="stylesheet" href="/data.css">'));
|
|
99
|
+
assert.ok(result.includes('<article><link rel="stylesheet" href="/nested.css">'));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { HTML } from '../../src/server/index.js';
|
|
4
|
+
|
|
5
|
+
for (const [saved, system, expected] of [
|
|
6
|
+
['dark', 'light', 'dark'],
|
|
7
|
+
['light', 'dark', 'light'],
|
|
8
|
+
['', 'dark', 'dark'],
|
|
9
|
+
['dark', undefined, 'dark']
|
|
10
|
+
]) {
|
|
11
|
+
test(`saved theme ${saved || 'auto'} with system ${system}: ${expected}`, async () => {
|
|
12
|
+
const result = await HTML({
|
|
13
|
+
file: { src: '<html state-id="theme" color-scheme=""><head><meta name="color-scheme" content="light"></head><body></body></html>', pathname: '/index.html' },
|
|
14
|
+
urlObject: new URL('https://example.test/index.html'),
|
|
15
|
+
state: JSON.stringify({ theme: { 'color-scheme': saved } }),
|
|
16
|
+
theme: system
|
|
17
|
+
});
|
|
18
|
+
assert.match(result, new RegExp(`color-scheme="${expected}"`));
|
|
19
|
+
assert.match(result, new RegExp(`<meta name="color-scheme" content="${expected}">`));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { HTML } from '../../src/server/index.js';
|
|
4
|
+
|
|
5
|
+
const render = (src, options = {}) => HTML({
|
|
6
|
+
file: { src, pathname: '/index.html' },
|
|
7
|
+
urlObject: new URL('https://example.com/index.html'),
|
|
8
|
+
...options
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('API header replaces app placeholders during SSR and replaces its outer element', async () => {
|
|
12
|
+
const include = String.raw`<header src="/components/offcanvas-app/header.html" value-replace="/\{\{app\.name\}\}/g" value-replacement="APIs" value-type="outerHtml"></header>`;
|
|
13
|
+
const header = '<header><div aria-label="{{app.name}} views"><button aria-controls="{{app.name}}-details-view">Details</button></div></header>';
|
|
14
|
+
const result = await render(include, { resolveFile: async () => ({ src: header }) });
|
|
15
|
+
assert.doesNotMatch(result, /\{\{app\.name\}\}/);
|
|
16
|
+
assert.match(result, /aria-label="APIs views"/);
|
|
17
|
+
assert.match(result, /aria-controls="APIs-details-view"/);
|
|
18
|
+
assert.equal((result.match(/<header\b/g) || []).length, 1);
|
|
19
|
+
assert.doesNotMatch(result, /src="\/components\/offcanvas-app\/header.html"/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('replacement runs before resolving nested includes', async () => {
|
|
23
|
+
const requests = [];
|
|
24
|
+
const result = await render('<div src="/parent.html" value-replace="APP" value-replacement="apis"></div>', {
|
|
25
|
+
resolveFile: async pathname => {
|
|
26
|
+
requests.push(pathname);
|
|
27
|
+
return { src: pathname === '/parent.html' ? '<div src="/APP/child.html"></div>' : '<p>Nested</p>' };
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
assert.deepEqual(requests, ['/parent.html', '/apis/child.html']);
|
|
31
|
+
assert.match(result, /<p>Nested<\/p>/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('setter overrides shared options and ignores getter options', async () => {
|
|
35
|
+
const result = await render('<div src="/part.html" value-replace="NAME" value-replacement="shared" set-value-replacement="setter" get-value-replace="setter" get-value-replacement="getter"></div>', {
|
|
36
|
+
resolveFile: async () => ({ src: '<p>NAME</p>' })
|
|
37
|
+
});
|
|
38
|
+
assert.match(result, /<p>setter<\/p>/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('explicit setter false disables a shared transformation', async () => {
|
|
42
|
+
const result = await render('<div src="/part.html" value-uppercase set-value-uppercase="false"></div>', {
|
|
43
|
+
resolveFile: async () => ({ src: '<p>Mixed</p>' })
|
|
44
|
+
});
|
|
45
|
+
assert.match(result, /<p>Mixed<\/p>/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('CRUD transformations are independent for elements sharing cached records', async () => {
|
|
49
|
+
let requests = 0;
|
|
50
|
+
const result = await render('<main><div array="parts" object="one" key="src" value-replace="NAME" value-replacement="First"></div><div array="parts" object="one" key="src" set-value-replace="NAME" set-value-replacement="Second"></div></main>', {
|
|
51
|
+
crud: { send: async () => { requests++; return { object: [{ src: '<p>NAME</p>' }] }; } }
|
|
52
|
+
});
|
|
53
|
+
assert.equal(requests, 1);
|
|
54
|
+
assert.match(result, /<p>First<\/p>/);
|
|
55
|
+
assert.match(result, /<p>Second<\/p>/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('CRUD identity without a key preserves element content', async () => {
|
|
59
|
+
let requests = 0;
|
|
60
|
+
const result = await render(
|
|
61
|
+
'<button array="ai-messages" object="message-id"><i>Delete</i></button>',
|
|
62
|
+
{
|
|
63
|
+
crud: {
|
|
64
|
+
send: async () => {
|
|
65
|
+
requests++;
|
|
66
|
+
return { object: [{ content: 'must not render' }] };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
assert.equal(requests, 0);
|
|
73
|
+
assert.match(result, /<button[^>]*><i>Delete<\/i><\/button>/);
|
|
74
|
+
assert.doesNotMatch(result, /rendered(?:=|\s|>)/);
|
|
75
|
+
assert.doesNotMatch(result, /must not render/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('decoding and type conversion work without browser globals', async () => {
|
|
79
|
+
const result = await render('<div array="parts" key="src" set-value-decode="json" set-value-type="array.$keys"></div>', {
|
|
80
|
+
crud: { send: async () => ({ object: [{ src: '{"name":"APIs"}' }] }) }
|
|
81
|
+
});
|
|
82
|
+
assert.match(result, /\[\s*"name"\s*\]/);
|
|
83
|
+
const decoded = await render('<div src="/part.html" set-value-decode="html-entities"></div>', {
|
|
84
|
+
resolveFile: async () => ({ src: '<p>© APIs</p>' })
|
|
85
|
+
});
|
|
86
|
+
assert.match(decoded, /<p>© APIs<\/p>/);
|
|
87
|
+
});
|
package/webpack.config.js
CHANGED
|
@@ -7,7 +7,7 @@ module.exports = async (env, argv) => {
|
|
|
7
7
|
const isProduction = argv && argv.mode === "production";
|
|
8
8
|
const config = {
|
|
9
9
|
entry: {
|
|
10
|
-
"CoCreate-render": "./src/index.js"
|
|
10
|
+
"CoCreate-render": "./src/client/index.js"
|
|
11
11
|
},
|
|
12
12
|
output: {
|
|
13
13
|
path: path.resolve(__dirname, "dist"),
|
package/src/index.css
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
.template, [template] {display: none !important;}
|