@cocreate/render 1.51.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,90 @@
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 renderTemplate = source.slice(source.indexOf('async function renderTemplate('), source.indexOf('function cloneTemplate('));
9
+ const insertElement = source.slice(source.indexOf('function insertElement('), source.indexOf('async function renderValues('));
10
+ const regions = ['us-east-1', 'us-east-2', 'us-west-1', 'me-south-1'];
11
+
12
+ async function renderOrder({ templateReverse = null, sourceReverse, withSource = false } = {}) {
13
+ const siblings = [];
14
+ let nextId = 0;
15
+
16
+ class Element {
17
+ constructor(reverse = null) {
18
+ this.nodeType = 1;
19
+ this.attributes = new Map();
20
+ if (reverse !== null) this.attributes.set('render-reverse', reverse);
21
+ }
22
+ getAttribute(name) { return this.attributes.get(name) ?? null; }
23
+ setAttribute(name, value) { this.attributes.set(name, value); }
24
+ insertAdjacentElement(position, element) {
25
+ assert.ok(['beforebegin', 'afterend'].includes(position));
26
+ const previous = siblings.indexOf(element);
27
+ if (previous >= 0) siblings.splice(previous, 1);
28
+ siblings.splice(siblings.indexOf(this) + (position === 'afterend' ? 1 : 0), 0, element);
29
+ }
30
+ }
31
+
32
+ const template = new Element(templateReverse);
33
+ template.setAttribute('render-as', 'region');
34
+ siblings.push(template);
35
+ const templateData = { element: template, clones: new Map(), parent: {} };
36
+ if (withSource) templateData.source = { element: new Element(sourceReverse) };
37
+
38
+ const context = vm.createContext({
39
+ template,
40
+ data: { regions: regions.map(region => ({ region })) },
41
+ renderedNodes: new Map([[template, templateData]]),
42
+ HTMLTemplateElement: class {},
43
+ uuid: () => String(++nextId),
44
+ getRenderValue: (element, data, key) => data[key],
45
+ getRenderContext: () => undefined,
46
+ setRenderContext: (node, context) => context,
47
+ cloneTemplate: () => ({ element: new Element() }),
48
+ // Value interpolation is independent of list insertion and reverse settings.
49
+ renderValues: async (element, data) => { element.region = data.region.region; }
50
+ });
51
+ vm.runInContext(`${renderTemplate}\n${insertElement}\nglobalThis.done = renderTemplate(template, data, 'regions');`, context);
52
+ await context.done;
53
+ return siblings.filter(element => element !== template).map(element => element.region);
54
+ }
55
+
56
+ test('nested region arrays preserve input order without a source or reverse attribute', async () => {
57
+ assert.deepEqual(await renderOrder(), regions);
58
+ });
59
+
60
+ test('a source with no reverse attribute preserves input order', async () => {
61
+ assert.deepEqual(await renderOrder({ withSource: true }), regions);
62
+ });
63
+
64
+ test('false reverse settings preserve input order', async t => {
65
+ for (const value of [false, 'false']) {
66
+ await t.test(String(value) + ' (' + typeof value + ')', async () => {
67
+ assert.deepEqual(await renderOrder({ templateReverse: value }), regions);
68
+ assert.deepEqual(await renderOrder({ withSource: true, sourceReverse: value }), regions);
69
+ });
70
+ }
71
+ });
72
+
73
+ test('explicit reverse settings still reverse the list', async t => {
74
+ for (const value of ['', true, 'true']) {
75
+ await t.test(JSON.stringify(value), async () => {
76
+ const expected = [...regions].reverse();
77
+ assert.deepEqual(await renderOrder({ templateReverse: value }), expected);
78
+ assert.deepEqual(await renderOrder({ withSource: true, sourceReverse: value }), expected);
79
+ });
80
+ }
81
+ });
82
+
83
+ test('template reverse settings apply when the source attribute is absent', async () => {
84
+ assert.deepEqual(await renderOrder({ withSource: true, templateReverse: '' }), [...regions].reverse());
85
+ });
86
+
87
+ test('an explicit source setting takes precedence over the template', async () => {
88
+ assert.deepEqual(await renderOrder({ withSource: true, sourceReverse: 'false', templateReverse: '' }), regions);
89
+ assert.deepEqual(await renderOrder({ withSource: true, sourceReverse: '', templateReverse: 'false' }), [...regions].reverse());
90
+ });
@@ -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,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -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: '&lt;p&gt;&copy; APIs&lt;/p&gt;' })
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;}