frontend: fix StackBlitz launcher (was opening default Next.js starter)
Browse filesBug: the previous form-POST to https://stackblitz.com/run was being
silently rejected and StackBlitz fell back to opening their default
'stackblitz-starters/nextjs' template instead of the user's actual code
(verified in user screenshot — file tree shows next.config.js / postcss
/ tailwind.config.ts which we never generate).
Root cause: the bare /run endpoint has known reliability issues with
SameSite cookie / referrer policy in modern browsers; payloads silently
404 and StackBlitz redirects to a default starter.
Fix:
- Load the official @stackblitz/sdk@1.11.0 UMD bundle from unpkg.
- launchInStackBlitz() now prefers sdk.openProject({...}, {newWindow,
openFile}) which uses a maintained iframe handshake. Form-POST is
kept as a fallback if the SDK script fails to load (offline / strict
CSP).
- chooseEntryFile() picks the most likely landing file (app/page.tsx,
src/App.tsx, index.html, ...) so users land on the right tab.
Bonus UX fix for the secondary complaint (model produced HTML when
user asked for Next.js):
- Extracted detectProjectKind() so the same logic powers both the
builder and a new pill on every code block: 'HTML' / 'Next.js' /
'React' / 'Node.js' / 'Snippet', color-coded.
- Users now SEE that the model output HTML even though they asked
for Next.js — set expectations honestly instead of having the
launcher silently mis-template.
Files:
- frontend/index.html: load sdk.umd.js before app.js
- frontend/app.js: new detectProjectKind / projectKindLabel /
chooseEntryFile, refactored launchInStackBlitz with SDK + fallback,
renderMarkdown shows kind pill in code-block header
- frontend/styles.css: .md-lang wrapper + .md-kind pill with
per-kind colors (purple Next, blue React, green Node, orange HTML)
- frontend/app.js +76 -14
- frontend/index.html +6 -0
- frontend/styles.css +22 -0
|
@@ -277,21 +277,44 @@
|
|
| 277 |
return /<!doctype|<html|^\s*import |^\s*export |^\s*function |^\s*const |^\s*class /im.test(code);
|
| 278 |
}
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
// Decide which StackBlitz template + file layout to use based on what
|
| 281 |
// the model produced. We try to be permissive — anything that looks
|
| 282 |
// like a React/Next/Node project goes into the WebContainer-backed
|
| 283 |
// 'node' template; raw HTML uses the static 'html' template.
|
| 284 |
function buildStackBlitzProject(code, lang) {
|
|
|
|
| 285 |
const l = (lang || '').toLowerCase();
|
| 286 |
-
const looksLikeNext = /from ['"]next\/|next\.config|app\/page\.[jt]sx|pages\/index/i.test(code);
|
| 287 |
-
const looksLikeReact = /from ['"]react['"]|ReactDOM\.|useState\(|useEffect\(|<\w+\s+\w+={/i.test(code);
|
| 288 |
-
const looksLikeNode = /^\s*(?:const|import)\s+\w+\s*=?\s*require\(|process\.env|module\.exports/m.test(code);
|
| 289 |
const isHtmlDoc = /<!doctype|<html/i.test(code);
|
| 290 |
|
| 291 |
const title = 'MINDI generated project';
|
| 292 |
const description = 'Generated by MINDI 1.5 Vision-Coder';
|
| 293 |
|
| 294 |
-
if (
|
| 295 |
// Minimal Next.js 14 app-router project.
|
| 296 |
return {
|
| 297 |
title, description,
|
|
@@ -323,7 +346,7 @@
|
|
| 323 |
};
|
| 324 |
}
|
| 325 |
|
| 326 |
-
if (
|
| 327 |
// Vite + React project (faster boot in WebContainer than CRA).
|
| 328 |
const ext = (l === 'tsx' || /\:\s*\w+(\[\])?/.test(code)) ? 'tsx' : 'jsx';
|
| 329 |
return {
|
|
@@ -348,7 +371,7 @@
|
|
| 348 |
};
|
| 349 |
}
|
| 350 |
|
| 351 |
-
if (
|
| 352 |
return {
|
| 353 |
title, description,
|
| 354 |
template: 'node',
|
|
@@ -362,7 +385,7 @@
|
|
| 362 |
};
|
| 363 |
}
|
| 364 |
|
| 365 |
-
if (
|
| 366 |
return {
|
| 367 |
title, description,
|
| 368 |
template: 'html',
|
|
@@ -383,13 +406,49 @@
|
|
| 383 |
};
|
| 384 |
}
|
| 385 |
|
| 386 |
-
//
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
function launchInStackBlitz(code, lang) {
|
| 392 |
const proj = buildStackBlitzProject(code, lang);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
const form = document.createElement('form');
|
| 394 |
form.action = 'https://stackblitz.com/run';
|
| 395 |
form.method = 'POST';
|
|
@@ -407,7 +466,6 @@
|
|
| 407 |
add('project[title]', proj.title);
|
| 408 |
add('project[description]', proj.description);
|
| 409 |
add('project[template]', proj.template);
|
| 410 |
-
add('project[settings][compile][trigger]', 'auto');
|
| 411 |
Object.entries(proj.files).forEach(([path, content]) => {
|
| 412 |
add(`project[files][${path}]`, content);
|
| 413 |
});
|
|
@@ -502,6 +560,10 @@
|
|
| 502 |
const safe = escapeHtml(seg.value);
|
| 503 |
const dataCode = escapeAttr(seg.value);
|
| 504 |
const runnable = isCloudRunnable(seg.value, lang);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
const launchBtns = runnable
|
| 506 |
? (
|
| 507 |
`<button class="md-run" data-code="${dataCode}" data-lang="${escapeAttr(lang)}" type="button" title="Run this code on stackblitz.com (real Node.js / WebContainer sandbox, supports Next.js / React / Node)">\u25B6 StackBlitz</button>` +
|
|
@@ -511,7 +573,7 @@
|
|
| 511 |
return (
|
| 512 |
`<pre class="md-code-block">` +
|
| 513 |
`<div class="md-code-head">` +
|
| 514 |
-
`<span>${escapeHtml(lang)}</span>` +
|
| 515 |
`<div class="md-code-actions">` +
|
| 516 |
launchBtns +
|
| 517 |
`<button class="md-copy" data-code="${dataCode}" type="button">Copy</button>` +
|
|
|
|
| 277 |
return /<!doctype|<html|^\s*import |^\s*export |^\s*function |^\s*const |^\s*class /im.test(code);
|
| 278 |
}
|
| 279 |
|
| 280 |
+
// Detect the kind of project the model produced. Returns one of:
|
| 281 |
+
// 'next' | 'react' | 'node' | 'html' | 'snippet'
|
| 282 |
+
// The detection has to be permissive but ordered (Next.js before React
|
| 283 |
+
// before Node) so a Next.js file with `import 'react'` doesn't mis-route.
|
| 284 |
+
function detectProjectKind(code, lang) {
|
| 285 |
+
const l = (lang || '').toLowerCase();
|
| 286 |
+
const looksLikeNext = /from ['"]next\/|next\.config|app\/page\.[jt]sx|pages\/index|getServerSideProps|getStaticProps/i.test(code);
|
| 287 |
+
const looksLikeReact = /from ['"]react['"]|ReactDOM\.|useState\(|useEffect\(|<\w+\s+\w+={/i.test(code);
|
| 288 |
+
const looksLikeNode = /^\s*(?:const|import)\s+\w+\s*=?\s*require\(|process\.env|module\.exports/m.test(code);
|
| 289 |
+
const isHtmlDoc = /<!doctype|<html/i.test(code);
|
| 290 |
+
|
| 291 |
+
if (looksLikeNext) return 'next';
|
| 292 |
+
if (looksLikeReact || l === 'jsx' || l === 'tsx') return 'react';
|
| 293 |
+
if (looksLikeNode || l === 'json') return 'node';
|
| 294 |
+
if (isHtmlDoc || l === 'html' || l === 'markup') return 'html';
|
| 295 |
+
return 'snippet';
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Human-friendly label shown on the code-block header pill so users see
|
| 299 |
+
// exactly what we detected (and why a launcher might open as HTML when
|
| 300 |
+
// they asked for Next.js).
|
| 301 |
+
function projectKindLabel(kind) {
|
| 302 |
+
return ({ next: 'Next.js', react: 'React', node: 'Node.js', html: 'HTML', snippet: 'Snippet' })[kind] || kind;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
// Decide which StackBlitz template + file layout to use based on what
|
| 306 |
// the model produced. We try to be permissive — anything that looks
|
| 307 |
// like a React/Next/Node project goes into the WebContainer-backed
|
| 308 |
// 'node' template; raw HTML uses the static 'html' template.
|
| 309 |
function buildStackBlitzProject(code, lang) {
|
| 310 |
+
const kind = detectProjectKind(code, lang);
|
| 311 |
const l = (lang || '').toLowerCase();
|
|
|
|
|
|
|
|
|
|
| 312 |
const isHtmlDoc = /<!doctype|<html/i.test(code);
|
| 313 |
|
| 314 |
const title = 'MINDI generated project';
|
| 315 |
const description = 'Generated by MINDI 1.5 Vision-Coder';
|
| 316 |
|
| 317 |
+
if (kind === 'next') {
|
| 318 |
// Minimal Next.js 14 app-router project.
|
| 319 |
return {
|
| 320 |
title, description,
|
|
|
|
| 346 |
};
|
| 347 |
}
|
| 348 |
|
| 349 |
+
if (kind === 'react') {
|
| 350 |
// Vite + React project (faster boot in WebContainer than CRA).
|
| 351 |
const ext = (l === 'tsx' || /\:\s*\w+(\[\])?/.test(code)) ? 'tsx' : 'jsx';
|
| 352 |
return {
|
|
|
|
| 371 |
};
|
| 372 |
}
|
| 373 |
|
| 374 |
+
if (kind === 'node') {
|
| 375 |
return {
|
| 376 |
title, description,
|
| 377 |
template: 'node',
|
|
|
|
| 385 |
};
|
| 386 |
}
|
| 387 |
|
| 388 |
+
if (kind === 'html') {
|
| 389 |
return {
|
| 390 |
title, description,
|
| 391 |
template: 'html',
|
|
|
|
| 406 |
};
|
| 407 |
}
|
| 408 |
|
| 409 |
+
// Pick the file the user most likely wants to land on when StackBlitz opens.
|
| 410 |
+
function chooseEntryFile(proj) {
|
| 411 |
+
const files = proj.files || {};
|
| 412 |
+
const preferred = [
|
| 413 |
+
'app/page.tsx', 'app/page.jsx',
|
| 414 |
+
'src/App.tsx', 'src/App.jsx',
|
| 415 |
+
'pages/index.tsx', 'pages/index.jsx',
|
| 416 |
+
'index.html', 'index.js',
|
| 417 |
+
];
|
| 418 |
+
for (const p of preferred) if (files[p]) return p;
|
| 419 |
+
return Object.keys(files)[0] || 'index.html';
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// Hand the project off to stackblitz.com. We prefer the official SDK
|
| 423 |
+
// (https://developer.stackblitz.com/platform/api/javascript-sdk) because
|
| 424 |
+
// the bare /run form-POST endpoint silently rejects some payloads and
|
| 425 |
+
// falls back to opening their default Next.js starter — exactly what
|
| 426 |
+
// the user reported. The SDK uses an iframe handshake that's more
|
| 427 |
+
// reliable across browser SameSite / referrer policies.
|
| 428 |
+
// Form POST is kept as a fallback if the SDK script fails to load.
|
| 429 |
function launchInStackBlitz(code, lang) {
|
| 430 |
const proj = buildStackBlitzProject(code, lang);
|
| 431 |
+
const sdk = window.StackBlitzSDK;
|
| 432 |
+
|
| 433 |
+
if (sdk && typeof sdk.openProject === 'function') {
|
| 434 |
+
try {
|
| 435 |
+
sdk.openProject(
|
| 436 |
+
{
|
| 437 |
+
title: proj.title,
|
| 438 |
+
description: proj.description,
|
| 439 |
+
template: proj.template,
|
| 440 |
+
files: proj.files,
|
| 441 |
+
},
|
| 442 |
+
{ newWindow: true, openFile: chooseEntryFile(proj) }
|
| 443 |
+
);
|
| 444 |
+
return;
|
| 445 |
+
} catch (err) {
|
| 446 |
+
console.warn('StackBlitz SDK launch failed, falling back to form POST:', err);
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
// Fallback: classic form POST. Less reliable but works without the SDK
|
| 451 |
+
// script (useful if it's blocked by an offline / strict-CSP environment).
|
| 452 |
const form = document.createElement('form');
|
| 453 |
form.action = 'https://stackblitz.com/run';
|
| 454 |
form.method = 'POST';
|
|
|
|
| 466 |
add('project[title]', proj.title);
|
| 467 |
add('project[description]', proj.description);
|
| 468 |
add('project[template]', proj.template);
|
|
|
|
| 469 |
Object.entries(proj.files).forEach(([path, content]) => {
|
| 470 |
add(`project[files][${path}]`, content);
|
| 471 |
});
|
|
|
|
| 560 |
const safe = escapeHtml(seg.value);
|
| 561 |
const dataCode = escapeAttr(seg.value);
|
| 562 |
const runnable = isCloudRunnable(seg.value, lang);
|
| 563 |
+
const kind = runnable ? detectProjectKind(seg.value, lang) : null;
|
| 564 |
+
const kindPill = kind
|
| 565 |
+
? `<span class="md-kind md-kind--${kind}" title="Project kind detected from the generated code. The launchers below open this exact code, even if it doesn't match what you originally asked for.">${projectKindLabel(kind)}</span>`
|
| 566 |
+
: '';
|
| 567 |
const launchBtns = runnable
|
| 568 |
? (
|
| 569 |
`<button class="md-run" data-code="${dataCode}" data-lang="${escapeAttr(lang)}" type="button" title="Run this code on stackblitz.com (real Node.js / WebContainer sandbox, supports Next.js / React / Node)">\u25B6 StackBlitz</button>` +
|
|
|
|
| 573 |
return (
|
| 574 |
`<pre class="md-code-block">` +
|
| 575 |
`<div class="md-code-head">` +
|
| 576 |
+
`<span class="md-lang">${escapeHtml(lang)}${kindPill}</span>` +
|
| 577 |
`<div class="md-code-actions">` +
|
| 578 |
launchBtns +
|
| 579 |
`<button class="md-copy" data-code="${dataCode}" type="button">Copy</button>` +
|
|
@@ -344,6 +344,12 @@
|
|
| 344 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
| 345 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
<script src="sandbox.js"></script>
|
| 348 |
<script src="agent.js"></script>
|
| 349 |
<script src="app.js"></script>
|
|
|
|
| 344 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
| 345 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
|
| 346 |
|
| 347 |
+
<!-- StackBlitz SDK — drives the "Run in StackBlitz" launcher.
|
| 348 |
+
The form-POST /run endpoint is unreliable (gets silently rejected
|
| 349 |
+
and falls back to the default Next.js starter); the SDK's hidden
|
| 350 |
+
iframe handshake is the maintained, robust path. -->
|
| 351 |
+
<script src="https://unpkg.com/@stackblitz/sdk@1.11.0/bundles/sdk.umd.js"></script>
|
| 352 |
+
|
| 353 |
<script src="sandbox.js"></script>
|
| 354 |
<script src="agent.js"></script>
|
| 355 |
<script src="app.js"></script>
|
|
@@ -686,6 +686,28 @@ body.sidebar-open .scrim { opacity: 1; pointer-events: auto; }
|
|
| 686 |
border-bottom: 1px solid var(--border);
|
| 687 |
}
|
| 688 |
.md-code-head span:first-child { color: var(--c-code); font-weight: 500; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
.md-code-actions {
|
| 690 |
display: flex;
|
| 691 |
gap: 6px;
|
|
|
|
| 686 |
border-bottom: 1px solid var(--border);
|
| 687 |
}
|
| 688 |
.md-code-head span:first-child { color: var(--c-code); font-weight: 500; }
|
| 689 |
+
.md-lang {
|
| 690 |
+
display: inline-flex;
|
| 691 |
+
align-items: center;
|
| 692 |
+
gap: 8px;
|
| 693 |
+
}
|
| 694 |
+
.md-kind {
|
| 695 |
+
font-family: var(--mono);
|
| 696 |
+
font-size: 10px;
|
| 697 |
+
font-weight: 600;
|
| 698 |
+
letter-spacing: .04em;
|
| 699 |
+
text-transform: none;
|
| 700 |
+
padding: 2px 7px;
|
| 701 |
+
border-radius: 999px;
|
| 702 |
+
border: 1px solid currentColor;
|
| 703 |
+
background: rgba(255, 255, 255, .03);
|
| 704 |
+
cursor: help;
|
| 705 |
+
}
|
| 706 |
+
.md-kind--next { color: #f8fafc; }
|
| 707 |
+
.md-kind--react { color: #60a5fa; }
|
| 708 |
+
.md-kind--node { color: #34d399; }
|
| 709 |
+
.md-kind--html { color: #fb923c; }
|
| 710 |
+
.md-kind--snippet { color: var(--text-mute); }
|
| 711 |
.md-code-actions {
|
| 712 |
display: flex;
|
| 713 |
gap: 6px;
|