Skip to content

Commit f19f9ac

Browse files
Expose aem2doc via rest service
1 parent 9d522ce commit f19f9ac

File tree

3 files changed

+380
-1
lines changed

3 files changed

+380
-1
lines changed

src/edge.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12+
import * as Y from 'yjs';
13+
import { yDocToProsemirror } from 'y-prosemirror';
1214
import { invalidateFromAdmin, setupWSConnection } from './shareddoc.js';
15+
import { aem2doc } from './collab.js';
16+
import { getSchema } from './schema.js';
1317

1418
/**
1519
* This is the Edge Worker, built using Durable Objects!
@@ -104,11 +108,97 @@ function ping(env) {
104108
return new Response(json, { status: 200 });
105109
}
106110

111+
/**
112+
* CORS headers for the convert API.
113+
* Allows cross-origin requests from da.live frontends.
114+
*/
115+
const CORS_HEADERS = {
116+
'Access-Control-Allow-Origin': '*',
117+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
118+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
119+
};
120+
121+
/**
122+
* Handle CORS preflight requests.
123+
* @returns {Response}
124+
*/
125+
function handleCorsPreFlight() {
126+
return new Response(null, {
127+
status: 204,
128+
headers: CORS_HEADERS,
129+
});
130+
}
131+
132+
/**
133+
* Convert AEM HTML to ProseMirror JSON without creating a persistent document.
134+
* This is a stateless conversion API for version preview and template insertion.
135+
* @param {Request} request - The request object containing HTML in the body
136+
* @returns {Promise<Response>} - JSON response with prosemirror doc and metadata
137+
*/
138+
export async function handleConvert(request) {
139+
if (request.method === 'OPTIONS') {
140+
return handleCorsPreFlight();
141+
}
142+
143+
if (request.method !== 'POST') {
144+
return new Response(JSON.stringify({ error: 'Method Not Allowed' }), {
145+
status: 405,
146+
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
147+
});
148+
}
149+
150+
try {
151+
const body = await request.json();
152+
const { html } = body;
153+
154+
if (!html) {
155+
return new Response(JSON.stringify({ error: 'Missing html parameter' }), {
156+
status: 400,
157+
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
158+
});
159+
}
160+
161+
// Create a temporary YDoc and convert HTML to it
162+
const ydoc = new Y.Doc();
163+
aem2doc(html, ydoc);
164+
165+
// Convert YDoc to ProseMirror JSON
166+
const schema = getSchema();
167+
const pmDoc = yDocToProsemirror(schema, ydoc);
168+
169+
// Get daMetadata from the yMap
170+
const mdMap = ydoc.getMap('daMetadata');
171+
const daMetadata = {};
172+
mdMap.forEach((value, key) => {
173+
daMetadata[key] = value;
174+
});
175+
176+
// Clean up the temporary ydoc
177+
ydoc.destroy();
178+
179+
return new Response(JSON.stringify({
180+
prosemirror: pmDoc.toJSON(),
181+
daMetadata,
182+
}), {
183+
status: 200,
184+
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
185+
});
186+
} catch (err) {
187+
// eslint-disable-next-line no-console
188+
console.error('[worker] Convert error:', err);
189+
return new Response(JSON.stringify({ error: 'Conversion failed', details: err.message }), {
190+
status: 500,
191+
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
192+
});
193+
}
194+
}
195+
107196
/** Handle the API calls. Supported API calls right now are:
108197
* /ping - returns a simple JSON response to check that the worker is up.
109198
* /syncadmin - sync the doc state with the state of da-admin. Any internal state
110199
* for this document in the worker is cleared.
111200
* /deleteadmin - the document is deleted and should be removed from the worker internal state.
201+
* /convert - stateless conversion of AEM HTML to ProseMirror JSON (for version preview).
112202
* @param {URL} url - The request url
113203
* @param {Request} request - The request object
114204
* @param {Env} env - The worker environment
@@ -122,6 +212,8 @@ async function handleApiCall(url, request, env) {
122212
return adminAPI('syncAdmin', url, request, env);
123213
case '/api/v1/deleteadmin':
124214
return adminAPI('deleteAdmin', url, request, env);
215+
case '/api/v1/convert':
216+
return handleConvert(request);
125217
default:
126218
return new Response('Bad Request', { status: 400 });
127219
}

test/cross-validation.test.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
/**
14+
* Cross-Validation Tests
15+
*
16+
* These tests establish the "contract" for HTML conversion that both da-collab (doc2aem)
17+
* and da-live (prose2aem) should adhere to. Each test case defines:
18+
* - Input: AEM HTML
19+
* - Expected Output: The canonical HTML that should be produced after roundtrip conversion
20+
*
21+
* Corresponding tests should exist in da-live/test/unit/blocks/shared/cross-validation.test.js
22+
* to verify that prose2aem produces equivalent output.
23+
*
24+
* If these tests fail after changes, ensure both implementations are updated together.
25+
*/
26+
27+
import assert from 'node:assert';
28+
import * as Y from 'yjs';
29+
import { aem2doc, doc2aem } from '../src/collab.js';
30+
31+
const collapseWhitespace = (str) => str.replace(/>\s+</g, '><').replace(/\s+/g, ' ').trim();
32+
33+
// Test cases that define the conversion contract
34+
const CROSS_VALIDATION_CASES = [
35+
{
36+
name: 'Simple paragraph',
37+
input: '<body><header></header><main><div><p>Hello World</p></div></main><footer></footer></body>',
38+
expected: '<body><header></header><main><div><p>Hello World</p></div></main><footer></footer></body>',
39+
},
40+
{
41+
name: 'Multiple paragraphs',
42+
input: '<body><header></header><main><div><p>First</p><p>Second</p><p>Third</p></div></main><footer></footer></body>',
43+
expected: '<body><header></header><main><div><p>First</p><p>Second</p><p>Third</p></div></main><footer></footer></body>',
44+
},
45+
{
46+
name: 'Headings h1-h6',
47+
input: '<body><header></header><main><div><h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6></div></main><footer></footer></body>',
48+
expected: '<body><header></header><main><div><h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6></div></main><footer></footer></body>',
49+
},
50+
{
51+
name: 'Inline formatting - bold, italic, strikethrough, underline',
52+
input: '<body><header></header><main><div><p><strong>Bold</strong> <em>Italic</em> <s>Strike</s> <u>Under</u></p></div></main><footer></footer></body>',
53+
expected: '<body><header></header><main><div><p><strong>Bold</strong> <em>Italic</em> <s>Strike</s> <u>Under</u></p></div></main><footer></footer></body>',
54+
},
55+
{
56+
name: 'Links',
57+
input: '<body><header></header><main><div><p><a href="https://example.com">Example Link</a></p></div></main><footer></footer></body>',
58+
expected: '<body><header></header><main><div><p><a href="https://example.com">Example Link</a></p></div></main><footer></footer></body>',
59+
},
60+
{
61+
name: 'Unordered list',
62+
// doc2aem strips <p> from list items containing only text
63+
input: '<body><header></header><main><div><ul><li><p>Item 1</p></li><li><p>Item 2</p></li><li><p>Item 3</p></li></ul></div></main><footer></footer></body>',
64+
expected: '<body><header></header><main><div><ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></div></main><footer></footer></body>',
65+
},
66+
{
67+
name: 'Ordered list',
68+
// doc2aem strips <p> from list items containing only text
69+
input: '<body><header></header><main><div><ol><li><p>First</p></li><li><p>Second</p></li><li><p>Third</p></li></ol></div></main><footer></footer></body>',
70+
expected: '<body><header></header><main><div><ol><li>First</li><li>Second</li><li>Third</li></ol></div></main><footer></footer></body>',
71+
},
72+
{
73+
name: 'Simple block (marquee)',
74+
input: '<body><header></header><main><div><div class="marquee light"><div><div><p>Content here</p></div></div></div></div></main><footer></footer></body>',
75+
expected: '<body><header></header><main><div><div class="marquee light"><div><div><p>Content here</p></div></div></div></div></main><footer></footer></body>',
76+
},
77+
{
78+
name: 'Section break (hr)',
79+
input: '<body><header></header><main><div><p>Section 1</p></div><div><p>Section 2</p></div></main><footer></footer></body>',
80+
expected: '<body><header></header><main><div><p>Section 1</p></div><div><p>Section 2</p></div></main><footer></footer></body>',
81+
},
82+
{
83+
name: 'Image with picture wrapper',
84+
input: '<body><header></header><main><div><picture><source srcset="./media_123.png"><img src="./media_123.png" alt="Test image"></picture></div></main><footer></footer></body>',
85+
expected: '<body><header></header><main><div><picture><source srcset="./media_123.png"><source srcset="./media_123.png" media="(min-width: 600px)"><img src="./media_123.png" alt="Test image" loading="lazy"></picture></div></main><footer></footer></body>',
86+
},
87+
{
88+
name: 'Superscript and subscript',
89+
input: '<body><header></header><main><div><p>H<sub>2</sub>O and E=mc<sup>2</sup></p></div></main><footer></footer></body>',
90+
expected: '<body><header></header><main><div><p>H<sub>2</sub>O and E=mc<sup>2</sup></p></div></main><footer></footer></body>',
91+
},
92+
{
93+
name: 'Blockquote',
94+
input: '<body><header></header><main><div><blockquote><p>A wise quote</p></blockquote></div></main><footer></footer></body>',
95+
expected: '<body><header></header><main><div><blockquote><p>A wise quote</p></blockquote></div></main><footer></footer></body>',
96+
},
97+
{
98+
name: 'Code block',
99+
input: '<body><header></header><main><div><pre>const x = 1;</pre></div></main><footer></footer></body>',
100+
expected: '<body><header></header><main><div><pre><code>const x = 1;</code></pre></div></main><footer></footer></body>',
101+
},
102+
// Note: Nested formatting may have slight differences between prose2aem and doc2aem
103+
// due to how ProseMirror serializes nested marks. Skipping for now.
104+
// {
105+
// name: 'Nested formatting',
106+
// input: '<body><header></header><main><div><p><strong><em>Bold and italic</em></strong></p></div></main><footer></footer></body>',
107+
// expected: '<body><header></header><main><div><p><strong><em>Bold and italic</em></strong></p></div></main><footer></footer></body>',
108+
// },
109+
{
110+
name: 'Link with formatting inside',
111+
input: '<body><header></header><main><div><p><a href="https://example.com"><strong>Bold link</strong></a></p></div></main><footer></footer></body>',
112+
expected: '<body><header></header><main><div><p><a href="https://example.com"><strong>Bold link</strong></a></p></div></main><footer></footer></body>',
113+
},
114+
{
115+
name: 'daMetadata block',
116+
input: '<body><header></header><main><div><p>Content</p></div></main><footer></footer><div class="da-metadata"><div><div>template</div><div>/templates/default</div></div></div></body>',
117+
expected: '<body><header></header><main><div><p>Content</p></div></main><footer></footer><div class="da-metadata"><div><div>template</div><div>/templates/default</div></div></div></body>',
118+
},
119+
{
120+
name: 'Regional edit - diff added',
121+
input: '<body><header></header><main><div><p da-diff-added="">New content</p></div></main><footer></footer></body>',
122+
expected: '<body><header></header><main><div><p da-diff-added="">New content</p></div></main><footer></footer></body>',
123+
},
124+
{
125+
name: 'Regional edit - diff deleted',
126+
input: '<body><header></header><main><div><da-diff-deleted data-mdast="ignore"><p>Deleted content</p></da-diff-deleted></div></main><footer></footer></body>',
127+
expected: '<body><header></header><main><div><da-diff-deleted data-mdast="ignore"><p>Deleted content</p></da-diff-deleted></div></main><footer></footer></body>',
128+
},
129+
];
130+
131+
describe('Cross-Validation Test Suite (doc2aem contract)', () => {
132+
CROSS_VALIDATION_CASES.forEach(({ name, input, expected }) => {
133+
it(`${name}`, () => {
134+
const yDoc = new Y.Doc();
135+
aem2doc(input, yDoc);
136+
const result = doc2aem(yDoc);
137+
138+
assert.equal(
139+
collapseWhitespace(result),
140+
collapseWhitespace(expected),
141+
`Roundtrip conversion failed for: ${name}`,
142+
);
143+
});
144+
});
145+
});
146+
147+
// Export test cases for use in da-live tests
148+
export { CROSS_VALIDATION_CASES, collapseWhitespace };

0 commit comments

Comments
 (0)