veryfront 0.1.30 → 0.1.32

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/esm/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "veryfront",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "license": "Apache-2.0",
5
5
  "nodeModulesDir": "auto",
6
6
  "exclude": [
@@ -8,6 +8,10 @@ export interface StudioScriptOptions {
8
8
  nonce?: string;
9
9
  /** Hash of source code for sync detection with Navigator tree */
10
10
  sourceHash?: string;
11
+ /** WebSocket URL for direct Yjs connection from the bridge */
12
+ wsUrl?: string;
13
+ /** Yjs document GUID for the bridge to join the same room */
14
+ yjsGuid?: string;
11
15
  }
12
16
  export declare function getStudioScripts(options: StudioScriptOptions): string;
13
17
  //# sourceMappingURL=dev-scripts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dev-scripts.d.ts","sourceRoot":"","sources":["../../../src/src/html/dev-scripts.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CA+BnD;AAMD,wBAAgB,aAAa,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAMvE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAOnE;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAerE"}
1
+ {"version":3,"file":"dev-scripts.d.ts","sourceRoot":"","sources":["../../../src/src/html/dev-scripts.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CA+BnD;AAMD,wBAAgB,aAAa,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAMvE;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAOnE;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAiBrE"}
@@ -51,6 +51,8 @@ export function getStudioScripts(options) {
51
51
  projectId: options.projectId,
52
52
  pageId: options.pageId,
53
53
  ...(options.pagePath ? { pagePath: options.pagePath } : {}),
54
+ ...(options.wsUrl ? { wsUrl: options.wsUrl } : {}),
55
+ ...(options.yjsGuid ? { yjsGuid: options.yjsGuid } : {}),
54
56
  }).toString();
55
57
  const sourceHashScript = options.sourceHash
56
58
  ? `<script${nonceAttr}>window.__VERYFRONT_SOURCE_HASH__="${options.sourceHash}";</script>\n `
@@ -15,6 +15,10 @@ export interface InjectHTMLContentOptions {
15
15
  pageId?: string;
16
16
  /** CSP nonce */
17
17
  nonce?: string;
18
+ /** WebSocket URL for direct Yjs connection from the bridge */
19
+ wsUrl?: string;
20
+ /** Yjs document GUID for the bridge to join the same room */
21
+ yjsGuid?: string;
18
22
  }
19
23
  export declare function injectHTMLContent(template: string, content: string, metadata: HTMLMetadata, options: InjectHTMLContentOptions): string;
20
24
  //# sourceMappingURL=html-injection.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"html-injection.d.ts","sourceRoot":"","sources":["../../../src/src/html/html-injection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAS/D,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gDAAgD;IAChD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,YAAY,EACtB,OAAO,EAAE,wBAAwB,GAChC,MAAM,CA0ER"}
1
+ {"version":3,"file":"html-injection.d.ts","sourceRoot":"","sources":["../../../src/src/html/html-injection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAS/D,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gDAAgD;IAChD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,YAAY,EACtB,OAAO,EAAE,wBAAwB,GAChC,MAAM,CA4ER"}
@@ -56,6 +56,8 @@ export function injectHTMLContent(template, content, metadata, options) {
56
56
  projectId: options.projectId ?? options.slug,
57
57
  pageId: options.pageId ?? options.slug,
58
58
  nonce: options.nonce,
59
+ wsUrl: options.wsUrl,
60
+ yjsGuid: options.yjsGuid,
59
61
  });
60
62
  html = html.replace(/<\/body>/i, `${studioScripts}</body>`);
61
63
  }
@@ -24,6 +24,10 @@ export interface MarkdownHtmlOptions {
24
24
  projectId: string;
25
25
  /** File path of the markdown file. */
26
26
  filePath: string;
27
+ /** Branch ID for Yjs room GUID computation. */
28
+ branchId?: string | null;
29
+ /** Request host for computing the WebSocket URL. */
30
+ requestHost?: string;
27
31
  }
28
32
  /**
29
33
  * Generate a complete HTML document for markdown preview rendering.
@@ -1 +1 @@
1
- {"version":3,"file":"markdown-html-generator.d.ts","sourceRoot":"","sources":["../../../../../src/src/server/handlers/preview/markdown-html-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAMrD,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,iDAAiD;IACjD,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC;IACzB,gDAAgD;IAChD,GAAG,EAAE,GAAG,CAAC;IACT,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAgDD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CA0FzE"}
1
+ {"version":3,"file":"markdown-html-generator.d.ts","sourceRoot":"","sources":["../../../../../src/src/server/handlers/preview/markdown-html-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAMrD,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,iDAAiD;IACjD,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC;IACzB,gDAAgD;IAChD,GAAG,EAAE,GAAG,CAAC;IACT,gDAAgD;IAChD,SAAS,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAgED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CA2FzE"}
@@ -25,19 +25,28 @@ function detectTheme(req, url) {
25
25
  * Injected when embedded in Studio (`studio_embed=true`) or for standalone
26
26
  * markdown/MDX pages so the edit button and editor features are available.
27
27
  */
