@ox-content/code-play 3.0.0-alpha.10 → 3.0.0-alpha.11

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/dist/plugin2.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { f as resolveCodePlayOptions, h as resolveLanguage, i as escapeAttribute, n as encodePayload, o as DEFAULT_ENDPOINTS, r as decodeHtml, s as DEFAULT_VIEWERS, t as decodePayload, u as DEV_TYPECHECK_PATH } from "./payload.mjs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  //#region src/authoring.ts
@@ -48,6 +48,7 @@ function parseCodePlayAttributes(attrs) {
48
48
  options.viewers = parseViewers(value);
49
49
  continue;
50
50
  }
51
+ if (applyProjectAttribute(options, name, value)) continue;
51
52
  if (name.startsWith("config-")) options.config[configKeyFromAttribute(name.slice(7))] = coerceOptionValue(value);
52
53
  }
53
54
  return options;
@@ -118,9 +119,70 @@ function applyPlayOption(options, rawName, value) {
118
119
  options.viewers = parseViewers(value);
119
120
  return;
120
121
  }
122
+ if (applyProjectMeta(options, rawName, value)) return;
121
123
  const configKey = name.startsWith("play-config:") || name.startsWith("play-config.") ? rawName.slice(12) : name.startsWith("play-") ? rawName.slice(5) : "";
122
124
  if (configKey) options.config[configKey] = coerceOptionValue(value);
123
125
  }
126
+ function applyProjectMeta(options, rawName, value) {
127
+ switch (rawName.toLowerCase()) {
128
+ case "play-project":
129
+ case "play-sandbox":
130
+ case "play-provider":
131
+ ensureProject(options, value);
132
+ return true;
133
+ case "play-entry":
134
+ ensureProject(options).entry = value;
135
+ return true;
136
+ case "play-file":
137
+ ensureProject(options).file = value;
138
+ return true;
139
+ case "play-files":
140
+ ensureProject(options).files.push(...splitList(value));
141
+ return true;
142
+ case "play-project-url":
143
+ case "play-open-url":
144
+ ensureProject(options).openUrl = value;
145
+ return true;
146
+ case "play-fallback-url":
147
+ ensureProject(options).fallbackUrl = value;
148
+ return true;
149
+ default: return false;
150
+ }
151
+ }
152
+ function applyProjectAttribute(options, name, value) {
153
+ switch (name) {
154
+ case "project":
155
+ case "sandbox":
156
+ case "provider":
157
+ ensureProject(options, value);
158
+ return true;
159
+ case "entry":
160
+ ensureProject(options).entry = value;
161
+ return true;
162
+ case "file":
163
+ ensureProject(options).file = value;
164
+ return true;
165
+ case "files":
166
+ ensureProject(options).files.push(...splitList(value));
167
+ return true;
168
+ case "project-url":
169
+ case "open-url":
170
+ ensureProject(options).openUrl = value;
171
+ return true;
172
+ case "fallback-url":
173
+ ensureProject(options).fallbackUrl = value;
174
+ return true;
175
+ default: return false;
176
+ }
177
+ }
178
+ function ensureProject(options, provider = "external") {
179
+ options.project ??= {
180
+ provider,
181
+ files: []
182
+ };
183
+ if (provider && options.project.provider === "external") options.project.provider = provider;
184
+ return options.project;
185
+ }
124
186
  function readTokenPair(token) {
125
187
  const index = token.indexOf("=");
126
188
  if (index === -1) return;
@@ -155,6 +217,9 @@ function parseViewers(value) {
155
217
  }
156
218
  return Object.keys(viewers).length > 0 ? viewers : void 0;
157
219
  }
220
+ function splitList(value) {
221
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
222
+ }
158
223
  function coerceOptionValue(value) {
159
224
  if (value === "true") return true;
160
225
  if (value === "false") return false;
@@ -231,7 +296,8 @@ function parsePlayFences(source) {
231
296
  config: options.config,
232
297
  ui: options.ui,
233
298
  viewers: options.viewers,
234
- timeoutMs: options.timeoutMs
299
+ timeoutMs: options.timeoutMs,
300
+ project: options.project
235
301
  };
236
302
  });
237
303
  }
@@ -275,7 +341,8 @@ function parseCodePlayTags(source) {
275
341
  config: options.config,
276
342
  ui: options.ui,
277
343
  viewers: options.viewers,
278
- timeoutMs: options.timeoutMs
344
+ timeoutMs: options.timeoutMs,
345
+ project: options.project
279
346
  });
280
347
  }
281
348
  return tags;
@@ -300,8 +367,153 @@ function stripIndent(value) {
300
367
  return lines.map((line) => line.slice(indent)).join("\n");
301
368
  }
302
369
  //#endregion
370
+ //#region src/project-sandbox.ts
371
+ const PROJECT_SANDBOX_ADAPTERS = {
372
+ stackblitz: adapter("stackblitz", "StackBlitz", "browser"),
373
+ codesandbox: adapter("codesandbox", "CodeSandbox", "browser"),
374
+ webcontainer: adapter("webcontainer", "WebContainer", "node"),
375
+ external: adapter("external", "External sandbox", "external")
376
+ };
377
+ function projectSandboxFromPayloadInput(input) {
378
+ if (!input.project) return;
379
+ const warnings = [...input.warnings ?? []];
380
+ const adapter = resolveProjectSandboxAdapter(input.project.provider, warnings);
381
+ const sourceFile = normalizeProjectPath(input.project.file, warnings, "source file");
382
+ const entry = normalizeProjectPath(input.project.entry, warnings, "entry path");
383
+ const primary = {
384
+ path: sourceFile ?? entry ?? defaultProjectFile(input.language, input.definition),
385
+ code: input.code
386
+ };
387
+ const files = mergeProjectFiles([primary, ...input.files ?? []]);
388
+ const openUrl = safeProjectUrl(input.project.openUrl, adapter.provider, warnings);
389
+ const fallbackUrl = safeProjectUrl(input.project.fallbackUrl, adapter.provider, warnings);
390
+ return adapter.resolve({
391
+ provider: input.project.provider,
392
+ entry: entry ?? primary.path,
393
+ files,
394
+ openUrl,
395
+ fallbackUrl,
396
+ warnings
397
+ });
398
+ }
399
+ function normalizeProjectPath(value, warnings = [], label = "file path") {
400
+ const trimmed = value?.trim();
401
+ if (!trimmed) return;
402
+ const normalized = trimmed.replace(/^\.\//, "");
403
+ if (normalized.startsWith("/") || normalized.includes("\\") || normalized.includes("\0") || normalized.split("/").some((part) => part === "" || part === "." || part === "..") || /^[a-z]+:/i.test(normalized)) {
404
+ warnings.push(`Skipped unsafe project ${label}: ${trimmed}`);
405
+ return;
406
+ }
407
+ if (normalized.length > 256) {
408
+ warnings.push(`Skipped long project ${label}: ${trimmed}`);
409
+ return;
410
+ }
411
+ return normalized;
412
+ }
413
+ function projectSandboxProviderLabel(provider) {
414
+ return PROJECT_SANDBOX_ADAPTERS[provider].label;
415
+ }
416
+ function adapter(provider, label, target) {
417
+ return {
418
+ provider,
419
+ label,
420
+ target,
421
+ resolve(input) {
422
+ return compactProjectSandbox({
423
+ provider,
424
+ label,
425
+ target,
426
+ entry: input.entry,
427
+ files: input.files,
428
+ openUrl: input.openUrl,
429
+ fallbackUrl: input.fallbackUrl,
430
+ warnings: input.warnings
431
+ });
432
+ }
433
+ };
434
+ }
435
+ function defaultProjectFile(language, definition) {
436
+ if (definition?.framework === "vue") return "src/App.vue";
437
+ if (definition?.framework === "react" || definition?.framework === "solid") return "src/App.tsx";
438
+ if (definition?.framework === "svelte") return "src/App.svelte";
439
+ switch (language) {
440
+ case "javascript": return "index.js";
441
+ case "typescript": return "index.ts";
442
+ case "go": return "main.go";
443
+ case "rust": return "src/main.rs";
444
+ default: return "snippet.txt";
445
+ }
446
+ }
447
+ function resolveProjectSandboxAdapter(rawProvider, warnings) {
448
+ switch (rawProvider.trim().toLowerCase()) {
449
+ case "stackblitz":
450
+ case "stack-blitz":
451
+ case "sb": return PROJECT_SANDBOX_ADAPTERS.stackblitz;
452
+ case "codesandbox":
453
+ case "code-sandbox":
454
+ case "csb": return PROJECT_SANDBOX_ADAPTERS.codesandbox;
455
+ case "webcontainer":
456
+ case "web-container": return PROJECT_SANDBOX_ADAPTERS.webcontainer;
457
+ case "external":
458
+ case "": return PROJECT_SANDBOX_ADAPTERS.external;
459
+ default:
460
+ warnings.push(`Unknown project sandbox provider: ${rawProvider}`);
461
+ return PROJECT_SANDBOX_ADAPTERS.external;
462
+ }
463
+ }
464
+ function mergeProjectFiles(files) {
465
+ const merged = /* @__PURE__ */ new Map();
466
+ for (const file of files) merged.set(file.path, file);
467
+ return [...merged.values()];
468
+ }
469
+ function safeProjectUrl(value, provider, warnings) {
470
+ if (!value) return;
471
+ let url;
472
+ try {
473
+ url = new URL(value);
474
+ } catch {
475
+ warnings.push(`Skipped invalid project URL: ${value}`);
476
+ return;
477
+ }
478
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
479
+ warnings.push(`Skipped non-http project URL: ${value}`);
480
+ return;
481
+ }
482
+ if (url.username || url.password) {
483
+ warnings.push(`Skipped project URL with credentials: ${url.origin}`);
484
+ return;
485
+ }
486
+ if (!providerAllowsHost(provider, url.hostname)) {
487
+ warnings.push(`Skipped ${provider} project URL on unexpected host: ${url.hostname}`);
488
+ return;
489
+ }
490
+ return url.href;
491
+ }
492
+ function providerAllowsHost(provider, host) {
493
+ if (provider === "external") return true;
494
+ const normalized = host.toLowerCase();
495
+ return {
496
+ stackblitz: ["stackblitz.com"],
497
+ codesandbox: ["codesandbox.io", "csb.app"],
498
+ webcontainer: ["webcontainers.io", "webcontainer.io"]
499
+ }[provider].some((suffix) => normalized === suffix || normalized.endsWith(`.${suffix}`));
500
+ }
501
+ function compactProjectSandbox(project) {
502
+ const next = {
503
+ provider: project.provider,
504
+ label: project.label,
505
+ target: project.target,
506
+ files: project.files
507
+ };
508
+ if (project.entry) next.entry = project.entry;
509
+ if (project.openUrl) next.openUrl = project.openUrl;
510
+ if (project.fallbackUrl) next.fallbackUrl = project.fallbackUrl;
511
+ if (project.warnings?.length) next.warnings = project.warnings;
512
+ return next;
513
+ }
514
+ //#endregion
303
515
  //#region src/payload-factory.ts