28
- function buildStudioScript(url, projectId, filePath) {
28
+ function buildStudioScript(url, projectId, filePath, branchId, requestHost) {
29
29
  const studioEmbed = url.searchParams.get("studio_embed") === "true";
30
30
  const isMarkdown = /\.mdx?$/i.test(filePath);
31
31
  if (!studioEmbed && !isMarkdown)
32
32
  return "";
33
- const queryProjectId = url.searchParams.get("vf_project_id")?.trim() || "";
33
+ const rawQueryProjectId = url.searchParams.get("vf_project_id")?.trim() || "";
34
+ // Validate query param to prevent path traversal in WebSocket URL
35
+ const queryProjectId = /^[a-zA-Z0-9_-]+$/.test(rawQueryProjectId) ? rawQueryProjectId : "";
34
36
  const queryFileId = url.searchParams.get("vf_file_id")?.trim() || "";
35
37
  const canonicalProjectId = queryProjectId || projectId;
36
38
  const canonicalPageId = queryFileId || filePath;
39
+ // Compute Yjs connection config for the bridge to self-connect
40
+ const wsProtocol = url.protocol === "https:" ? "wss" : "ws";
41
+ const host = requestHost || url.host;
42
+ const wsUrl = `${wsProtocol}://${host}/api/ws/${canonicalProjectId}/yjs`;
43
+ const yjsGuid = branchId ? `${canonicalProjectId}:${branchId}` : canonicalProjectId;
37
44
  return `<script>${generateStudioBridgeScript({
38
45
  projectId: canonicalProjectId,
39
46
  pageId: canonicalPageId,
40
47
  pagePath: filePath,
48
+ wsUrl,
49
+ yjsGuid,
41
50
  })}</script>`;
42
51
  }
43
52
  /**
@@ -48,9 +57,9 @@ function buildStudioScript(url, projectId, filePath) {
48
57
  * studio bridge integration.
49
58
  */
50
59
  export function generateMarkdownHtml(options) {
51
- const { rawHtml, title, description, request, url, projectId, filePath } = options;
60
+ const { rawHtml, title, description, request, url, projectId, filePath, branchId, requestHost } = options;
52
61
  const theme = detectTheme(request, url);
53
- const studioScript = buildStudioScript(url, projectId, filePath);
62
+ const studioScript = buildStudioScript(url, projectId, filePath, branchId, requestHost);
54
63
  const themeAttrs = theme ? ` data-theme="${theme}" style="color-scheme: ${theme};"` : "";
55
64
  return `<!DOCTYPE html>
56
65
  <html lang="en"${themeAttrs}>
@@ -1 +1 @@
1
- {"version":3,"file":"markdown-preview.handler.d.ts","sourceRoot":"","sources":["../../../../../src/src/server/handlers/preview/markdown-preview.handler.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAmB,aAAa,EAAE,MAAM,aAAa,CAAC;AAgBnG,qBAAa,sBAAuB,SAAQ,WAAW;IACrD,QAAQ,EAAE,eAAe,CAKvB;IAEI,MAAM,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;YAiEjE,cAAc;CAgF7B"}
1
+ {"version":3,"file":"markdown-preview.handler.d.ts","sourceRoot":"","sources":["../../../../../src/src/server/handlers/preview/markdown-preview.handler.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAmB,aAAa,EAAE,MAAM,aAAa,CAAC;AAgBnG,qBAAa,sBAAuB,SAAQ,WAAW;IACrD,QAAQ,EAAE,eAAe,CAKvB;IAEI,MAAM,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;YAiEjE,cAAc;CAkF7B"}
@@ -108,6 +108,8 @@ export class MarkdownPreviewHandler extends BaseHandler {
108
108
  url,
109
109
  projectId: ctx.projectSlug || ctx.projectId || "markdown-preview",
110
110
  filePath,
111
+ branchId: ctx.parsedDomain?.branch ?? null,
112
+ requestHost: url.host,
111
113
  });
112
114
  const responseBuilder = this.createResponseBuilder(ctx)
113
115
  .withCache("no-cache")
@@ -1 +1 @@
1
- {"version":3,"file":"endpoints.handler.d.ts","sourceRoot":"","sources":["../../../../../src/src/server/handlers/studio/endpoints.handler.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EAEf,aAAa,EACd,MAAM,aAAa,CAAC;AAIrB,qBAAa,sBAAuB,SAAQ,WAAW;IACrD,QAAQ,EAAE,eAAe,CAKvB;IAEF,MAAM,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;CAqB1E"}
1
+ {"version":3,"file":"endpoints.handler.d.ts","sourceRoot":"","sources":["../../../../../src/src/server/handlers/studio/endpoints.handler.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAGrD,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzD,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EAEf,aAAa,EACd,MAAM,aAAa,CAAC;AAIrB,qBAAa,sBAAuB,SAAQ,WAAW;IACrD,QAAQ,EAAE,eAAe,CAKvB;IAEF,MAAM,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;CAuB1E"}
@@ -20,7 +20,9 @@ export class StudioEndpointsHandler extends BaseHandler {
20
20
  const projectId = url.searchParams.get("projectId") ?? "";
21
21
  const pageId = url.searchParams.get("pageId") ?? "";
22
22
  const pagePath = url.searchParams.get("pagePath") ?? undefined;
23
- const script = generateStudioBridgeScript({ projectId, pageId, pagePath });
23
+ const wsUrl = url.searchParams.get("wsUrl") ?? undefined;
24
+ const yjsGuid = url.searchParams.get("yjsGuid") ?? undefined;
25
+ const script = generateStudioBridgeScript({ projectId, pageId, pagePath, wsUrl, yjsGuid });
24
26
  const response = builder.withCache("no-cache").javascript(script, HTTP_OK);
25
27
  return Promise.resolve(this.respond(response));
26
28
  }
@@ -2,6 +2,8 @@ export interface StudioBridgeOptions {
2
2
  projectId: string;
3
3
  pageId: string;
4
4
  pagePath?: string;
5
+ wsUrl?: string;
6
+ yjsGuid?: string;
5
7
  debugSkipInit?: boolean;
6
8
  debugExposeInternals?: boolean;
7
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"bridge-template.d.ts","sourceRoot":"","sources":["../../../src/src/studio/bridge-template.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAigI/E"}
1
+ {"version":3,"file":"bridge-template.d.ts","sourceRoot":"","sources":["../../../src/src/studio/bridge-template.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAsvI/E"}
@@ -5,6 +5,8 @@ export function generateStudioBridgeScript(options) {
5
5
  const PROJECT_ID = ${JSON.stringify(options.projectId)};
6
6
  const PAGE_ID = ${JSON.stringify(options.pageId)};
7
7
  const PAGE_PATH = ${JSON.stringify(options.pagePath ?? options.pageId)};
8
+ const WS_URL = ${JSON.stringify(options.wsUrl ?? "")};
9
+ const YJS_GUID = ${JSON.stringify(options.yjsGuid ?? "")};
8
10
  const DEBUG_SKIP_INIT = ${options.debugSkipInit ? "true" : "false"};
9
11
  const DEBUG_EXPOSE_INTERNALS = ${options.debugExposeInternals ? "true" : "false"};
10
12
 
@@ -70,6 +72,14 @@ export function generateStudioBridgeScript(options) {
70
72
  let markdownLatestSelections = [];
71
73
  let markdownHasUnsavedChanges = false;
72
74
  let markdownSaveInProgress = false;
75
+ let markdownYDoc = null;
76
+ let markdownYProvider = null;
77
+ let markdownYText = null;
78
+ let markdownYjsConnected = false;
79
+ let markdownYjsSetupId = 0;
80
+ let markdownYjsY = null;
81
+ let markdownPendingSelection = null;
82
+ const LEXICAL_YJS_ORIGIN = 'lexical-yjs-binding';
73
83
 
74
84
  const MARKDOWN_SLASH_COMMANDS = [
75
85
  {
@@ -1410,6 +1420,247 @@ export function generateStudioBridgeScript(options) {
1410
1420
  }, 120);
1411
1421
  }
1412
1422
 
1423
+ function computeTextDiff(oldText, newText) {
1424
+ var prefixLen = 0;
1425
+ var minLen = Math.min(oldText.length, newText.length);
1426
+ while (prefixLen < minLen && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) {
1427
+ prefixLen++;
1428
+ }
1429
+ var suffixLen = 0;
1430
+ var maxSuffix = minLen - prefixLen;
1431
+ while (suffixLen < maxSuffix &&
1432
+ oldText.charCodeAt(oldText.length - 1 - suffixLen) === newText.charCodeAt(newText.length - 1 - suffixLen)) {
1433
+ suffixLen++;
1434
+ }
1435
+ return {
1436
+ index: prefixLen,
1437
+ deleteCount: oldText.length - prefixLen - suffixLen,
1438
+ insertText: newText.slice(prefixLen, suffixLen > 0 ? newText.length - suffixLen : undefined)
1439
+ };
1440
+ }
1441
+
1442
+ function syncLocalChangeToYText(fullContent) {
1443
+ if (!markdownYText || !markdownYDoc) {
1444
+ return;
1445
+ }
1446
+ var currentYContent = markdownYText.toString();
1447
+ if (currentYContent === fullContent) {
1448
+ return;
1449
+ }
1450
+ var diff = computeTextDiff(currentYContent, fullContent);
1451
+ if (diff.deleteCount === 0 && diff.insertText === '') {
1452
+ return;
1453
+ }
1454
+ markdownYDoc.transact(function() {
1455
+ if (diff.deleteCount > 0) {
1456
+ markdownYText.delete(diff.index, diff.deleteCount);
1457
+ }
1458
+ if (diff.insertText) {
1459
+ markdownYText.insert(diff.index, diff.insertText);
1460
+ }
1461
+ }, LEXICAL_YJS_ORIGIN);
1462
+ }
1463
+
1464
+ function setupMarkdownYjsConnection(config) {
1465
+ if (markdownYDoc) {
1466
+ return;
1467
+ }
1468
+
1469
+ var setupId = ++markdownYjsSetupId;
1470
+
1471
+ Promise.all([
1472
+ import('https://esm.sh/yjs@13.6.28?target=es2022'),
1473
+ import('https://esm.sh/y-websocket@2.1.0?deps=yjs@13.6.28&target=es2022')
1474
+ ]).then(function(modules) {
1475
+ // Abort if edit mode was closed while imports were loading
1476
+ if (setupId !== markdownYjsSetupId) {
1477
+ return;
1478
+ }
1479
+
1480
+ var Y = modules[0];
1481
+ var WebsocketProvider = modules[1].WebsocketProvider;
1482
+ markdownYjsY = Y;
1483
+
1484
+ var doc = new Y.Doc({ guid: config.guid });
1485
+ // Cookie auth: authToken cookie on .veryfront.com is sent automatically
1486
+ // with the WebSocket upgrade request. No explicit token param needed.
1487
+ var provider = new WebsocketProvider(config.wsUrl, config.guid, doc, {
1488
+ resyncInterval: -1
1489
+ });
1490
+
1491
+ var ytext = doc.getText(config.fileId);
1492
+
1493
+ markdownYDoc = doc;
1494
+ markdownYProvider = provider;
1495
+ markdownYText = ytext;
1496
+
1497
+ // Filter non-binary messages to prevent y-websocket parse errors
1498
+ provider.on('status', function(event) {
1499
+ console.debug('[StudioBridge] Yjs status:', event.status);
1500
+ if (event.status === 'connected' && provider.ws) {
1501
+ var origOnMessage = provider.ws.onmessage;
1502
+ provider.ws.onmessage = function(wsEvent) {
1503
+ if (typeof wsEvent.data === 'string') {
1504
+ return;
1505
+ }
1506
+ if (origOnMessage) {
1507
+ origOnMessage.call(provider.ws, wsEvent);
1508
+ }
1509
+ };
1510
+ }
1511
+ });
1512
+
1513
+ // Extract user identity from authToken JWT cookie for presence
1514
+ var presenceUser = { id: 'preview-' + Math.random().toString(36).slice(2), name: 'Preview' };
1515
+ try {
1516
+ var cookieMatch = document.cookie.match(/authToken=([^;]+)/);
1517
+ if (cookieMatch) {
1518
+ var parts = cookieMatch[1].split('.');
1519
+ if (parts.length === 3) {
1520
+ var payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
1521
+ if (payload.userId) {
1522
+ presenceUser.id = payload.userId;
1523
+ }
1524
+ if (payload.email) {
1525
+ var local = payload.email.split('@')[0] || '';
1526
+ if (local.includes('.') || local.includes('_')) {
1527
+ presenceUser.name = local.split(/[._]/).map(function(p) { return p.charAt(0).toUpperCase() + p.slice(1); }).join(' ');
1528
+ } else {
1529
+ presenceUser.name = local.charAt(0).toUpperCase() + local.slice(1);
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ } catch (e) {
1535
+ // Fall back to defaults on any parse error
1536
+ }
1537
+
1538
+ // Set local user on awareness for presence
1539
+ provider.awareness.setLocalStateField('user', {
1540
+ id: presenceUser.id,
1541
+ name: presenceUser.name,
1542
+ color: '#10b981'
1543
+ });
1544
+
1545
+ // Observe awareness for remote presence and selection changes
1546
+ function syncAwareness() {
1547
+ var states = Array.from(provider.awareness.getStates().entries());
1548
+
1549
+ // Sync presence users
1550
+ var users = [];
1551
+ for (var i = 0; i < states.length; i++) {
1552
+ var clientId = states[i][0];
1553
+ var state = states[i][1];
1554
+ var user = state.user;
1555
+ if (!user || typeof user.name !== 'string') {
1556
+ continue;
1557
+ }
1558
+ users.push({
1559
+ id: user.id || String(clientId),
1560
+ name: user.name,
1561
+ color: user.color || '#6b7280',
1562
+ isCurrentUser: clientId === provider.awareness.clientID,
1563
+ isAgent: user.isAgent || false
1564
+ });
1565
+ }
1566
+ setMarkdownPresence(users);
1567
+
1568
+ // Sync remote selections
1569
+ var selections = [];
1570
+ for (var j = 0; j < states.length; j++) {
1571
+ var cId = states[j][0];
1572
+ var st = states[j][1];
1573
+ var u = st.user;
1574
+ var ranges = st.selection;
1575
+ if (!u || !Array.isArray(ranges) || ranges.length === 0) {
1576
+ continue;
1577
+ }
1578
+ for (var k = 0; k < ranges.length; k++) {
1579
+ var range = ranges[k];
1580
+ var anchorPos = Y.createAbsolutePositionFromRelativePosition(range.anchor, doc);
1581
+ var markerPos = Y.createAbsolutePositionFromRelativePosition(range.marker, doc);
1582
+ if (!anchorPos || !markerPos || anchorPos.type !== ytext || markerPos.type !== ytext) {
1583
+ continue;
1584
+ }
1585
+ selections.push({
1586
+ id: u.id || String(cId),
1587
+ name: u.name || 'Anonymous',
1588
+ color: u.color || '#6b7280',
1589
+ isCurrentUser: cId === provider.awareness.clientID,
1590
+ start: Math.min(anchorPos.index, markerPos.index),
1591
+ end: Math.max(anchorPos.index, markerPos.index)
1592
+ });
1593
+ }
1594
+ }
1595
+ setMarkdownSelections(selections);
1596
+ }
1597
+
1598
+ provider.awareness.on('change', syncAwareness);
1599
+
1600
+ provider.on('sync', function(synced) {
1601
+ if (synced && !markdownYjsConnected) {
1602
+ markdownYjsConnected = true;
1603
+
1604
+ var ytextContent = ytext.toString();
1605
+ if (markdownCurrentContent && markdownCurrentContent !== ytextContent) {
1606
+ // User typed before sync completed — push local edits to Y.Text
1607
+ syncLocalChangeToYText(markdownCurrentContent);
1608
+ } else if (ytextContent) {
1609
+ // No local edits — seed editor from Y.Text
1610
+ applyMarkdownContent(ytextContent);
1611
+ }
1612
+
1613
+ // Replay any selection queued before Yjs was ready
1614
+ if (markdownPendingSelection) {
1615
+ var ps = markdownPendingSelection;
1616
+ markdownPendingSelection = null;
1617
+ var cs = Math.max(0, Math.min(ytext.length, ps.start));
1618
+ var ce = Math.max(0, Math.min(ytext.length, ps.end));
1619
+ provider.awareness.setLocalStateField('selection', [{
1620
+ anchor: Y.createRelativePositionFromTypeIndex(ytext, cs),
1621
+ marker: Y.createRelativePositionFromTypeIndex(ytext, ce)
1622
+ }]);
1623
+ }
1624
+
1625
+ // Observe Y.Text for remote changes (from other users / Monaco)
1626
+ ytext.observe(function(event) {
1627
+ if (event.transaction.origin === LEXICAL_YJS_ORIGIN) {
1628
+ return;
1629
+ }
1630
+ var fullContent = ytext.toString();
1631
+ if (fullContent === markdownCurrentContent) {
1632
+ return;
1633
+ }
1634
+ applyMarkdownContent(fullContent);
1635
+ });
1636
+
1637
+ // Initial awareness sync after Yjs is connected
1638
+ syncAwareness();
1639
+
1640
+ console.debug('[StudioBridge] Yjs synced, bound to Y.Text for fileId:', config.fileId);
1641
+ }
1642
+ });
1643
+ }).catch(function(error) {
1644
+ console.error('[StudioBridge] Failed to setup Yjs connection:', error);
1645
+ });
1646
+ }
1647
+
1648
+ function disposeMarkdownYjs() {
1649
+ markdownYjsSetupId++;
1650
+ if (markdownYProvider) {
1651
+ markdownYProvider.disconnect();
1652
+ markdownYProvider.destroy();
1653
+ markdownYProvider = null;
1654
+ }
1655
+ if (markdownYDoc) {
1656
+ markdownYDoc.destroy();
1657
+ markdownYDoc = null;
1658
+ }
1659
+ markdownYText = null;
1660
+ markdownYjsConnected = false;
1661
+ markdownYjsY = null;
1662
+ }
1663
+
1413
1664
  function getTextOffsetWithinRoot(root, targetNode, targetOffset) {
1414
1665
  if (!root || !targetNode) {
1415
1666
  return 0;
@@ -2495,27 +2746,26 @@ export function generateStudioBridgeScript(options) {
2495
2746
  const start = editorOffsetToSourceOffset(selection.start, 'start');
2496
2747
  const end = editorOffsetToSourceOffset(selection.end, 'end');
2497
2748
 
2498
- postToStudio({
2499
- action: 'markdownSelectionChange',
2500
- fileId: markdownFileId,
2501
- filePath: PAGE_PATH,
2502
- start: start,
2503
- end: end
2504
- });
2749
+ // Set local selection on Yjs awareness directly
2750
+ if (markdownYjsConnected && markdownYText && markdownYjsY && markdownYProvider) {
2751
+ var clampedStart = Math.max(0, Math.min(markdownYText.length, start));
2752
+ var clampedEnd = Math.max(0, Math.min(markdownYText.length, end));
2753
+ markdownYProvider.awareness.setLocalStateField('selection', [{
2754
+ anchor: markdownYjsY.createRelativePositionFromTypeIndex(markdownYText, clampedStart),
2755
+ marker: markdownYjsY.createRelativePositionFromTypeIndex(markdownYText, clampedEnd)
2756
+ }]);
2757
+ markdownPendingSelection = null;
2758
+ } else {
2759
+ // Queue selection for replay after Yjs connects
2760
+ markdownPendingSelection = { start: start, end: end };
2761
+ }
2505
2762
  }, 80);
2506
2763
  }
2507
2764
 
2508
2765
  function clearMarkdownSelectionSync() {
2509
- if (!markdownFileId) {
2510
- return;
2766
+ if (markdownYProvider) {
2767
+ markdownYProvider.awareness.setLocalStateField('selection', null);
2511
2768
  }
2512
- postToStudio({
2513
- action: 'markdownSelectionChange',
2514
- fileId: markdownFileId,
2515
- filePath: PAGE_PATH,
2516
- start: -1,
2517
- end: -1
2518
- });
2519
2769
  }
2520
2770
 
2521
2771
  function clearMarkdownSelectionOverlay() {
@@ -2909,6 +3159,9 @@ export function generateStudioBridgeScript(options) {
2909
3159
  }
2910
3160
  markdownCurrentContent = fullContent;
2911
3161
  markdownHasUnsavedChanges = true;
3162
+ if (markdownYjsConnected) {
3163
+ syncLocalChangeToYText(fullContent);
3164
+ }
2912
3165
  scheduleMarkdownSync(fullContent);
2913
3166
  scheduleMarkdownSelectionOverlayRender();
2914
3167
  }
@@ -3070,7 +3323,7 @@ export function generateStudioBridgeScript(options) {
3070
3323
  return;
3071
3324
  }
3072
3325
 
3073
- if (markdownLexicalApi && (markdownLexicalRenderedContent === content || markdownCurrentContent === content)) {
3326
+ if (markdownLexicalApi && markdownLexicalRenderedContent === content) {
3074
3327
  console.debug('[StudioBridge] applyMarkdownContent: skipped (content unchanged)');
3075
3328
  markdownCurrentContent = content;
3076
3329
  scheduleMarkdownSelectionOverlayRender();
@@ -3700,6 +3953,15 @@ export function generateStudioBridgeScript(options) {
3700
3953
  scheduleMarkdownSlashMenuUpdate();
3701
3954
  scheduleMarkdownInlineToolbarUpdate();
3702
3955
  postMarkdownEditorReady();
3956
+
3957
+ // Self-connect to Yjs when server-injected config is available
3958
+ if (WS_URL && YJS_GUID && !markdownYDoc) {
3959
+ setupMarkdownYjsConnection({
3960
+ wsUrl: WS_URL,
3961
+ guid: YJS_GUID,
3962
+ fileId: markdownFileId
3963
+ });
3964
+ }
3703
3965
  } else {
3704
3966
  markdownBody.style.display = '';
3705
3967
  if (markdownEditorRoot) {
@@ -3711,6 +3973,7 @@ export function generateStudioBridgeScript(options) {
3711
3973
  markdownOverlaySelections = [];
3712
3974
  clearMarkdownSelectionOverlay();
3713
3975
  clearMarkdownSelectionSync();
3976
+ disposeMarkdownYjs();
3714
3977
  }
3715
3978
 
3716
3979
  const nextUrl = new URL(window.location.href);
@@ -3934,16 +4197,6 @@ export function generateStudioBridgeScript(options) {
3934
4197
  if (!inspectMode) showHoverOverlay(message.id);
3935
4198
  return;
3936
4199
 
3937
- case 'setMarkdownContent':
3938
- if (!isMarkdownPage()) {
3939
- return;
3940
- }
3941
- if (message.fileId && markdownFileId && message.fileId !== markdownFileId) {
3942
- return;
3943
- }
3944
- applyMarkdownContent(message.content || '');
3945
- return;
3946
-
3947
4200
  case 'setMarkdownPersistState':
3948
4201
  if (!isMarkdownPage()) {
3949
4202
  return;
@@ -3960,26 +4213,6 @@ export function generateStudioBridgeScript(options) {
3960
4213
  }
3961
4214
  return;
3962
4215
 
3963
- case 'setMarkdownPresence':
3964
- if (!isMarkdownPage()) {
3965
- return;
3966
- }
3967
- if (message.fileId && markdownFileId && message.fileId !== markdownFileId) {
3968
- return;
3969
- }
3970
- setMarkdownPresence(message.users);
3971
- return;
3972
-
3973
- case 'setMarkdownSelections':
3974
- if (!isMarkdownPage()) {
3975
- return;
3976
- }
3977
- if (message.fileId && markdownFileId && message.fileId !== markdownFileId) {
3978
- return;
3979
- }
3980
- setMarkdownSelections(message.selections);
3981
- return;
3982
-
3983
4216
  case 'screenshot':
3984
4217
  (async function() {
3985
4218
  if (message.multipleSections) {
@@ -4037,47 +4270,59 @@ export function generateStudioBridgeScript(options) {
4037
4270
  function init() {
4038
4271
  const params = new URLSearchParams(window.location.search);
4039
4272
  const studioEmbed = params.get('studio_embed') === 'true';
4273
+ const isStandalone = window.parent === window && !studioEmbed;
4040
4274
 
4041
- if (window.parent === window && !studioEmbed) {
4042
- console.debug('[StudioBridge] Not in iframe and not studio_embed mode, skipping initialization');
4043
- return;
4275
+ if (isStandalone) {
4276
+ // Allow standalone markdown editing when WS_URL is available (server-injected Yjs config)
4277
+ if (!WS_URL) {
4278
+ console.debug('[StudioBridge] Not in iframe and not studio_embed mode, skipping initialization');
4279
+ return;
4280
+ }
4044
4281
  }
4045
4282
 
4046
4283
  console.debug('[StudioBridge] Initializing...');
4047
4284
 
4048
- injectOverlayStyles();
4049
- hoverOverlay = createOverlay('hover');
4050
- selectionOverlay = createOverlay('selection');
4285
+ // Only set up Studio interaction features when embedded in Studio
4286
+ if (!isStandalone) {
4287
+ injectOverlayStyles();
4288
+ hoverOverlay = createOverlay('hover');
4289
+ selectionOverlay = createOverlay('selection');
4290
+
4291
+ setupConsoleCapture();
4292
+ setupErrorHandling();
4293
+ setupInspectMode();
4294
+ }
4051
4295
 
4052
- setupConsoleCapture();
4053
- setupErrorHandling();
4054
- setupInspectMode();
4055
4296
  setupMarkdownEditor(params);
4056
4297
 
4057
4298
  window.addEventListener('message', handleStudioMessage);
4058
4299
 
4059
- // IMPORTANT: notifyAppLoaded() must be called BEFORE setupMutationObserver()
4060
- // because notifyAppLoaded sends onPageTransitionEnd which sets previewId,
4061
- // and treeUpdated (from setupMutationObserver) requires previewId to be set
4062
- if (document.readyState === 'loading') {
4063
- document.addEventListener('DOMContentLoaded', function() {
4300
+ if (!isStandalone) {
4301
+ // IMPORTANT: notifyAppLoaded() must be called BEFORE setupMutationObserver()
4302
+ // because notifyAppLoaded sends onPageTransitionEnd which sets previewId,
4303
+ // and treeUpdated (from setupMutationObserver) requires previewId to be set
4304
+ if (document.readyState === 'loading') {
4305
+ document.addEventListener('DOMContentLoaded', function() {
4306
+ notifyAppLoaded();
4307
+ setupMutationObserver();
4308
+ });
4309
+ } else {
4064
4310
  notifyAppLoaded();
4065
4311
  setupMutationObserver();
4066
- });
4067
- } else {
4068
- notifyAppLoaded();
4069
- setupMutationObserver();
4070
- }
4312
+ }
4071
4313
 
4072
- window.addEventListener('beforeunload', notifyAppUnloaded);
4314
+ window.addEventListener('beforeunload', notifyAppUnloaded);
4315
+ }
4073
4316
 
4074
4317
  const colorMode = params.get('color_mode');
4075
4318
  if (colorMode) setColorMode(colorMode);
4076
4319
 
4077
- const inspectModeParam = params.get('inspect_mode');
4078
- if (inspectModeParam === 'true') {
4079
- inspectMode = true;
4080
- console.debug('[StudioBridge] Inspect mode enabled from query param');
4320
+ if (!isStandalone) {
4321
+ const inspectModeParam = params.get('inspect_mode');
4322
+ if (inspectModeParam === 'true') {
4323
+ inspectMode = true;
4324
+ console.debug('[StudioBridge] Inspect mode enabled from query param');
4325
+ }
4081
4326
  }
4082
4327
 
4083
4328
  console.debug('[StudioBridge] Initialized successfully');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veryfront",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "The simplest way to build AI-powered apps",
5
5
  "keywords": [
6
6
  "react",
package/src/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "veryfront",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "license": "Apache-2.0",
5
5
  "nodeModulesDir": "auto",
6
6
  "exclude": [
@@ -59,6 +59,10 @@ export interface StudioScriptOptions {
59
59
  nonce?: string;
60
60
  /** Hash of source code for sync detection with Navigator tree */
61
61
  sourceHash?: string;
62
+ /** WebSocket URL for direct Yjs connection from the bridge */
63
+ wsUrl?: string;
64
+ /** Yjs document GUID for the bridge to join the same room */
65
+ yjsGuid?: string;
62
66
  }
63
67
 
64
68
  export function getStudioScripts(options: StudioScriptOptions): string {
@@ -68,6 +72,8 @@ export function getStudioScripts(options: StudioScriptOptions): string {
68
72
  projectId: options.projectId,
69
73
  pageId: options.pageId,
70
74
  ...(options.pagePath ? { pagePath: options.pagePath } : {}),
75
+ ...(options.wsUrl ? { wsUrl: options.wsUrl } : {}),
76
+ ...(options.yjsGuid ? { yjsGuid: options.yjsGuid } : {}),
71
77
  }).toString();
72
78
 
73
79
  const sourceHashScript = options.sourceHash
@@ -23,6 +23,10 @@ export interface InjectHTMLContentOptions {
23
23
  pageId?: string;
24
24
  /** CSP nonce */
25
25
  nonce?: string;
26
+ /** WebSocket URL for direct Yjs connection from the bridge */
27
+ wsUrl?: string;
28
+ /** Yjs document GUID for the bridge to join the same room */
29
+ yjsGuid?: string;
26
30
  }
27
31
 
28
32
  export function injectHTMLContent(
@@ -99,6 +103,8 @@ export function injectHTMLContent(
99
103
  projectId: options.projectId ?? options.slug,
100
104
  pageId: options.pageId ?? options.slug,
101
105
  nonce: options.nonce,
106
+ wsUrl: options.wsUrl,
107
+ yjsGuid: options.yjsGuid,
102
108
  });
103
109
  html = html.replace(/<\/body>/i, `${studioScripts}</body>`);
104
110
  }
@@ -29,6 +29,10 @@ export interface MarkdownHtmlOptions {
29
29
  projectId: string;
30
30
  /** File path of the markdown file. */
31
31
  filePath: string;
32
+ /** Branch ID for Yjs room GUID computation. */
33
+ branchId?: string | null;
34
+ /** Request host for computing the WebSocket URL. */
35
+ requestHost?: string;
32
36
  }
33
37
 
34
38
  /**
@@ -58,21 +62,37 @@ function detectTheme(req: dntShim.Request, url: URL): "light" | "dark" | null {
58
62
  * Injected when embedded in Studio (`studio_embed=true`) or for standalone
59
63
  * markdown/MDX pages so the edit button and editor features are available.
60
64
  */
61
- function buildStudioScript(url: URL, projectId: string, filePath: string): string {
65
+ function buildStudioScript(
66
+ url: URL,
67
+ projectId: string,
68
+ filePath: string,
69
+ branchId?: string | null,
70
+ requestHost?: string,
71
+ ): string {
62
72
  const studioEmbed = url.searchParams.get("studio_embed") === "true";
63
73
  const isMarkdown = /\.mdx?$/i.test(filePath);
64
74
  if (!studioEmbed && !isMarkdown) return "";
65
75
 
66
- const queryProjectId = url.searchParams.get("vf_project_id")?.trim() || "";
76
+ const rawQueryProjectId = url.searchParams.get("vf_project_id")?.trim() || "";
77
+ // Validate query param to prevent path traversal in WebSocket URL
78
+ const queryProjectId = /^[a-zA-Z0-9_-]+$/.test(rawQueryProjectId) ? rawQueryProjectId : "";
67
79
  const queryFileId = url.searchParams.get("vf_file_id")?.trim() || "";
68
80
  const canonicalProjectId = queryProjectId || projectId;
69
81
  const canonicalPageId = queryFileId || filePath;
70
82
 
83
+ // Compute Yjs connection config for the bridge to self-connect
84
+ const wsProtocol = url.protocol === "https:" ? "wss" : "ws";
85
+ const host = requestHost || url.host;
86
+ const wsUrl = `${wsProtocol}://${host}/api/ws/${canonicalProjectId}/yjs`;
87
+ const yjsGuid = branchId ? `${canonicalProjectId}:${branchId}` : canonicalProjectId;
88
+
71
89
  return `<script>${
72
90
  generateStudioBridgeScript({
73
91
  projectId: canonicalProjectId,
74
92
  pageId: canonicalPageId,
75
93
  pagePath: filePath,
94
+ wsUrl,
95
+ yjsGuid,
76
96
  })
77
97
  }</script>`;
78
98
  }
@@ -85,10 +105,11 @@ function buildStudioScript(url: URL, projectId: string, filePath: string): strin
85
105
  * studio bridge integration.
86
106
  */
87
107
  export function generateMarkdownHtml(options: MarkdownHtmlOptions): string {
88
- const { rawHtml, title, description, request, url, projectId, filePath } = options;
108
+ const { rawHtml, title, description, request, url, projectId, filePath, branchId, requestHost } =
109
+ options;
89
110
 
90
111
  const theme = detectTheme(request, url);
91
- const studioScript = buildStudioScript(url, projectId, filePath);
112
+ const studioScript = buildStudioScript(url, projectId, filePath, branchId, requestHost);
92
113
  const themeAttrs = theme ? ` data-theme="${theme}" style="color-scheme: ${theme};"` : "";
93
114
 
94
115
  return `<!DOCTYPE html>
@@ -159,6 +159,8 @@ export class MarkdownPreviewHandler extends BaseHandler {
159
159
  url,
160
160
  projectId: ctx.projectSlug || ctx.projectId || "markdown-preview",
161
161
  filePath,
162
+ branchId: ctx.parsedDomain?.branch ?? null,
163
+ requestHost: url.host,
162
164
  });
163
165
 
164
166
  const responseBuilder = this.createResponseBuilder(ctx)
@@ -38,8 +38,10 @@ export class StudioEndpointsHandler extends BaseHandler {
38
38
  const projectId = url.searchParams.get("projectId") ?? "";
39
39
  const pageId = url.searchParams.get("pageId") ?? "";
40
40
  const pagePath = url.searchParams.get("pagePath") ?? undefined;
41
+ const wsUrl = url.searchParams.get("wsUrl") ?? undefined;
42
+ const yjsGuid = url.searchParams.get("yjsGuid") ?? undefined;
41
43
 
42
- const script = generateStudioBridgeScript({ projectId, pageId, pagePath });
44
+ const script = generateStudioBridgeScript({ projectId, pageId, pagePath, wsUrl, yjsGuid });
43
45
  const response = builder.withCache("no-cache").javascript(script, HTTP_OK);
44
46
 
45
47
  return Promise.resolve(this.respond(response));
@@ -2,6 +2,8 @@ export interface StudioBridgeOptions {
2
2
  projectId: string;
3
3
  pageId: string;
4
4
  pagePath?: string;
5
+ wsUrl?: string;
6
+ yjsGuid?: string;
5
7
  debugSkipInit?: boolean;
6
8
  debugExposeInternals?: boolean;
7
9
  }
@@ -13,6 +15,8 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
13
15
  const PROJECT_ID = ${JSON.stringify(options.projectId)};
14
16
  const PAGE_ID = ${JSON.stringify(options.pageId)};
15
17
  const PAGE_PATH = ${JSON.stringify(options.pagePath ?? options.pageId)};
18
+ const WS_URL = ${JSON.stringify(options.wsUrl ?? "")};
19
+ const YJS_GUID = ${JSON.stringify(options.yjsGuid ?? "")};
16
20
  const DEBUG_SKIP_INIT = ${options.debugSkipInit ? "true" : "false"};
17
21
  const DEBUG_EXPOSE_INTERNALS = ${options.debugExposeInternals ? "true" : "false"};
18
22
 
@@ -78,6 +82,14 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
78
82
  let markdownLatestSelections = [];
79
83
  let markdownHasUnsavedChanges = false;
80
84
  let markdownSaveInProgress = false;
85
+ let markdownYDoc = null;
86
+ let markdownYProvider = null;
87
+ let markdownYText = null;
88
+ let markdownYjsConnected = false;
89
+ let markdownYjsSetupId = 0;
90
+ let markdownYjsY = null;
91
+ let markdownPendingSelection = null;
92
+ const LEXICAL_YJS_ORIGIN = 'lexical-yjs-binding';
81
93
 
82
94
  const MARKDOWN_SLASH_COMMANDS = [
83
95
  {
@@ -1418,6 +1430,247 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
1418
1430
  }, 120);
1419
1431
  }
1420
1432
 
1433
+ function computeTextDiff(oldText, newText) {
1434
+ var prefixLen = 0;
1435
+ var minLen = Math.min(oldText.length, newText.length);
1436
+ while (prefixLen < minLen && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) {
1437
+ prefixLen++;
1438
+ }
1439
+ var suffixLen = 0;
1440
+ var maxSuffix = minLen - prefixLen;
1441
+ while (suffixLen < maxSuffix &&
1442
+ oldText.charCodeAt(oldText.length - 1 - suffixLen) === newText.charCodeAt(newText.length - 1 - suffixLen)) {
1443
+ suffixLen++;
1444
+ }
1445
+ return {
1446
+ index: prefixLen,
1447
+ deleteCount: oldText.length - prefixLen - suffixLen,
1448
+ insertText: newText.slice(prefixLen, suffixLen > 0 ? newText.length - suffixLen : undefined)
1449
+ };
1450
+ }
1451
+
1452
+ function syncLocalChangeToYText(fullContent) {
1453
+ if (!markdownYText || !markdownYDoc) {
1454
+ return;
1455
+ }
1456
+ var currentYContent = markdownYText.toString();
1457
+ if (currentYContent === fullContent) {
1458
+ return;
1459
+ }
1460
+ var diff = computeTextDiff(currentYContent, fullContent);
1461
+ if (diff.deleteCount === 0 && diff.insertText === '') {
1462
+ return;
1463
+ }
1464
+ markdownYDoc.transact(function() {
1465
+ if (diff.deleteCount > 0) {
1466
+ markdownYText.delete(diff.index, diff.deleteCount);
1467
+ }
1468
+ if (diff.insertText) {
1469
+ markdownYText.insert(diff.index, diff.insertText);
1470
+ }
1471
+ }, LEXICAL_YJS_ORIGIN);
1472
+ }
1473
+
1474
+ function setupMarkdownYjsConnection(config) {
1475
+ if (markdownYDoc) {
1476
+ return;
1477
+ }
1478
+
1479
+ var setupId = ++markdownYjsSetupId;
1480
+
1481
+ Promise.all([
1482
+ import('https://esm.sh/yjs@13.6.28?target=es2022'),
1483
+ import('https://esm.sh/y-websocket@2.1.0?deps=yjs@13.6.28&target=es2022')
1484
+ ]).then(function(modules) {
1485
+ // Abort if edit mode was closed while imports were loading
1486
+ if (setupId !== markdownYjsSetupId) {
1487
+ return;
1488
+ }
1489
+
1490
+ var Y = modules[0];
1491
+ var WebsocketProvider = modules[1].WebsocketProvider;
1492
+ markdownYjsY = Y;
1493
+
1494
+ var doc = new Y.Doc({ guid: config.guid });
1495
+ // Cookie auth: authToken cookie on .veryfront.com is sent automatically
1496
+ // with the WebSocket upgrade request. No explicit token param needed.
1497
+ var provider = new WebsocketProvider(config.wsUrl, config.guid, doc, {
1498
+ resyncInterval: -1
1499
+ });
1500
+
1501
+ var ytext = doc.getText(config.fileId);
1502
+
1503
+ markdownYDoc = doc;
1504
+ markdownYProvider = provider;
1505
+ markdownYText = ytext;
1506
+
1507
+ // Filter non-binary messages to prevent y-websocket parse errors
1508
+ provider.on('status', function(event) {
1509
+ console.debug('[StudioBridge] Yjs status:', event.status);
1510
+ if (event.status === 'connected' && provider.ws) {
1511
+ var origOnMessage = provider.ws.onmessage;
1512
+ provider.ws.onmessage = function(wsEvent) {
1513
+ if (typeof wsEvent.data === 'string') {
1514
+ return;
1515
+ }
1516
+ if (origOnMessage) {
1517
+ origOnMessage.call(provider.ws, wsEvent);
1518
+ }
1519
+ };
1520
+ }
1521
+ });
1522
+
1523
+ // Extract user identity from authToken JWT cookie for presence
1524
+ var presenceUser = { id: 'preview-' + Math.random().toString(36).slice(2), name: 'Preview' };
1525
+ try {
1526
+ var cookieMatch = document.cookie.match(/authToken=([^;]+)/);
1527
+ if (cookieMatch) {
1528
+ var parts = cookieMatch[1].split('.');
1529
+ if (parts.length === 3) {
1530
+ var payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
1531
+ if (payload.userId) {
1532
+ presenceUser.id = payload.userId;
1533
+ }
1534
+ if (payload.email) {
1535
+ var local = payload.email.split('@')[0] || '';
1536
+ if (local.includes('.') || local.includes('_')) {
1537
+ presenceUser.name = local.split(/[._]/).map(function(p) { return p.charAt(0).toUpperCase() + p.slice(1); }).join(' ');
1538
+ } else {
1539
+ presenceUser.name = local.charAt(0).toUpperCase() + local.slice(1);
1540
+ }
1541
+ }
1542
+ }
1543
+ }
1544
+ } catch (e) {
1545
+ // Fall back to defaults on any parse error
1546
+ }
1547
+
1548
+ // Set local user on awareness for presence
1549
+ provider.awareness.setLocalStateField('user', {
1550
+ id: presenceUser.id,
1551
+ name: presenceUser.name,
1552
+ color: '#10b981'
1553
+ });
1554
+
1555
+ // Observe awareness for remote presence and selection changes
1556
+ function syncAwareness() {
1557
+ var states = Array.from(provider.awareness.getStates().entries());
1558
+
1559
+ // Sync presence users
1560
+ var users = [];
1561
+ for (var i = 0; i < states.length; i++) {
1562
+ var clientId = states[i][0];
1563
+ var state = states[i][1];
1564
+ var user = state.user;
1565
+ if (!user || typeof user.name !== 'string') {
1566
+ continue;
1567
+ }
1568
+ users.push({
1569
+ id: user.id || String(clientId),
1570
+ name: user.name,
1571
+ color: user.color || '#6b7280',
1572
+ isCurrentUser: clientId === provider.awareness.clientID,
1573
+ isAgent: user.isAgent || false
1574
+ });
1575
+ }
1576
+ setMarkdownPresence(users);
1577
+
1578
+ // Sync remote selections
1579
+ var selections = [];
1580
+ for (var j = 0; j < states.length; j++) {
1581
+ var cId = states[j][0];
1582
+ var st = states[j][1];
1583
+ var u = st.user;
1584
+ var ranges = st.selection;
1585
+ if (!u || !Array.isArray(ranges) || ranges.length === 0) {
1586
+ continue;
1587
+ }
1588
+ for (var k = 0; k < ranges.length; k++) {
1589
+ var range = ranges[k];
1590
+ var anchorPos = Y.createAbsolutePositionFromRelativePosition(range.anchor, doc);
1591
+ var markerPos = Y.createAbsolutePositionFromRelativePosition(range.marker, doc);
1592
+ if (!anchorPos || !markerPos || anchorPos.type !== ytext || markerPos.type !== ytext) {
1593
+ continue;
1594
+ }
1595
+ selections.push({
1596
+ id: u.id || String(cId),
1597
+ name: u.name || 'Anonymous',
1598
+ color: u.color || '#6b7280',
1599
+ isCurrentUser: cId === provider.awareness.clientID,
1600
+ start: Math.min(anchorPos.index, markerPos.index),
1601
+ end: Math.max(anchorPos.index, markerPos.index)
1602
+ });
1603
+ }
1604
+ }
1605
+ setMarkdownSelections(selections);
1606
+ }
1607
+
1608
+ provider.awareness.on('change', syncAwareness);
1609
+
1610
+ provider.on('sync', function(synced) {
1611
+ if (synced && !markdownYjsConnected) {
1612
+ markdownYjsConnected = true;
1613
+
1614
+ var ytextContent = ytext.toString();
1615
+ if (markdownCurrentContent && markdownCurrentContent !== ytextContent) {
1616
+ // User typed before sync completed — push local edits to Y.Text
1617
+ syncLocalChangeToYText(markdownCurrentContent);
1618
+ } else if (ytextContent) {
1619
+ // No local edits — seed editor from Y.Text
1620
+ applyMarkdownContent(ytextContent);
1621
+ }
1622
+
1623
+ // Replay any selection queued before Yjs was ready
1624
+ if (markdownPendingSelection) {
1625
+ var ps = markdownPendingSelection;
1626
+ markdownPendingSelection = null;
1627
+ var cs = Math.max(0, Math.min(ytext.length, ps.start));
1628
+ var ce = Math.max(0, Math.min(ytext.length, ps.end));
1629
+ provider.awareness.setLocalStateField('selection', [{
1630
+ anchor: Y.createRelativePositionFromTypeIndex(ytext, cs),
1631
+ marker: Y.createRelativePositionFromTypeIndex(ytext, ce)
1632
+ }]);
1633
+ }
1634
+
1635
+ // Observe Y.Text for remote changes (from other users / Monaco)
1636
+ ytext.observe(function(event) {
1637
+ if (event.transaction.origin === LEXICAL_YJS_ORIGIN) {
1638
+ return;
1639
+ }
1640
+ var fullContent = ytext.toString();
1641
+ if (fullContent === markdownCurrentContent) {
1642
+ return;
1643
+ }
1644
+ applyMarkdownContent(fullContent);
1645
+ });
1646
+
1647
+ // Initial awareness sync after Yjs is connected
1648
+ syncAwareness();
1649
+
1650
+ console.debug('[StudioBridge] Yjs synced, bound to Y.Text for fileId:', config.fileId);
1651
+ }
1652
+ });
1653
+ }).catch(function(error) {
1654
+ console.error('[StudioBridge] Failed to setup Yjs connection:', error);
1655
+ });
1656
+ }
1657
+
1658
+ function disposeMarkdownYjs() {
1659
+ markdownYjsSetupId++;
1660
+ if (markdownYProvider) {
1661
+ markdownYProvider.disconnect();
1662
+ markdownYProvider.destroy();
1663
+ markdownYProvider = null;
1664
+ }
1665
+ if (markdownYDoc) {
1666
+ markdownYDoc.destroy();
1667
+ markdownYDoc = null;
1668
+ }
1669
+ markdownYText = null;
1670
+ markdownYjsConnected = false;
1671
+ markdownYjsY = null;
1672
+ }
1673
+
1421
1674
  function getTextOffsetWithinRoot(root, targetNode, targetOffset) {
1422
1675
  if (!root || !targetNode) {
1423
1676
  return 0;
@@ -2503,27 +2756,26 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
2503
2756
  const start = editorOffsetToSourceOffset(selection.start, 'start');
2504
2757
  const end = editorOffsetToSourceOffset(selection.end, 'end');
2505
2758
 
2506
- postToStudio({
2507
- action: 'markdownSelectionChange',
2508
- fileId: markdownFileId,
2509
- filePath: PAGE_PATH,
2510
- start: start,
2511
- end: end
2512
- });
2759
+ // Set local selection on Yjs awareness directly
2760
+ if (markdownYjsConnected && markdownYText && markdownYjsY && markdownYProvider) {
2761
+ var clampedStart = Math.max(0, Math.min(markdownYText.length, start));
2762
+ var clampedEnd = Math.max(0, Math.min(markdownYText.length, end));
2763
+ markdownYProvider.awareness.setLocalStateField('selection', [{
2764
+ anchor: markdownYjsY.createRelativePositionFromTypeIndex(markdownYText, clampedStart),
2765
+ marker: markdownYjsY.createRelativePositionFromTypeIndex(markdownYText, clampedEnd)
2766
+ }]);
2767
+ markdownPendingSelection = null;
2768
+ } else {
2769
+ // Queue selection for replay after Yjs connects
2770
+ markdownPendingSelection = { start: start, end: end };
2771
+ }
2513
2772
  }, 80);
2514
2773
  }
2515
2774
 
2516
2775
  function clearMarkdownSelectionSync() {
2517
- if (!markdownFileId) {
2518
- return;
2776
+ if (markdownYProvider) {
2777
+ markdownYProvider.awareness.setLocalStateField('selection', null);
2519
2778
  }
2520
- postToStudio({
2521
- action: 'markdownSelectionChange',
2522
- fileId: markdownFileId,
2523
- filePath: PAGE_PATH,
2524
- start: -1,
2525
- end: -1
2526
- });
2527
2779
  }
2528
2780
 
2529
2781
  function clearMarkdownSelectionOverlay() {
@@ -2917,6 +3169,9 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
2917
3169
  }
2918
3170
  markdownCurrentContent = fullContent;
2919
3171
  markdownHasUnsavedChanges = true;
3172
+ if (markdownYjsConnected) {
3173
+ syncLocalChangeToYText(fullContent);
3174
+ }
2920
3175
  scheduleMarkdownSync(fullContent);
2921
3176
  scheduleMarkdownSelectionOverlayRender();
2922
3177
  }
@@ -3078,7 +3333,7 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
3078
3333
  return;
3079
3334
  }
3080
3335
 
3081
- if (markdownLexicalApi && (markdownLexicalRenderedContent === content || markdownCurrentContent === content)) {
3336
+ if (markdownLexicalApi && markdownLexicalRenderedContent === content) {
3082
3337
  console.debug('[StudioBridge] applyMarkdownContent: skipped (content unchanged)');
3083
3338
  markdownCurrentContent = content;
3084
3339
  scheduleMarkdownSelectionOverlayRender();
@@ -3708,6 +3963,15 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
3708
3963
  scheduleMarkdownSlashMenuUpdate();
3709
3964
  scheduleMarkdownInlineToolbarUpdate();
3710
3965
  postMarkdownEditorReady();
3966
+
3967
+ // Self-connect to Yjs when server-injected config is available
3968
+ if (WS_URL && YJS_GUID && !markdownYDoc) {
3969
+ setupMarkdownYjsConnection({
3970
+ wsUrl: WS_URL,
3971
+ guid: YJS_GUID,
3972
+ fileId: markdownFileId
3973
+ });
3974
+ }
3711
3975
  } else {
3712
3976
  markdownBody.style.display = '';
3713
3977
  if (markdownEditorRoot) {
@@ -3719,6 +3983,7 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
3719
3983
  markdownOverlaySelections = [];
3720
3984
  clearMarkdownSelectionOverlay();
3721
3985
  clearMarkdownSelectionSync();
3986
+ disposeMarkdownYjs();
3722
3987
  }
3723
3988
 
3724
3989
  const nextUrl = new URL(window.location.href);
@@ -3942,16 +4207,6 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
3942
4207
  if (!inspectMode) showHoverOverlay(message.id);
3943
4208
  return;
3944
4209
 
3945
- case 'setMarkdownContent':
3946
- if (!isMarkdownPage()) {
3947
- return;
3948
- }
3949
- if (message.fileId && markdownFileId && message.fileId !== markdownFileId) {
3950
- return;
3951
- }
3952
- applyMarkdownContent(message.content || '');
3953
- return;
3954
-
3955
4210
  case 'setMarkdownPersistState':
3956
4211
  if (!isMarkdownPage()) {
3957
4212
  return;
@@ -3968,26 +4223,6 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
3968
4223
  }
3969
4224
  return;
3970
4225
 
3971
- case 'setMarkdownPresence':
3972
- if (!isMarkdownPage()) {
3973
- return;
3974
- }
3975
- if (message.fileId && markdownFileId && message.fileId !== markdownFileId) {
3976
- return;
3977
- }
3978
- setMarkdownPresence(message.users);
3979
- return;
3980
-
3981
- case 'setMarkdownSelections':
3982
- if (!isMarkdownPage()) {
3983
- return;
3984
- }
3985
- if (message.fileId && markdownFileId && message.fileId !== markdownFileId) {
3986
- return;
3987
- }
3988
- setMarkdownSelections(message.selections);
3989
- return;
3990
-
3991
4226
  case 'screenshot':
3992
4227
  (async function() {
3993
4228
  if (message.multipleSections) {
@@ -4045,47 +4280,59 @@ export function generateStudioBridgeScript(options: StudioBridgeOptions): string
4045
4280
  function init() {
4046
4281
  const params = new URLSearchParams(window.location.search);
4047
4282
  const studioEmbed = params.get('studio_embed') === 'true';
4283
+ const isStandalone = window.parent === window && !studioEmbed;
4048
4284
 
4049
- if (window.parent === window && !studioEmbed) {
4050
- console.debug('[StudioBridge] Not in iframe and not studio_embed mode, skipping initialization');
4051
- return;
4285
+ if (isStandalone) {
4286
+ // Allow standalone markdown editing when WS_URL is available (server-injected Yjs config)
4287
+ if (!WS_URL) {
4288
+ console.debug('[StudioBridge] Not in iframe and not studio_embed mode, skipping initialization');
4289
+ return;
4290
+ }
4052
4291
  }
4053
4292
 
4054
4293
  console.debug('[StudioBridge] Initializing...');
4055
4294
 
4056
- injectOverlayStyles();
4057
- hoverOverlay = createOverlay('hover');
4058
- selectionOverlay = createOverlay('selection');
4295
+ // Only set up Studio interaction features when embedded in Studio
4296
+ if (!isStandalone) {
4297
+ injectOverlayStyles();
4298
+ hoverOverlay = createOverlay('hover');
4299
+ selectionOverlay = createOverlay('selection');
4300
+
4301
+ setupConsoleCapture();
4302
+ setupErrorHandling();
4303
+ setupInspectMode();
4304
+ }
4059
4305
 
4060
- setupConsoleCapture();
4061
- setupErrorHandling();
4062
- setupInspectMode();
4063
4306
  setupMarkdownEditor(params);
4064
4307
 
4065
4308
  window.addEventListener('message', handleStudioMessage);
4066
4309
 
4067
- // IMPORTANT: notifyAppLoaded() must be called BEFORE setupMutationObserver()
4068
- // because notifyAppLoaded sends onPageTransitionEnd which sets previewId,
4069
- // and treeUpdated (from setupMutationObserver) requires previewId to be set
4070
- if (document.readyState === 'loading') {
4071
- document.addEventListener('DOMContentLoaded', function() {
4310
+ if (!isStandalone) {
4311
+ // IMPORTANT: notifyAppLoaded() must be called BEFORE setupMutationObserver()
4312
+ // because notifyAppLoaded sends onPageTransitionEnd which sets previewId,
4313
+ // and treeUpdated (from setupMutationObserver) requires previewId to be set
4314
+ if (document.readyState === 'loading') {
4315
+ document.addEventListener('DOMContentLoaded', function() {
4316
+ notifyAppLoaded();
4317
+ setupMutationObserver();
4318
+ });
4319
+ } else {
4072
4320
  notifyAppLoaded();
4073
4321
  setupMutationObserver();
4074
- });
4075
- } else {
4076
- notifyAppLoaded();
4077
- setupMutationObserver();
4078
- }
4322
+ }
4079
4323
 
4080
- window.addEventListener('beforeunload', notifyAppUnloaded);
4324
+ window.addEventListener('beforeunload', notifyAppUnloaded);
4325
+ }
4081
4326
 
4082
4327
  const colorMode = params.get('color_mode');
4083
4328
  if (colorMode) setColorMode(colorMode);
4084
4329
 
4085
- const inspectModeParam = params.get('inspect_mode');
4086
- if (inspectModeParam === 'true') {
4087
- inspectMode = true;
4088
- console.debug('[StudioBridge] Inspect mode enabled from query param');
4330
+ if (!isStandalone) {
4331
+ const inspectModeParam = params.get('inspect_mode');
4332
+ if (inspectModeParam === 'true') {
4333
+ inspectMode = true;
4334
+ console.debug('[StudioBridge] Inspect mode enabled from query param');
4335
+ }
4089
4336
  }
4090
4337
 
4091
4338
  console.debug('[StudioBridge] Initialized successfully');