304
- function payloadFromFence(fence, options) {
516
+ function payloadFromFence(fence, options, context = {}) {
305
517
  const definition = resolveLanguage(fence.language);
306
518
  const enabled = definition ? options.languages.get(definition.id) : void 0;
307
519
  const payload = {
@@ -326,6 +538,15 @@ function payloadFromFence(fence, options) {
326
538
  endpoints: options.endpoints
327
539
  };
328
540
  if (enabled?.endpoint) payload.endpoint = enabled.endpoint;
541
+ const project = projectSandboxFromPayloadInput({
542
+ language: payload.language,
543
+ code: payload.code,
544
+ definition,
545
+ project: fence.project,
546
+ files: context.files,
547
+ warnings: context.warnings
548
+ });
549
+ if (project) payload.project = project;
329
550
  return payload;
330
551
  }
331
552
  /** TypeScript typecheck in the browser needs a reachable endpoint; hide the dead button otherwise. */
@@ -369,7 +590,7 @@ function upgradeCodePlayTags(html, options) {
369
590
  const definition = resolveLanguage(language);
370
591
  const endpoints = options.endpoints ?? DEFAULT_ENDPOINTS;
371
592
  const playOptions = parseCodePlayAttributes(attrs);
372
- return wrapWidget(options.encodePayload({
593
+ const payloadValue = {
373
594
  language: definition?.id ?? language,
374
595
  code,
375
596
  title: playOptions.title,
@@ -388,7 +609,15 @@ function upgradeCodePlayTags(html, options) {
388
609
  ui: playOptions.ui ?? "default",
389
610
  timeoutMs: playOptions.timeoutMs ?? 1e4,
390
611
  endpoints
391
- }), `<pre><code class="language-${escapeAttribute(language)}">${body}</code></pre>`);
612
+ };
613
+ const project = projectSandboxFromPayloadInput({
614
+ language: payloadValue.language,
615
+ code,
616
+ definition,
617
+ project: playOptions.project
618
+ });
619
+ if (project) payloadValue.project = project;
620
+ return wrapWidget(options.encodePayload(payloadValue), `<pre><code class="language-${escapeAttribute(language)}">${body}</code></pre>`);
392
621
  });
393
622
  }
394
623
  function wrapMatchingFences(html, matches) {
@@ -445,6 +674,111 @@ function aliasesEqual(left, right) {
445
674
  return left.toLowerCase() === right.toLowerCase();
446
675
  }
447
676
  //#endregion
677
+ //#region src/project-files.ts
678
+ const DEFAULT_MAX_FILES = 32;
679
+ const DEFAULT_MAX_BYTES = 262144;
680
+ function collectProjectFiles(project, context = {}) {
681
+ const warnings = [];
682
+ if (!project?.files.length) return {
683
+ files: [],
684
+ warnings
685
+ };
686
+ if (!context.documentPath) {
687
+ warnings.push("Skipped project files because the Markdown source path is unavailable.");
688
+ return {
689
+ files: [],
690
+ warnings
691
+ };
692
+ }
693
+ const documentDir = path.dirname(context.documentPath);
694
+ const sourceRoot = path.resolve(context.sourceRoot ?? documentDir);
695
+ const realSourceRoot = realpathIfExists(sourceRoot) ?? sourceRoot;
696
+ const files = [];
697
+ for (const requested of project.files) {
698
+ if (files.length >= (context.maxFiles ?? DEFAULT_MAX_FILES)) {
699
+ warnings.push(`Skipped project file after ${files.length} files: ${requested}`);
700
+ continue;
701
+ }
702
+ const safePath = normalizeProjectPath(requested, warnings);
703
+ if (!safePath) continue;
704
+ const absolute = path.resolve(documentDir, safePath);
705
+ if (!pathInside(absolute, sourceRoot)) {
706
+ warnings.push(`Skipped project file outside source root: ${safePath}`);
707
+ continue;
708
+ }
709
+ const realAbsolute = realpathIfExists(absolute);
710
+ if (!realAbsolute) {
711
+ warnings.push(`Skipped missing project file: ${safePath}`);
712
+ continue;
713
+ }
714
+ if (!pathInside(realAbsolute, realSourceRoot)) {
715
+ warnings.push(`Skipped project file outside real source root: ${safePath}`);
716
+ continue;
717
+ }
718
+ const stat = statSync(realAbsolute);
719
+ if (!stat.isFile()) {
720
+ warnings.push(`Skipped non-file project path: ${safePath}`);
721
+ continue;
722
+ }
723
+ if (stat.size > (context.maxBytes ?? DEFAULT_MAX_BYTES)) {
724
+ warnings.push(`Skipped large project file: ${safePath}`);
725
+ continue;
726
+ }
727
+ files.push({
728
+ path: safePath,
729
+ code: readFileSync(realAbsolute, "utf8")
730
+ });
731
+ }
732
+ return {
733
+ files,
734
+ warnings
735
+ };
736
+ }
737
+ function realpathIfExists(file) {
738
+ if (!existsSync(file)) return;
739
+ return realpathSync(file);
740
+ }
741
+ function pathInside(file, root) {
742
+ const relative = path.relative(root, file);
743
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
744
+ }
745
+ //#endregion
746
+ //#region src/plugin-paths.ts
747
+ const MARKDOWN_RE = /\.(?:md|markdown|mdx)(?:$|\?)/i;
748
+ function cleanMarkdownPath(id, root) {
749
+ const file = id.split("?")[0];
750
+ if (!file || file.startsWith("\0")) return;
751
+ return path.isAbsolute(file) ? file : path.resolve(root, file);
752
+ }
753
+ function guessHtmlPath(file, srcDir, outDir) {
754
+ const relative = path.relative(srcDir, file).replace(/\.(?:md|markdown|mdx)$/i, "");
755
+ return [path.join(outDir, `${relative}.html`), path.join(outDir, relative, "index.html")].find((candidate) => existsSync(candidate));
756
+ }
757
+ function normalizeBase(base) {
758
+ if (!base || base === "/") return "/";
759
+ return base.endsWith("/") ? base : `${base}/`;
760
+ }
761
+ function sourceRoot(root, resolved) {
762
+ return path.resolve(root, resolved.srcDir ?? "docs");
763
+ }
764
+ function urlToMarkdown(urlPath, root, srcDir, base) {
765
+ let relative = urlPath;
766
+ if (base !== "/" && relative.startsWith(base)) relative = relative.slice(base.length);
767
+ relative = relative.replace(/^\//, "").replace(/\.html$/, "");
768
+ if (!relative || relative.includes("..")) return;
769
+ return [path.resolve(root, srcDir, `${relative}.md`), path.resolve(root, srcDir, relative, "index.md")].find((candidate) => existsSync(candidate));
770
+ }
771
+ function walkFiles(dir) {
772
+ const entries = readdirSync(dir);
773
+ const files = [];
774
+ for (const entry of entries) {
775
+ const full = path.join(dir, entry);
776
+ if (statSync(full).isDirectory()) files.push(...walkFiles(full));
777
+ else files.push(full);
778
+ }
779
+ return files;
780
+ }
781
+ //#endregion
448
782
  //#region src/plugin-client.ts
449
783
  const CLIENT_FILE_CANDIDATES = [
450
784
  "browser.mjs",
@@ -555,7 +889,6 @@ function writeJson(res, status, body) {
555
889
  //#region src/plugin.ts
556
890
  const VIRTUAL_ID = "virtual:ox-content/code-play";
557
891
  const RESOLVED_VIRTUAL = `\0${VIRTUAL_ID}`;
558
- const MARKDOWN_RE = /\.(?:md|markdown|mdx)(?:$|\?)/i;
559
892
  function codePlay(options = {}) {
560
893
  const resolved = resolveCodePlayOptions(options);
561
894
  const explicitTypecheck = options.endpoints?.typecheck;
@@ -610,7 +943,7 @@ function codePlay(options = {}) {
610
943
  const rewritten = rewritePlayFences(code, (fence) => {
611
944
  const definition = resolveLanguage(fence.language);
612
945
  if (!definition || !resolved.languages.has(definition.id)) return null;
613
- return encodePayload(payloadFromFence(fence, resolved));
946
+ return encodePayload(payloadFromFence(fence, resolved, projectContextForFence(fence, cleanMarkdownPath(id, root), sourceRoot(root, resolved))));
614
947
  });
615
948
  if (rewritten === code) return null;
616
949
  needsClientAsset = true;
@@ -680,6 +1013,7 @@ function enhanceHtmlForUrl(urlPath, html, root, resolved, enhance) {
680
1013
  const markdownPath = urlToMarkdown(urlPath, root, resolved.srcDir ?? "docs", resolved.base);
681
1014
  if (!markdownPath || !existsSync(markdownPath)) return;
682
1015
  const source = readFileSync(markdownPath, "utf8");
1016
+ const srcRoot = sourceRoot(root, resolved);
683
1017
  const fences = [...parsePlayFences(source), ...parseCodePlayTags(source)].filter((fence) => {
684
1018
  const definition = resolveLanguage(fence.language);
685
1019
  return Boolean(definition && resolved.languages.has(definition.id));
@@ -688,16 +1022,9 @@ function enhanceHtmlForUrl(urlPath, html, root, resolved, enhance) {
688
1022
  return enhancePlayHtml(html, enhance(fences.map((fence) => ({
689
1023
  language: fence.language,
690
1024
  code: fence.code,
691
- payload: encodePayload(payloadFromFence(fence, resolved))
1025
+ payload: encodePayload(payloadFromFence(fence, resolved, projectContextForFence(fence, markdownPath, srcRoot)))
692
1026
  }))));
693
1027
  }
694
- function urlToMarkdown(urlPath, root, srcDir, base) {
695
- let relative = urlPath;
696
- if (base !== "/" && relative.startsWith(base)) relative = relative.slice(base.length);
697
- relative = relative.replace(/^\//, "").replace(/\.html$/, "");
698
- if (!relative || relative.includes("..")) return;
699
- return [path.resolve(root, srcDir, `${relative}.md`), path.resolve(root, srcDir, relative, "index.md")].find((candidate) => existsSync(candidate));
700
- }
701
1028
  function mountProxies(server, rustUrl, goUrl) {
702
1029
  server.middlewares.use("/__ox-code-play/rust", (req, res) => {
703
1030
  proxy(req, res, rustUrl, "application/json");
@@ -725,7 +1052,7 @@ async function enhanceWrittenPages(srcDir, outDir, resolved, enhance) {
725
1052
  const enhanced = enhancePlayHtml(html, enhance(fences.map((fence) => ({
726
1053
  language: fence.language,
727
1054
  code: fence.code,
728
- payload: encodePayload(payloadFromFence(fence, resolved))
1055
+ payload: encodePayload(payloadFromFence(fence, resolved, projectContextForFence(fence, file, srcDir)))
729
1056
  }))));
730
1057
  if (enhanced !== html) {
731
1058
  await writeFile(htmlPath, enhanced);
@@ -734,25 +1061,14 @@ async function enhanceWrittenPages(srcDir, outDir, resolved, enhance) {
734
1061
  }
735
1062
  return enhancedAny;
736
1063
  }
737
- function guessHtmlPath(file, srcDir, outDir) {
738
- const relative = path.relative(srcDir, file).replace(/\.(?:md|markdown|mdx)$/i, "");
739
- return [path.join(outDir, `${relative}.html`), path.join(outDir, relative, "index.html")].find((candidate) => existsSync(candidate));
740
- }
741
- function walkFiles(dir) {
742
- const entries = readdirSync(dir);
743
- const files = [];
744
- for (const entry of entries) {
745
- const full = path.join(dir, entry);
746
- if (statSync(full).isDirectory()) files.push(...walkFiles(full));
747
- else files.push(full);
748
- }
749
- return files;
750
- }
751
- function normalizeBase(base) {
752
- if (!base || base === "/") return "/";
753
- return base.endsWith("/") ? base : `${base}/`;
1064
+ function projectContextForFence(fence, documentPath, srcRoot) {
1065
+ if (!fence.project) return {};
1066
+ return collectProjectFiles(fence.project, {
1067
+ documentPath,
1068
+ sourceRoot: srcRoot
1069
+ });
754
1070
  }
755
1071
  //#endregion
756
- export { parsePlayFences as a, parseCodePlayTags as i, enhanceGeneratedModule as n, rewritePlayFences as o, enhancePlayHtml as r, stripPlayMeta as s, codePlay as t };
1072
+ export { normalizeProjectPath as a, parseCodePlayTags as c, stripPlayMeta as d, PROJECT_SANDBOX_ADAPTERS as i, parsePlayFences as l, enhanceGeneratedModule as n, projectSandboxFromPayloadInput as o, enhancePlayHtml as r, projectSandboxProviderLabel as s, codePlay as t, rewritePlayFences as u };
757
1073
 
758
1074
  //# sourceMappingURL=plugin2.mjs.map