@saastro/studio 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-JMSDBWL2.js → chunk-3MKWTNQA.js} +2 -2
- package/dist/{chunk-7U2GMTVW.js → chunk-G3MUVKWX.js} +8 -3
- package/dist/chunk-G3MUVKWX.js.map +1 -0
- package/dist/{chunk-FGYTDBZU.js → chunk-T3ZZAM65.js} +77 -8
- package/dist/{chunk-FGYTDBZU.js.map → chunk-T3ZZAM65.js.map} +1 -1
- package/dist/chunk-TU7VF6SY.js +443 -0
- package/dist/chunk-TU7VF6SY.js.map +1 -0
- package/dist/doctor.d.ts +18 -2
- package/dist/doctor.js +42 -7
- package/dist/doctor.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/integration/index.js +3 -3
- package/dist/middleware/index.js +3 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/richtext/index.d.ts +96 -17
- package/dist/richtext/index.js +23 -1
- package/dist/vite/index.js +2 -2
- package/package.json +1 -1
- package/src/RichText.astro +45 -22
- package/dist/chunk-4L5C226B.js +0 -71
- package/dist/chunk-4L5C226B.js.map +0 -1
- package/dist/chunk-5QZN4IGF.js +0 -71
- package/dist/chunk-5QZN4IGF.js.map +0 -1
- package/dist/chunk-7U2GMTVW.js.map +0 -1
- package/dist/chunk-K7DTYPRE.js +0 -1348
- package/dist/chunk-K7DTYPRE.js.map +0 -1
- package/dist/chunk-S2IKJ3QM.js +0 -28
- package/dist/chunk-S2IKJ3QM.js.map +0 -1
- /package/dist/{chunk-JMSDBWL2.js.map → chunk-3MKWTNQA.js.map} +0 -0
package/dist/doctor.js
CHANGED
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
} from "./chunk-PKDMKMD6.js";
|
|
8
8
|
import {
|
|
9
9
|
SPAN_MARKS,
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
isSpansShape,
|
|
11
|
+
isSpansValue,
|
|
12
|
+
isValidMark
|
|
13
|
+
} from "./chunk-T3ZZAM65.js";
|
|
12
14
|
import "./chunk-PZ5AY32C.js";
|
|
13
15
|
|
|
14
16
|
// ../studio-core/dist/chunk-CW5U4CZJ.js
|
|
@@ -482,8 +484,8 @@ function spansDefect(v) {
|
|
|
482
484
|
if (span.marks !== void 0) {
|
|
483
485
|
if (!Array.isArray(span.marks)) return `element ${i} has a non-array "marks"`;
|
|
484
486
|
for (const m of span.marks) {
|
|
485
|
-
if (!
|
|
486
|
-
return `element ${i} uses unknown mark "${String(m)}" (
|
|
487
|
+
if (!isValidMark(m))
|
|
488
|
+
return `element ${i} uses unknown mark "${String(m)}" (allowed: ${SPAN_MARKS.join(", ")}, c:<token>, s:sm|lg|xl, f:display|body|code)`;
|
|
487
489
|
}
|
|
488
490
|
}
|
|
489
491
|
}
|
|
@@ -497,6 +499,8 @@ function classifyLeaves(obj, prefix, out) {
|
|
|
497
499
|
if (Array.isArray(obj)) {
|
|
498
500
|
if (isSpansValue(obj)) {
|
|
499
501
|
out.set(prefix, "spans");
|
|
502
|
+
} else if (isSpansShape(obj)) {
|
|
503
|
+
out.set(prefix, "spans-unmarked");
|
|
500
504
|
} else if (isSpansLike(obj)) {
|
|
501
505
|
out.set(prefix, "spans-malformed");
|
|
502
506
|
} else {
|
|
@@ -524,7 +528,7 @@ function checkSpansIntegrity(locales) {
|
|
|
524
528
|
status: "spans_malformed",
|
|
525
529
|
severity: "error",
|
|
526
530
|
reason: `Key "${path}" (${l.locale}) looks like rich spans but is malformed: ${Array.isArray(raw) ? spansDefect(raw) : "not an array"}.`,
|
|
527
|
-
remedy: `Fix the value to [{ text, marks?: [${SPAN_MARKS.map((m) => `'${m}'`).join(" | ")}] }] or revert it to a plain string.`,
|
|
531
|
+
remedy: `Fix the value to [{ text, marks?: [${SPAN_MARKS.map((m) => `'${m}'`).join(" | ")} | 'c:<token>' | 's:sm|lg|xl' | 'f:display|body|code'] }] or revert it to a plain string.`,
|
|
528
532
|
detail: path
|
|
529
533
|
});
|
|
530
534
|
}
|
|
@@ -556,13 +560,42 @@ function getAtDottedPath(obj, dotted) {
|
|
|
556
560
|
}
|
|
557
561
|
return cursor;
|
|
558
562
|
}
|
|
563
|
+
function extractThemeBlock(css) {
|
|
564
|
+
const m = css.match(/@theme[^{]*\{([\s\S]*?)\}/);
|
|
565
|
+
return m ? m[1] : null;
|
|
566
|
+
}
|
|
567
|
+
function checkThemeFontOverridable(files) {
|
|
568
|
+
const findings = [];
|
|
569
|
+
for (const f of files) {
|
|
570
|
+
if (!f.path.endsWith(".css")) continue;
|
|
571
|
+
const block = extractThemeBlock(f.content);
|
|
572
|
+
if (block == null) continue;
|
|
573
|
+
for (const m of block.matchAll(/--font-(sans|heading)\s*:\s*([^;]+)/g)) {
|
|
574
|
+
const which = m[1];
|
|
575
|
+
const value = m[2].trim();
|
|
576
|
+
if (/^var\(\s*--font-(body|display)\b/.test(value)) continue;
|
|
577
|
+
if (/^var\(/.test(value)) continue;
|
|
578
|
+
findings.push({
|
|
579
|
+
file: f.path,
|
|
580
|
+
status: "theme_font_not_overridable",
|
|
581
|
+
severity: "warn",
|
|
582
|
+
reason: `--font-${which} is a literal family inside @theme \u2014 Studio font swaps won't apply (Tailwind inlines it at build).`,
|
|
583
|
+
remedy: "Move the family to a :root var and reference it: :root{--font-body:\u2026;--font-display:\u2026} + @theme{--font-sans:var(--font-body);--font-heading:var(--font-display)}.",
|
|
584
|
+
detail: `--font-${which}: ${truncate(value)}`
|
|
585
|
+
});
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return findings;
|
|
590
|
+
}
|
|
559
591
|
function runDoctorChecks(input) {
|
|
560
592
|
const findings = [
|
|
561
593
|
...checkI18nParity(input.locales),
|
|
562
594
|
...checkTokenCompliance(input.files),
|
|
563
595
|
...checkFormCompliance(input.files),
|
|
564
596
|
...checkAllowlistCompliance(input.files),
|
|
565
|
-
...checkSpansIntegrity(input.locales)
|
|
597
|
+
...checkSpansIntegrity(input.locales),
|
|
598
|
+
...checkThemeFontOverridable(input.files)
|
|
566
599
|
];
|
|
567
600
|
const byStatus = {
|
|
568
601
|
i18n_parity: 0,
|
|
@@ -570,7 +603,8 @@ function runDoctorChecks(input) {
|
|
|
570
603
|
raw_form: 0,
|
|
571
604
|
allowlist_violation: 0,
|
|
572
605
|
spans_malformed: 0,
|
|
573
|
-
spans_shape_mismatch: 0
|
|
606
|
+
spans_shape_mismatch: 0,
|
|
607
|
+
theme_font_not_overridable: 0
|
|
574
608
|
};
|
|
575
609
|
let needsAttention = 0;
|
|
576
610
|
let warnings = 0;
|
|
@@ -587,6 +621,7 @@ export {
|
|
|
587
621
|
checkFormCompliance,
|
|
588
622
|
checkI18nParity,
|
|
589
623
|
checkSpansIntegrity,
|
|
624
|
+
checkThemeFontOverridable,
|
|
590
625
|
checkTokenCompliance,
|
|
591
626
|
detectAutoWrapPagesInConfig,
|
|
592
627
|
findAutoWrapGaps,
|
package/dist/doctor.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../studio-core/src/doctor-gaps.ts","../../studio-core/src/doctor.ts","../../studio-core/src/doctor-config.ts","../../studio-core/src/doctor-checks.ts"],"sourcesContent":["/**\n * autoWrap gap report (brownfield onboarding Fase 3). Pure + I/O-free.\n *\n * `@saastro/studio`'s autoWrap auto-instruments ~95% of a component's fields for\n * the visual editor. The 5% it can't: an ARRAY prop iterated through a DERIVED\n * variable — `const cols = items.map(...)` in the frontmatter, then `cols.map(…)`\n * in the template. autoWrap's template regex looks for `items.map(` in the body,\n * doesn't find it, and stamps no per-item markers, so those items aren't editable\n * (the #160 warning). This detector finds those props so the onboarding UI can\n * point the author at the hand-written `data-saastro-field` escape-hatch.\n *\n * Mirrors the trigger in `packages/studio/src/vite/instrument-fields.ts`\n * (`hasMapReference` + `hasHandWrittenFieldMarker`): a gap is a prop that is\n * `.map()`-ed in the frontmatter, NOT `.map()`-ed in the template (so autoWrap\n * misses it), and carries no hand-written marker yet.\n */\n\nexport interface AutoWrapGap {\n /** The prop whose items won't be editable until marked by hand. */\n field: string\n /** Why autoWrap can't reach it. */\n reason: 'derived_variable'\n /** A ready-to-paste escape-hatch marker. */\n snippet: string\n}\n\n/** Escape a string for use inside a RegExp (ported from instrument-fields.ts). */\nfunction escapeReg(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/** `<name>.map(` appears in `source` (the array is iterated). */\nfunction hasMapReference(source: string, name: string): boolean {\n return new RegExp('\\\\b' + escapeReg(name) + '\\\\s*\\\\.\\\\s*map\\\\s*\\\\(').test(source)\n}\n\n/** A hand-written `data-saastro-field={`…name[…`}` already instruments it. */\nfunction hasHandWrittenFieldMarker(template: string, name: string): boolean {\n return new RegExp(\n 'data-saastro(?:-field)?\\\\s*=\\\\s*\\\\{\\\\s*`[^`]*\\\\b' + escapeReg(name) + '[[.]',\n ).test(template)\n}\n\n/** Split a `.astro` file into its frontmatter fence body and template. */\nfunction splitFrontmatter(content: string): { frontmatter: string; template: string } {\n const open = /^\\uFEFF?[ \\t]*---[ \\t]*\\r?\\n/.exec(content)\n if (!open) return { frontmatter: '', template: content }\n const bodyStart = open[0].length\n const close = /\\r?\\n[ \\t]*---[ \\t]*(?:\\r?\\n|$)/g\n close.lastIndex = bodyStart\n const cm = close.exec(content)\n if (!cm) return { frontmatter: '', template: content }\n return {\n frontmatter: content.slice(bodyStart, cm.index),\n template: content.slice(cm.index + cm[0].length),\n }\n}\n\n/**\n * Extract the local names bound from `const { … } = Astro.props`. Handles\n * defaults (`a = 1` → `a`), renames (`a: b` → `b`, the name used in code), and\n * skips rest (`...x`).\n */\nfunction extractPropNames(frontmatter: string): string[] {\n const m = /const\\s*\\{([^}]*)\\}\\s*=\\s*Astro\\.props/.exec(frontmatter)\n if (!m) return []\n return m[1]!\n .split(',')\n .map(part => {\n const raw = part.trim()\n if (!raw || raw.startsWith('...')) return ''\n const noDefault = raw.split('=')[0]!.trim()\n const renamed = noDefault.includes(':')\n ? noDefault.split(':')[1]!.trim()\n : noDefault\n return /^[A-Za-z_$][\\w$]*$/.test(renamed) ? renamed : ''\n })\n .filter(Boolean)\n}\n\n/**\n * Find array props that autoWrap can't auto-instrument in one `.astro` file.\n * Pure: same input → same output. Never throws.\n */\nexport function findAutoWrapGaps(content: string): AutoWrapGap[] {\n const { frontmatter, template } = splitFrontmatter(content)\n const props = extractPropNames(frontmatter)\n const gaps: AutoWrapGap[] = []\n for (const name of props) {\n if (\n hasMapReference(frontmatter, name) &&\n !hasMapReference(template, name) &&\n !hasHandWrittenFieldMarker(template, name)\n ) {\n gaps.push({\n field: name,\n reason: 'derived_variable',\n snippet: `<img src={item.image} data-saastro-field={\\`${name}[\\${i}].image\\`} />`,\n })\n }\n }\n return gaps\n}\n","/**\n * Studio \"doctor\" — deterministic, I/O-free analyzer that classifies every\n * candidate section in a connected site repo by whether the Studio can edit it,\n * and (when it can't) names the precise missing convention + a ready-to-paste\n * snippet. NO LLM — pure regex + i18n-key set membership, so it's unit-testable\n * and identical run from the Hub or a CLI.\n *\n * It mirrors what the runtime resolver (`sections.ts` → `findOwnerFiles`) and the\n * overlay's marker (`data-saastro=\"sec:<key>\"`) actually do, so the report agrees\n * with the live editor:\n * - a section is editable via SCHEMA when a file declares its `fieldPrefix`\n * (call-site literal or `?? '<key>'` / destructuring default) AND has an\n * `interface *Props` (Hero-style),\n * - editable via I18N when it carries a `data-saastro=\"sec:<key>\"` marker (or a\n * fieldPrefix) whose `<key>` resolves to a translations namespace (the\n * platform i18n-forward path),\n * - a stranded section is one with a marker but neither schema nor i18n, or a\n * component that renders `t.<key>` content but carries no marker at all.\n *\n * The route (`doctor-route.ts`) does the I/O (list repo + read i18n keys) and\n * feeds this module; everything here is pure.\n */\n\nimport {\n hasFrontmatterFieldPrefix,\n hasPageContentAnchor,\n hasPageMarker,\n resolvePageKey,\n routeBaseName,\n} from './auto-wrap-core.js'\nimport { findAutoWrapGaps } from './doctor-gaps.js'\n\nexport type DoctorStatus =\n | 'editable_props' // owner file with fieldPrefix + interface Props → full schema editing\n | 'editable_i18n' // marker/fieldPrefix + matching i18n namespace → i18n-derived fields\n | 'no_schema_no_i18n' // marked but neither schema nor i18n resolves — the dead-end\n | 'missing_marker' // renders t.<key> content but has no marker/fieldPrefix → not enumerable\n | 'autowrap_gap' // editable, but a derived array prop won't auto-instrument (#160)\n\nexport interface DoctorSection {\n /** Repo-relative file the finding is anchored to. */\n file: string\n /** The section key (data-saastro / fieldPrefix / suggested i18n namespace). */\n sectionPrefix: string\n status: DoctorStatus\n /** One-line human explanation of why this status. */\n reason: string\n /** What to do about it (empty for the already-editable statuses). */\n remedy: string\n /** A ready-to-paste snippet, when there is a mechanical fix. */\n snippet?: string\n}\n\nexport interface DoctorReport {\n sections: DoctorSection[]\n stats: {\n files: number\n candidates: number\n editable: number\n needsAttention: number\n byStatus: Record<DoctorStatus, number>\n }\n}\n\n// ── Regexes (mirror sections.ts / the overlay marker) ───────────────────────\n\n/** Hand-written literal marker: `data-saastro=\"sec:<key>\"`. */\nconst MARKER_LITERAL_RE = /data-saastro\\s*=\\s*[\"']sec:([^\"']+)[\"']/g\n/** Hero-style template marker default: ``data-saastro={`sec:${fieldPrefix ?? 'hero.home'}`}``. */\nconst MARKER_DEFAULT_RE = /data-saastro\\s*=\\s*\\{\\s*`sec:\\$\\{[^}]*?\\?\\?\\s*[\"']([^\"']+)[\"']/g\n/** Call-site / destructuring literal: `fieldPrefix=\"x\"` or `fieldPrefix={`x`}` / `{\"x\"}`. */\nconst FIELDPREFIX_ATTR_RE = /fieldPrefix\\s*=\\s*(?:[\"']([^\"']+)[\"']|\\{\\s*[`\"']([^`\"'}]+)[`\"']\\s*\\})/g\n/** Component `?? '<key>'` fallback near a fieldPrefix usage. */\nconst FIELDPREFIX_NULLISH_RE = /fieldPrefix[^\\n]*?\\?\\?\\s*[\"'`]([^\"'`]+)[\"'`]/g\n/** `interface Props` / `interface <Name>Props` / `type *Props` — same as sections.ts. */\nconst PROPS_DECL_RE = /\\b(?:interface|type)\\s+\\w*Props\\b/\n/** `t.<key>` / `Astro.locals.t.<key>` usage — the i18n namespace a file reads. */\nconst T_USAGE_RE = /(?:Astro\\.locals\\.)?\\bt\\.([A-Za-z_$][\\w$]*)/g\n\nfunction matchAll(re: RegExp, s: string): string[] {\n const out: string[] = []\n re.lastIndex = 0\n let m: RegExpExecArray | null\n while ((m = re.exec(s)) !== null) {\n // Pick the first defined capture group (FIELDPREFIX_ATTR_RE has two).\n const v = m[1] ?? m[2]\n if (v) out.push(v)\n }\n return out\n}\n\n/** `src/components/HomeValues.astro` → `homeValues` (basename, lower-first). */\nfunction fileBaseCamel(path: string): string {\n const base = path.split('/').pop() ?? path\n const name = base.replace(/\\.(astro|tsx|jsx)$/, '')\n return name.charAt(0).toLowerCase() + name.slice(1)\n}\n\nfunction isPage(path: string): boolean {\n return /(^|\\/)src\\/pages\\//.test(path)\n}\n\nfunction isLayout(path: string): boolean {\n return /(^|\\/)src\\/layouts\\//.test(path) || /layout/i.test(path.split('/').pop() ?? '')\n}\n\n/** Does an i18n top-level key set resolve this (possibly dotted) section key? */\nfunction i18nResolves(keys: ReadonlySet<string>, prefix: string): boolean {\n if (keys.has(prefix)) return true\n if (prefix.includes('.')) {\n const head = prefix.split('.')[0]\n if (head && keys.has(head)) return true\n }\n return false\n}\n\ninterface FileFacts {\n path: string\n markerKeys: string[] // literal sec:<k> + template default\n ownerKeys: string[] // fieldPrefix attr / nullish default\n hasProps: boolean\n tKeys: string[] // referenced i18n namespaces\n isPage: boolean\n isLayout: boolean\n content: string\n}\n\nfunction readFacts(path: string, content: string): FileFacts {\n return {\n path,\n markerKeys: [...matchAll(MARKER_LITERAL_RE, content), ...matchAll(MARKER_DEFAULT_RE, content)],\n ownerKeys: [\n ...matchAll(FIELDPREFIX_ATTR_RE, content),\n ...matchAll(FIELDPREFIX_NULLISH_RE, content),\n ],\n hasProps: PROPS_DECL_RE.test(content),\n tKeys: [...new Set(matchAll(T_USAGE_RE, content))],\n isPage: isPage(path),\n isLayout: isLayout(path),\n content,\n }\n}\n\n/**\n * Classify every candidate section across the repo. Pure: same files + i18n keys\n * → same report.\n */\nexport function analyzeSections(\n files: ReadonlyArray<{ path: string; content: string }>,\n i18nKeys: ReadonlySet<string>,\n autoWrapPagesEnabled: boolean | 'unknown' = false,\n): DoctorReport {\n const facts = files.map(f => readFacts(f.path, f.content))\n\n // Cross-file maps. A section key is \"props-editable\" when SOME file declares\n // its fieldPrefix AND has an interface Props (mirrors matchesComponent). It is\n // \"instrumented\" when it has a literal marker OR a fieldPrefix anywhere (the\n // plugin injects the marker at runtime from the fieldPrefix).\n const propsEditableKeys = new Set<string>()\n const instrumentedKeys = new Set<string>()\n const keyToFile = new Map<string, string>() // representative file for a key\n\n // Attribution rank for a key's representative file: a schema owner (interface\n // Props) > a component DECLARING fieldPrefix in its frontmatter (i18n-only\n // owner) or carrying a hand-written marker > a mere call-site mention\n // (`<Hero fieldPrefix=\"x\" />` on a page) — so findings anchor to the\n // component that OWNS the section, not the page that happens to use it.\n // First file wins within the same rank.\n const keyRank = new Map<string, number>()\n const setKeyFile = (k: string, path: string, rank: number) => {\n if ((keyRank.get(k) ?? 0) >= rank) return\n keyRank.set(k, rank)\n keyToFile.set(k, path)\n }\n\n for (const f of facts) {\n for (const k of f.markerKeys) {\n instrumentedKeys.add(k)\n setKeyFile(k, f.path, 2)\n }\n const ownerRank = f.hasProps ? 3 : hasFrontmatterFieldPrefix(f.content) ? 2 : 1\n for (const k of f.ownerKeys) {\n instrumentedKeys.add(k)\n setKeyFile(k, f.path, ownerRank)\n if (f.hasProps) propsEditableKeys.add(k)\n }\n }\n\n const sections: DoctorSection[] = []\n const seen = new Set<string>() // `${status}:${key}:${file}` dedupe\n\n const push = (s: DoctorSection) => {\n const id = `${s.status}:${s.sectionPrefix}:${s.file}`\n if (seen.has(id)) return\n seen.add(id)\n sections.push(s)\n }\n\n // 1) Classify every instrumented candidate key.\n for (const key of instrumentedKeys) {\n const file = keyToFile.get(key) ?? ''\n if (propsEditableKeys.has(key)) {\n push({\n file,\n sectionPrefix: key,\n status: 'editable_props',\n reason: 'Has a fieldPrefix + interface Props — full schema editing.',\n remedy: '',\n })\n } else if (i18nResolves(i18nKeys, key)) {\n push({\n file,\n sectionPrefix: key,\n status: 'editable_i18n',\n reason: 'Marker + matching i18n namespace — editable via translation fields.',\n remedy: '',\n })\n } else {\n push({\n file,\n sectionPrefix: key,\n status: 'no_schema_no_i18n',\n reason:\n 'Marked section but no interface Props and no matching i18n namespace — nothing to edit.',\n remedy: `Add an i18n namespace \"${key}\" to your translations, or give the component an interface Props (+ fieldPrefix=\"${key}\").`,\n })\n }\n }\n\n // 2) missing_marker — a component that renders t.<key> content (key ∈ i18n)\n // but has NO marker and NO fieldPrefix at all → the overlay can't enumerate\n // it. (Pages are handled by step 2b below; skip layouts.) Pick the t.<key>\n // that best matches the file basename.\n for (const f of facts) {\n if (f.markerKeys.length > 0 || f.ownerKeys.length > 0 || f.isPage || f.isLayout) continue\n const i18nRefs = f.tKeys.filter(k => i18nKeys.has(k) && !instrumentedKeys.has(k))\n if (i18nRefs.length === 0) continue\n const base = fileBaseCamel(f.path)\n const key = i18nRefs.includes(base) ? base : i18nRefs[0]!\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Renders i18n content (t.${key}) but has no data-saastro marker — the editor can't see it.`,\n remedy: `Add a marker to the section's root element so the Studio enumerates it (fields then come from t.${key}).`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n }\n\n // 2b) Route pages (src/pages/**). Mirror the @saastro/studio autoWrapPages\n // decision using the SHARED parser (resolvePageKey / hasPageContentAnchor /\n // hasFrontmatterFieldPrefix / hasPageMarker), so the verdict equals what the\n // plugin actually stamps — no re-implementation, no drift. The plugin stamps\n // iff: not a frontmatter-fieldPrefix section, no existing marker, the route\n // matches a referenced i18n namespace, there's a content element to anchor,\n // AND autoWrapPages is enabled in the site's astro.config.\n for (const f of facts) {\n // Gate ONLY on src/pages membership (mirrors the plugin, which has no\n // name/path filter beyond routeBaseName). NOT on isLayout: a route page\n // named like a layout (e.g. src/pages/Layout.astro) is still a real route\n // the plugin stamps — routeBaseName already rejects non-src/pages files.\n if (!f.isPage) continue\n if (hasFrontmatterFieldPrefix(f.content)) continue // section path (transformAstroSource), not a page wrap\n if (hasPageMarker(f.content)) continue // already carries a real marker\n if (!routeBaseName(f.path)) continue // dynamic / non-keyable route\n const routeBase = routeBaseName(f.path)!\n const key = resolvePageKey(f.content, f.path)\n const anchor = hasPageContentAnchor(f.content)\n\n if (key && anchor) {\n // Already covered by child sections: the page mounts sub-namespace\n // sections (e.g. colaboradores.astro renders <ColabHero\n // fieldPrefix=\"colaboradores.hero\" />, .benefits, .dark). The plugin does\n // NOT auto-wrap such a page — it has fieldPrefix props — and it doesn't\n // need to: the sub-sections are individually editable. Flagging a missing\n // `sec:colaboradores` marker is a false positive.\n const coveredBySubsections = Array.from(instrumentedKeys).some(k =>\n k.startsWith(`${key}.`),\n )\n if (coveredBySubsections) continue\n\n if (autoWrapPagesEnabled === true) {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'editable_i18n',\n reason: `Route page rendering t.${key} on a content element matching its path — auto-instrumented as sec:${key} (autoWrapPages is on).`,\n remedy: '',\n })\n } else if (autoWrapPagesEnabled === 'unknown') {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Route page renders t.${key} on a content element matching its path, but astro.config sets autoWrap/autoWrapPages via a variable — the doctor can't verify pages auto-instrument.`,\n remedy: `Use literal flags — saastroStudio({ autoWrap: true, autoWrapPages: true }) — so the doctor can verify it, or add the marker by hand.`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n } else {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Route page renders t.${key} on a content element matching its path, but autoWrapPages is OFF so no marker ships.`,\n remedy: `Enable it once — saastroStudio({ autoWrap: true, autoWrapPages: true }) in astro.config — or add the marker by hand.`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n }\n continue\n }\n if (key && !anchor) {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Route page renders t.${key} only through child components (no HTML element to anchor) — autoWrapPages can't mark it here.`,\n remedy: `Mark the component that renders the content (give it fieldPrefix or data-saastro=\"sec:${key}\"), or wrap the page body in a plain element.`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n continue\n }\n // No namespace matches the route. If it renders some resolvable i18n on an\n // element, the route name just doesn't match → suggest a hand marker.\n if (anchor) {\n const refs = f.tKeys.filter(k => i18nResolves(i18nKeys, k) && !instrumentedKeys.has(k))\n if (refs.length > 0) {\n const k2 = refs[0]!\n push({\n file: f.path,\n sectionPrefix: k2,\n status: 'missing_marker',\n reason: `Route page renders i18n content (t.${k2}) that doesn't match its path \"${routeBase}\", and has no marker.`,\n remedy: `Add a marker so the Studio enumerates it (fields then come from t.${k2}).`,\n snippet: `data-saastro=\"sec:${k2}\"`,\n })\n }\n }\n }\n\n // 3) autowrap_gap — additive warnings for derived-array props that won't\n // auto-instrument (#160), on .astro files only.\n for (const f of facts) {\n if (!f.path.endsWith('.astro')) continue\n const gaps = findAutoWrapGaps(f.content)\n if (gaps.length === 0) continue\n const key = f.markerKeys[0] ?? f.ownerKeys[0] ?? fileBaseCamel(f.path)\n for (const g of gaps) {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'autowrap_gap',\n reason: `Array prop \"${g.field}\" is iterated via a derived variable — autoWrap can't instrument its items.`,\n remedy: 'Add a hand-written data-saastro-field marker on the iterated element.',\n snippet: g.snippet,\n })\n }\n }\n\n const byStatus: Record<DoctorStatus, number> = {\n editable_props: 0,\n editable_i18n: 0,\n no_schema_no_i18n: 0,\n missing_marker: 0,\n autowrap_gap: 0,\n }\n for (const s of sections) byStatus[s.status]++\n const editable = byStatus.editable_props + byStatus.editable_i18n\n const needsAttention =\n byStatus.no_schema_no_i18n + byStatus.missing_marker + byStatus.autowrap_gap\n\n return {\n sections,\n stats: {\n files: files.length,\n candidates: instrumentedKeys.size,\n editable,\n needsAttention,\n byStatus,\n },\n }\n}\n","/**\n * astro.config flag detection for the Studio doctor. Pure + I/O-free.\n *\n * Decides whether a site's astro.config ACTIVELY enables page auto-wrapping, so\n * the doctor's verdict equals what the `@saastro/studio` plugin actually stamps.\n * Shared by the Hub's doctor route and the theme's `studio:check` CLI so both\n * read the config the same way — no drift.\n */\n\n/** Doctor's view of the astro.config flags: on / off / present-but-not-verifiable. */\nexport type AutoWrapConfigState = boolean | 'unknown'\n\n/** Strip JS comments + string literals so a flag mentioned in a comment / JSDoc\n * @example / string doesn't read as enabled. Best-effort (we test for presence,\n * not evaluate), so over-stripping is fine.\n *\n * Single-pass scanner — NOT sequential regex replaces. The old version stripped\n * strings before line-comments, so an apostrophe inside a `//` comment\n * (`// ...which doesn't exist`) opened a bogus single-quoted span that ate real\n * code — including the `autoWrap: true` flag — making the doctor report\n * autoWrapPages OFF on a config that literally enables it. Stripping comments\n * first instead would break `//` inside string literals (e.g. `'http://…'`).\n * Tracking context in one pass handles both. Regex literals are left as-is\n * (copied verbatim); they don't carry flag names so it's harmless. */\nexport function stripCommentsAndStrings(s: string): string {\n let out = ''\n let i = 0\n const n = s.length\n while (i < n) {\n const ch = s[i]\n const nx = s[i + 1]\n // line comment → blank to EOL\n if (ch === '/' && nx === '/') {\n i += 2\n while (i < n && s[i] !== '\\n') i++\n out += ' '\n continue\n }\n // block comment → blank through the close\n if (ch === '/' && nx === '*') {\n i += 2\n while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++\n i += 2\n out += ' '\n continue\n }\n // string literal (', \", `) → keep the delimiters, drop the body\n if (ch === '\"' || ch === \"'\" || ch === '`') {\n const q = ch\n out += q\n i++\n while (i < n && s[i] !== q) {\n if (s[i] === '\\\\') i++ // skip the escaped char\n i++\n }\n out += q // closing delimiter (or EOF)\n i++\n continue\n }\n out += ch\n i++\n }\n return out\n}\n\n/** One flag's literal state in stripped config source. */\nfunction flagState(c: string, name: string): 'true' | 'false' | 'absent' | 'unknown' {\n if (new RegExp(`\\\\b${name}\\\\s*:\\\\s*true\\\\b`).test(c)) return 'true'\n if (new RegExp(`\\\\b${name}\\\\s*:\\\\s*false\\\\b`).test(c)) return 'false'\n // Mentioned but not with a literal value — shorthand (`{ autoWrapPages }`)\n // or a variable (`autoWrapPages: flags.pages`). We can't evaluate it.\n if (new RegExp(`\\\\b${name}\\\\b`).test(c)) return 'unknown'\n return 'absent'\n}\n\n/**\n * Does the astro.config ACTIVELY enable page auto-wrapping? The plugin is inert\n * unless `autoWrap: true` AND only wraps pages when `autoWrapPages: true`\n * (vite/index.ts: `enabled = opts.autoWrap === true`, `load()` bails when not\n * enabled). We require BOTH, on comment/string-stripped source so a documented-\n * but-not-active flag doesn't flip it on. Best-effort source scan, NOT a JS\n * eval — but instead of silently reporting OFF when a flag is set via a\n * variable/shorthand, we return `'unknown'` so the doctor can say so. A flag\n * that's absent entirely (e.g. the whole options object is spread in) still\n * reads as OFF, matching the plugin's default. Pure + exported for unit tests.\n */\nexport function detectAutoWrapPagesInConfig(source: string): AutoWrapConfigState {\n const c = stripCommentsAndStrings(source)\n const wrap = flagState(c, 'autoWrap')\n const pages = flagState(c, 'autoWrapPages')\n if (wrap === 'false' || pages === 'false') return false\n if (wrap === 'absent' || pages === 'absent') return false\n if (wrap === 'true' && pages === 'true') return true\n return 'unknown'\n}\n","/**\n * Studio doctor — extra repo checks (H2). Pure + I/O-free, so the Hub's Setup\n * doctor and the theme's `studio:check` CLI run the SAME rules (no drift).\n *\n * Covers the gate items the section classifier (`analyzeSections`, doctor.ts)\n * doesn't:\n * - i18n parity — every translation key present across all locales\n * - token compliance — no hardcoded hex colors in .astro (oklch / var(--*))\n * - form compliance — no raw <form>; forms go through <HubForm>\n * - allowlist — editable copy lives in i18n, not hardcoded in a section\n * - spans integrity — rich-spans leaves are well-formed + locale-consistent\n *\n * (The playbook's \"section registered in saastrocms.config / studio.config\" gate\n * is intentionally NOT implemented: section keys are per-project examples, not a\n * closed canon — the doctor validates internal coherence, not list membership.)\n *\n * Each finding carries a severity: `error` fails the gate (counts toward\n * `needsAttention`), `warn` is advisory. `runDoctorChecks` aggregates them.\n */\n\nimport { stripCommentsAndStrings } from './doctor-config.js'\nimport { SPAN_MARKS, isSpansValue } from './spans.js'\n\nexport type CheckStatus =\n | 'i18n_parity' // a key present in one locale, missing in another\n | 'hardcoded_hex' // hex color literal where a token belongs\n | 'raw_form' // <form> element instead of <HubForm>\n | 'allowlist_violation' // hardcoded editable copy in a marked section\n | 'spans_malformed' // spans-like array that violates the rich-spans shape\n | 'spans_shape_mismatch' // key is rich spans in one locale, plain string in another\n\nexport type CheckSeverity = 'error' | 'warn'\n\nexport interface CheckFinding {\n file: string\n status: CheckStatus\n severity: CheckSeverity\n /** One-line human explanation. */\n reason: string\n /** What to do about it. */\n remedy: string\n /** The offending token (key, hex, snippet) for display. */\n detail?: string\n}\n\nexport interface ChecksReport {\n findings: CheckFinding[]\n stats: {\n /** error-severity findings — these fail the gate. */\n needsAttention: number\n /** advisory findings. */\n warnings: number\n byStatus: Record<CheckStatus, number>\n }\n}\n\n// ── 1) i18n parity ──────────────────────────────────────────────────────────\n\n/** A parsed translations file for one locale. */\nexport interface LocaleTranslations {\n /** Locale code, e.g. 'en'. */\n locale: string\n /** Translations file path (anchors findings). */\n file: string\n /** Parsed JSON/YAML object (arrays + scalars treated as leaves). */\n data: unknown\n}\n\n/** Flatten a nested translations object to dotted leaf-key paths. Arrays are\n * leaves (their shape can legitimately differ per locale). */\nfunction flattenKeys(obj: unknown, prefix = ''): string[] {\n if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) {\n return prefix ? [prefix] : []\n }\n const out: string[] = []\n for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {\n const path = prefix ? `${prefix}.${k}` : k\n if (v != null && typeof v === 'object' && !Array.isArray(v)) {\n out.push(...flattenKeys(v, path))\n } else {\n out.push(path)\n }\n }\n return out\n}\n\n/** Report keys missing in any locale relative to the union across all locales. */\nexport function checkI18nParity(locales: ReadonlyArray<LocaleTranslations>): CheckFinding[] {\n if (locales.length < 2) return []\n const withKeys = locales.map(l => ({ ...l, keys: new Set(flattenKeys(l.data)) }))\n const union = new Set<string>()\n for (const l of withKeys) for (const k of l.keys) union.add(k)\n const findings: CheckFinding[] = []\n for (const l of withKeys) {\n for (const key of union) {\n if (l.keys.has(key)) continue\n findings.push({\n file: l.file,\n status: 'i18n_parity',\n severity: 'error',\n reason: `Translation key \"${key}\" is missing in locale \"${l.locale}\".`,\n remedy: `Add \"${key}\" to ${l.file} so every locale has parity.`,\n detail: key,\n })\n }\n }\n return findings\n}\n\n// ── 2) token compliance ─────────────────────────────────────────────────────\n\nconst HEX_RE = /#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\\b/g\n\n/** Collect hex color literals from a color-bearing fragment. */\nfunction collectHex(fragment: string, into: Set<string>): void {\n for (const m of fragment.matchAll(HEX_RE)) into.add('#' + m[1]!.toLowerCase())\n}\n\n/** Hex colors that leak into a component instead of a token. Scans the places a\n * brand color realistically lands: `style=` attrs, `<style>` blocks, and\n * Tailwind arbitrary values `[#fff]`. Anchor ids / hrefs (`#section`) never look\n * like hex, so they don't false-positive. Inline `<svg>` is skipped: SVG paint\n * (icons, OG-image generators) legitimately uses literal colors — oklch tokens\n * don't apply to a standalone graphic rendered to an image. */\nexport function checkTokenCompliance(\n files: ReadonlyArray<{ path: string; content: string }>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n for (const f of files) {\n if (!f.path.endsWith('.astro')) continue\n // Drop inline SVG graphics — their fills/gradients aren't themeable UI.\n const content = f.content.replace(/<svg[\\s\\S]*?<\\/svg>/gi, ' ')\n const hits = new Set<string>()\n // Tailwind arbitrary values: bg-[#fff], text-[#abc123], [#fff]\n for (const m of content.matchAll(/\\[#([0-9a-fA-F]{3,8})\\]/g)) hits.add('#' + m[1]!.toLowerCase())\n // String style attributes: style=\"color:#fff\"\n for (const m of content.matchAll(/style\\s*=\\s*[\"']([^\"']*)[\"']/g)) collectHex(m[1]!, hits)\n // JSX object style: style={{ color: '#fff' }}\n for (const m of content.matchAll(/style\\s*=\\s*\\{\\{([^}]*)\\}\\}/g)) collectHex(m[1]!, hits)\n // <style> blocks\n for (const m of content.matchAll(/<style[^>]*>([\\s\\S]*?)<\\/style>/g)) collectHex(m[1]!, hits)\n for (const hex of hits) {\n findings.push({\n file: f.path,\n status: 'hardcoded_hex',\n severity: 'error',\n reason: `Hardcoded hex color \"${hex}\" — use a design token (oklch var) instead.`,\n remedy: `Replace ${hex} with a token: var(--primary)/--accent or add --brand in src/styles/global.css.`,\n detail: hex,\n })\n }\n }\n return findings\n}\n\n// ── 3) form compliance ──────────────────────────────────────────────────────\n\nconst RAW_FORM_RE = /<form[\\s/>]/\n\n/** A raw lowercase <form> element (HubForm renders <HubForm …>, never a bare\n * <form>). Strips comments + strings first so a `<form>` mentioned in a comment\n * or string literal doesn't trip it. */\nexport function checkFormCompliance(\n files: ReadonlyArray<{ path: string; content: string }>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n for (const f of files) {\n if (!/\\.(astro|tsx|jsx)$/.test(f.path)) continue\n if (!RAW_FORM_RE.test(stripCommentsAndStrings(f.content))) continue\n findings.push({\n file: f.path,\n status: 'raw_form',\n severity: 'error',\n reason: 'Raw <form> element — forms must go through <HubForm> (@saastro/forms).',\n remedy: 'Replace the <form> with <HubForm> so submits hit submit.saastro.io with validation/captcha.',\n detail: '<form>',\n })\n }\n return findings\n}\n\n// ── 4) allowlist compliance ─────────────────────────────────────────────────\n\n/** Publishable paths the Hub writes back. Editable copy must live here, never\n * hardcoded in a component (which the Hub freezes at connect time). */\nconst ALLOWLIST_PREFIXES = ['src/pages/', 'src/content/', 'src/i18n/']\nconst ALLOWLIST_FILES = ['studio.config.json']\n\nfunction isAllowlisted(path: string): boolean {\n if (ALLOWLIST_FILES.some(f => path.endsWith(f))) return true\n if (path.endsWith('.md') || path.endsWith('.mdx')) return true\n return ALLOWLIST_PREFIXES.some(p => path.includes(p))\n}\n\n/** Hardcoded sentence-like text content (≥2 words) directly rendered as the\n * child of an element — i.e. NOT an `{interpolation}`. Best-effort. */\nconst LITERAL_TEXT_RE = />\\s*([A-Z][A-Za-z0-9'’,.!?-]*(?:\\s+[A-Za-z0-9'’,.!?-]+)+)\\s*</g\n\n/** Editable copy hardcoded inside a marked (`data-saastro`) section that lives\n * OUTSIDE the publish allowlist → the client can't edit it and it's lost on the\n * next Hub publish. Advisory (best-effort heuristic): real i18n-driven sections\n * render `{t.…}`/`{prop}`, not literal sentences. */\nexport function checkAllowlistCompliance(\n files: ReadonlyArray<{ path: string; content: string }>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n for (const f of files) {\n if (!f.path.endsWith('.astro')) continue\n if (isAllowlisted(f.path)) continue // pages/content/i18n are publishable\n if (!/data-saastro\\s*=/.test(f.content)) continue // only marked (editable) sections\n const literals = new Set<string>()\n for (const m of f.content.matchAll(LITERAL_TEXT_RE)) {\n const text = m[1]!.trim()\n if (text.length >= 6) literals.add(text)\n }\n for (const text of literals) {\n findings.push({\n file: f.path,\n status: 'allowlist_violation',\n severity: 'warn',\n reason: `Hardcoded copy \"${truncate(text)}\" in a marked section — not editable via the Hub (outside the i18n allowlist).`,\n remedy: 'Move the text into an i18n namespace and render it via {t.<key>.<field>}.',\n detail: text,\n })\n }\n }\n return findings\n}\n\nfunction truncate(s: string): string {\n return s.length > 48 ? s.slice(0, 45) + '…' : s\n}\n\n// ── 5) rich-spans integrity ─────────────────────────────────────────────────\n\ntype SpansLeafKind = 'spans' | 'spans-malformed' | 'string' | 'other'\n\n/** Looks like an ATTEMPT at rich spans: array of objects where some element\n * carries a `text` key. Generic i18n arrays (strings, `{label, href}` items)\n * don't match, so they stay out of this check entirely. */\nfunction isSpansLike(v: unknown): v is unknown[] {\n return (\n Array.isArray(v) &&\n v.length > 0 &&\n v.some(\n item =>\n item !== null &&\n typeof item === 'object' &&\n !Array.isArray(item) &&\n 'text' in (item as Record<string, unknown>),\n )\n )\n}\n\n/** Human-readable reason a spans-like array fails `isSpansValue`. */\nfunction spansDefect(v: unknown[]): string {\n for (let i = 0; i < v.length; i++) {\n const item = v[i]\n if (item === null || typeof item !== 'object' || Array.isArray(item))\n return `element ${i} is not a span object`\n const span = item as Record<string, unknown>\n if (typeof span.text !== 'string') return `element ${i} has a non-string \"text\"`\n for (const k of Object.keys(span)) {\n if (k !== 'text' && k !== 'marks') return `element ${i} has unknown key \"${k}\"`\n }\n if (span.marks !== undefined) {\n if (!Array.isArray(span.marks)) return `element ${i} has a non-array \"marks\"`\n for (const m of span.marks) {\n if (!(SPAN_MARKS as readonly string[]).includes(m as string))\n return `element ${i} uses unknown mark \"${String(m)}\" (whitelist: ${SPAN_MARKS.join(', ')})`\n }\n }\n }\n return 'unknown defect'\n}\n\n/** Flatten one locale's tree to leaf paths classified for the spans check. */\nfunction classifyLeaves(obj: unknown, prefix: string, out: Map<string, SpansLeafKind>): void {\n if (obj === null || typeof obj !== 'object') {\n if (prefix) out.set(prefix, typeof obj === 'string' ? 'string' : 'other')\n return\n }\n if (Array.isArray(obj)) {\n if (isSpansValue(obj)) {\n out.set(prefix, 'spans')\n } else if (isSpansLike(obj)) {\n out.set(prefix, 'spans-malformed')\n } else {\n // Generic array: recurse — spans can sit inside collection items.\n obj.forEach((item, i) => classifyLeaves(item, prefix ? `${prefix}.${i}` : String(i), out))\n }\n return\n }\n for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {\n classifyLeaves(v, prefix ? `${prefix}.${k}` : k, out)\n }\n}\n\n/**\n * Rich-spans integrity:\n * - `spans_malformed` (error): a spans-like array that violates the shape\n * (unknown mark, non-string text, stray keys). Published as-is it renders\n * `[object Object]` on themes without the spans-aware renderer.\n * - `spans_shape_mismatch` (warn): a key that is rich spans in one locale\n * but a plain string in another — the dual renderer degrades fine, but\n * the spans migration is meant to be locale-atomic (deepMerge leaks the\n * default locale's array into locales missing the key).\n */\nexport function checkSpansIntegrity(\n locales: ReadonlyArray<LocaleTranslations>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n const perLocale = locales.map(l => {\n const map = new Map<string, SpansLeafKind>()\n classifyLeaves(l.data, '', map)\n return { ...l, map }\n })\n\n for (const l of perLocale) {\n for (const [path, kind] of l.map) {\n if (kind !== 'spans-malformed') continue\n const raw = getAtDottedPath(l.data, path)\n findings.push({\n file: l.file,\n status: 'spans_malformed',\n severity: 'error',\n reason: `Key \"${path}\" (${l.locale}) looks like rich spans but is malformed: ${\n Array.isArray(raw) ? spansDefect(raw) : 'not an array'\n }.`,\n remedy: `Fix the value to [{ text, marks?: [${SPAN_MARKS.map(m => `'${m}'`).join(' | ')}] }] or revert it to a plain string.`,\n detail: path,\n })\n }\n }\n\n const spansAnywhere = new Set<string>()\n for (const l of perLocale) {\n for (const [path, kind] of l.map) if (kind === 'spans') spansAnywhere.add(path)\n }\n for (const path of spansAnywhere) {\n for (const l of perLocale) {\n if (l.map.get(path) !== 'string') continue\n findings.push({\n file: l.file,\n status: 'spans_shape_mismatch',\n severity: 'warn',\n reason: `Key \"${path}\" is rich spans in another locale but a plain string in \"${l.locale}\" — spans migrations should cover all locales atomically.`,\n remedy: `Convert \"${path}\" in ${l.file} to the same rich-spans shape (or revert every locale to a string).`,\n detail: path,\n })\n }\n }\n return findings\n}\n\nfunction getAtDottedPath(obj: unknown, dotted: string): unknown {\n let cursor: unknown = obj\n for (const seg of dotted.split('.')) {\n if (cursor === null || typeof cursor !== 'object') return undefined\n cursor = Array.isArray(cursor)\n ? cursor[Number(seg)]\n : (cursor as Record<string, unknown>)[seg]\n }\n return cursor\n}\n\n// ── aggregator ───────────────────────────────────────────────────────────────\n\nexport interface DoctorChecksInput {\n files: ReadonlyArray<{ path: string; content: string }>\n locales: ReadonlyArray<LocaleTranslations>\n}\n\n/** Run all five checks and tally them (error count = needsAttention). */\nexport function runDoctorChecks(input: DoctorChecksInput): ChecksReport {\n const findings: CheckFinding[] = [\n ...checkI18nParity(input.locales),\n ...checkTokenCompliance(input.files),\n ...checkFormCompliance(input.files),\n ...checkAllowlistCompliance(input.files),\n ...checkSpansIntegrity(input.locales),\n ]\n const byStatus: Record<CheckStatus, number> = {\n i18n_parity: 0,\n hardcoded_hex: 0,\n raw_form: 0,\n allowlist_violation: 0,\n spans_malformed: 0,\n spans_shape_mismatch: 0,\n }\n let needsAttention = 0\n let warnings = 0\n for (const f of findings) {\n byStatus[f.status]++\n if (f.severity === 'error') needsAttention++\n else warnings++\n }\n return { findings, stats: { needsAttention, warnings, byStatus } }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../studio-core/src/doctor-gaps.ts","../../studio-core/src/doctor.ts","../../studio-core/src/doctor-config.ts","../../studio-core/src/doctor-checks.ts"],"sourcesContent":["/**\n * autoWrap gap report (brownfield onboarding Fase 3). Pure + I/O-free.\n *\n * `@saastro/studio`'s autoWrap auto-instruments ~95% of a component's fields for\n * the visual editor. The 5% it can't: an ARRAY prop iterated through a DERIVED\n * variable — `const cols = items.map(...)` in the frontmatter, then `cols.map(…)`\n * in the template. autoWrap's template regex looks for `items.map(` in the body,\n * doesn't find it, and stamps no per-item markers, so those items aren't editable\n * (the #160 warning). This detector finds those props so the onboarding UI can\n * point the author at the hand-written `data-saastro-field` escape-hatch.\n *\n * Mirrors the trigger in `packages/studio/src/vite/instrument-fields.ts`\n * (`hasMapReference` + `hasHandWrittenFieldMarker`): a gap is a prop that is\n * `.map()`-ed in the frontmatter, NOT `.map()`-ed in the template (so autoWrap\n * misses it), and carries no hand-written marker yet.\n */\n\nexport interface AutoWrapGap {\n /** The prop whose items won't be editable until marked by hand. */\n field: string\n /** Why autoWrap can't reach it. */\n reason: 'derived_variable'\n /** A ready-to-paste escape-hatch marker. */\n snippet: string\n}\n\n/** Escape a string for use inside a RegExp (ported from instrument-fields.ts). */\nfunction escapeReg(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/** `<name>.map(` appears in `source` (the array is iterated). */\nfunction hasMapReference(source: string, name: string): boolean {\n return new RegExp('\\\\b' + escapeReg(name) + '\\\\s*\\\\.\\\\s*map\\\\s*\\\\(').test(source)\n}\n\n/** A hand-written `data-saastro-field={`…name[…`}` already instruments it. */\nfunction hasHandWrittenFieldMarker(template: string, name: string): boolean {\n return new RegExp(\n 'data-saastro(?:-field)?\\\\s*=\\\\s*\\\\{\\\\s*`[^`]*\\\\b' + escapeReg(name) + '[[.]',\n ).test(template)\n}\n\n/** Split a `.astro` file into its frontmatter fence body and template. */\nfunction splitFrontmatter(content: string): { frontmatter: string; template: string } {\n const open = /^\\uFEFF?[ \\t]*---[ \\t]*\\r?\\n/.exec(content)\n if (!open) return { frontmatter: '', template: content }\n const bodyStart = open[0].length\n const close = /\\r?\\n[ \\t]*---[ \\t]*(?:\\r?\\n|$)/g\n close.lastIndex = bodyStart\n const cm = close.exec(content)\n if (!cm) return { frontmatter: '', template: content }\n return {\n frontmatter: content.slice(bodyStart, cm.index),\n template: content.slice(cm.index + cm[0].length),\n }\n}\n\n/**\n * Extract the local names bound from `const { … } = Astro.props`. Handles\n * defaults (`a = 1` → `a`), renames (`a: b` → `b`, the name used in code), and\n * skips rest (`...x`).\n */\nfunction extractPropNames(frontmatter: string): string[] {\n const m = /const\\s*\\{([^}]*)\\}\\s*=\\s*Astro\\.props/.exec(frontmatter)\n if (!m) return []\n return m[1]!\n .split(',')\n .map(part => {\n const raw = part.trim()\n if (!raw || raw.startsWith('...')) return ''\n const noDefault = raw.split('=')[0]!.trim()\n const renamed = noDefault.includes(':')\n ? noDefault.split(':')[1]!.trim()\n : noDefault\n return /^[A-Za-z_$][\\w$]*$/.test(renamed) ? renamed : ''\n })\n .filter(Boolean)\n}\n\n/**\n * Find array props that autoWrap can't auto-instrument in one `.astro` file.\n * Pure: same input → same output. Never throws.\n */\nexport function findAutoWrapGaps(content: string): AutoWrapGap[] {\n const { frontmatter, template } = splitFrontmatter(content)\n const props = extractPropNames(frontmatter)\n const gaps: AutoWrapGap[] = []\n for (const name of props) {\n if (\n hasMapReference(frontmatter, name) &&\n !hasMapReference(template, name) &&\n !hasHandWrittenFieldMarker(template, name)\n ) {\n gaps.push({\n field: name,\n reason: 'derived_variable',\n snippet: `<img src={item.image} data-saastro-field={\\`${name}[\\${i}].image\\`} />`,\n })\n }\n }\n return gaps\n}\n","/**\n * Studio \"doctor\" — deterministic, I/O-free analyzer that classifies every\n * candidate section in a connected site repo by whether the Studio can edit it,\n * and (when it can't) names the precise missing convention + a ready-to-paste\n * snippet. NO LLM — pure regex + i18n-key set membership, so it's unit-testable\n * and identical run from the Hub or a CLI.\n *\n * It mirrors what the runtime resolver (`sections.ts` → `findOwnerFiles`) and the\n * overlay's marker (`data-saastro=\"sec:<key>\"`) actually do, so the report agrees\n * with the live editor:\n * - a section is editable via SCHEMA when a file declares its `fieldPrefix`\n * (call-site literal or `?? '<key>'` / destructuring default) AND has an\n * `interface *Props` (Hero-style),\n * - editable via I18N when it carries a `data-saastro=\"sec:<key>\"` marker (or a\n * fieldPrefix) whose `<key>` resolves to a translations namespace (the\n * platform i18n-forward path),\n * - a stranded section is one with a marker but neither schema nor i18n, or a\n * component that renders `t.<key>` content but carries no marker at all.\n *\n * The route (`doctor-route.ts`) does the I/O (list repo + read i18n keys) and\n * feeds this module; everything here is pure.\n */\n\nimport {\n hasFrontmatterFieldPrefix,\n hasPageContentAnchor,\n hasPageMarker,\n resolvePageKey,\n routeBaseName,\n} from './auto-wrap-core.js'\nimport { findAutoWrapGaps } from './doctor-gaps.js'\n\nexport type DoctorStatus =\n | 'editable_props' // owner file with fieldPrefix + interface Props → full schema editing\n | 'editable_i18n' // marker/fieldPrefix + matching i18n namespace → i18n-derived fields\n | 'no_schema_no_i18n' // marked but neither schema nor i18n resolves — the dead-end\n | 'missing_marker' // renders t.<key> content but has no marker/fieldPrefix → not enumerable\n | 'autowrap_gap' // editable, but a derived array prop won't auto-instrument (#160)\n\nexport interface DoctorSection {\n /** Repo-relative file the finding is anchored to. */\n file: string\n /** The section key (data-saastro / fieldPrefix / suggested i18n namespace). */\n sectionPrefix: string\n status: DoctorStatus\n /** One-line human explanation of why this status. */\n reason: string\n /** What to do about it (empty for the already-editable statuses). */\n remedy: string\n /** A ready-to-paste snippet, when there is a mechanical fix. */\n snippet?: string\n}\n\nexport interface DoctorReport {\n sections: DoctorSection[]\n stats: {\n files: number\n candidates: number\n editable: number\n needsAttention: number\n byStatus: Record<DoctorStatus, number>\n }\n}\n\n// ── Regexes (mirror sections.ts / the overlay marker) ───────────────────────\n\n/** Hand-written literal marker: `data-saastro=\"sec:<key>\"`. */\nconst MARKER_LITERAL_RE = /data-saastro\\s*=\\s*[\"']sec:([^\"']+)[\"']/g\n/** Hero-style template marker default: ``data-saastro={`sec:${fieldPrefix ?? 'hero.home'}`}``. */\nconst MARKER_DEFAULT_RE = /data-saastro\\s*=\\s*\\{\\s*`sec:\\$\\{[^}]*?\\?\\?\\s*[\"']([^\"']+)[\"']/g\n/** Call-site / destructuring literal: `fieldPrefix=\"x\"` or `fieldPrefix={`x`}` / `{\"x\"}`. */\nconst FIELDPREFIX_ATTR_RE = /fieldPrefix\\s*=\\s*(?:[\"']([^\"']+)[\"']|\\{\\s*[`\"']([^`\"'}]+)[`\"']\\s*\\})/g\n/** Component `?? '<key>'` fallback near a fieldPrefix usage. */\nconst FIELDPREFIX_NULLISH_RE = /fieldPrefix[^\\n]*?\\?\\?\\s*[\"'`]([^\"'`]+)[\"'`]/g\n/** `interface Props` / `interface <Name>Props` / `type *Props` — same as sections.ts. */\nconst PROPS_DECL_RE = /\\b(?:interface|type)\\s+\\w*Props\\b/\n/** `t.<key>` / `Astro.locals.t.<key>` usage — the i18n namespace a file reads. */\nconst T_USAGE_RE = /(?:Astro\\.locals\\.)?\\bt\\.([A-Za-z_$][\\w$]*)/g\n\nfunction matchAll(re: RegExp, s: string): string[] {\n const out: string[] = []\n re.lastIndex = 0\n let m: RegExpExecArray | null\n while ((m = re.exec(s)) !== null) {\n // Pick the first defined capture group (FIELDPREFIX_ATTR_RE has two).\n const v = m[1] ?? m[2]\n if (v) out.push(v)\n }\n return out\n}\n\n/** `src/components/HomeValues.astro` → `homeValues` (basename, lower-first). */\nfunction fileBaseCamel(path: string): string {\n const base = path.split('/').pop() ?? path\n const name = base.replace(/\\.(astro|tsx|jsx)$/, '')\n return name.charAt(0).toLowerCase() + name.slice(1)\n}\n\nfunction isPage(path: string): boolean {\n return /(^|\\/)src\\/pages\\//.test(path)\n}\n\nfunction isLayout(path: string): boolean {\n return /(^|\\/)src\\/layouts\\//.test(path) || /layout/i.test(path.split('/').pop() ?? '')\n}\n\n/** Does an i18n top-level key set resolve this (possibly dotted) section key? */\nfunction i18nResolves(keys: ReadonlySet<string>, prefix: string): boolean {\n if (keys.has(prefix)) return true\n if (prefix.includes('.')) {\n const head = prefix.split('.')[0]\n if (head && keys.has(head)) return true\n }\n return false\n}\n\ninterface FileFacts {\n path: string\n markerKeys: string[] // literal sec:<k> + template default\n ownerKeys: string[] // fieldPrefix attr / nullish default\n hasProps: boolean\n tKeys: string[] // referenced i18n namespaces\n isPage: boolean\n isLayout: boolean\n content: string\n}\n\nfunction readFacts(path: string, content: string): FileFacts {\n return {\n path,\n markerKeys: [...matchAll(MARKER_LITERAL_RE, content), ...matchAll(MARKER_DEFAULT_RE, content)],\n ownerKeys: [\n ...matchAll(FIELDPREFIX_ATTR_RE, content),\n ...matchAll(FIELDPREFIX_NULLISH_RE, content),\n ],\n hasProps: PROPS_DECL_RE.test(content),\n tKeys: [...new Set(matchAll(T_USAGE_RE, content))],\n isPage: isPage(path),\n isLayout: isLayout(path),\n content,\n }\n}\n\n/**\n * Classify every candidate section across the repo. Pure: same files + i18n keys\n * → same report.\n */\nexport function analyzeSections(\n files: ReadonlyArray<{ path: string; content: string }>,\n i18nKeys: ReadonlySet<string>,\n autoWrapPagesEnabled: boolean | 'unknown' = false,\n): DoctorReport {\n const facts = files.map(f => readFacts(f.path, f.content))\n\n // Cross-file maps. A section key is \"props-editable\" when SOME file declares\n // its fieldPrefix AND has an interface Props (mirrors matchesComponent). It is\n // \"instrumented\" when it has a literal marker OR a fieldPrefix anywhere (the\n // plugin injects the marker at runtime from the fieldPrefix).\n const propsEditableKeys = new Set<string>()\n const instrumentedKeys = new Set<string>()\n const keyToFile = new Map<string, string>() // representative file for a key\n\n // Attribution rank for a key's representative file: a schema owner (interface\n // Props) > a component DECLARING fieldPrefix in its frontmatter (i18n-only\n // owner) or carrying a hand-written marker > a mere call-site mention\n // (`<Hero fieldPrefix=\"x\" />` on a page) — so findings anchor to the\n // component that OWNS the section, not the page that happens to use it.\n // First file wins within the same rank.\n const keyRank = new Map<string, number>()\n const setKeyFile = (k: string, path: string, rank: number) => {\n if ((keyRank.get(k) ?? 0) >= rank) return\n keyRank.set(k, rank)\n keyToFile.set(k, path)\n }\n\n for (const f of facts) {\n for (const k of f.markerKeys) {\n instrumentedKeys.add(k)\n setKeyFile(k, f.path, 2)\n }\n const ownerRank = f.hasProps ? 3 : hasFrontmatterFieldPrefix(f.content) ? 2 : 1\n for (const k of f.ownerKeys) {\n instrumentedKeys.add(k)\n setKeyFile(k, f.path, ownerRank)\n if (f.hasProps) propsEditableKeys.add(k)\n }\n }\n\n const sections: DoctorSection[] = []\n const seen = new Set<string>() // `${status}:${key}:${file}` dedupe\n\n const push = (s: DoctorSection) => {\n const id = `${s.status}:${s.sectionPrefix}:${s.file}`\n if (seen.has(id)) return\n seen.add(id)\n sections.push(s)\n }\n\n // 1) Classify every instrumented candidate key.\n for (const key of instrumentedKeys) {\n const file = keyToFile.get(key) ?? ''\n if (propsEditableKeys.has(key)) {\n push({\n file,\n sectionPrefix: key,\n status: 'editable_props',\n reason: 'Has a fieldPrefix + interface Props — full schema editing.',\n remedy: '',\n })\n } else if (i18nResolves(i18nKeys, key)) {\n push({\n file,\n sectionPrefix: key,\n status: 'editable_i18n',\n reason: 'Marker + matching i18n namespace — editable via translation fields.',\n remedy: '',\n })\n } else {\n push({\n file,\n sectionPrefix: key,\n status: 'no_schema_no_i18n',\n reason:\n 'Marked section but no interface Props and no matching i18n namespace — nothing to edit.',\n remedy: `Add an i18n namespace \"${key}\" to your translations, or give the component an interface Props (+ fieldPrefix=\"${key}\").`,\n })\n }\n }\n\n // 2) missing_marker — a component that renders t.<key> content (key ∈ i18n)\n // but has NO marker and NO fieldPrefix at all → the overlay can't enumerate\n // it. (Pages are handled by step 2b below; skip layouts.) Pick the t.<key>\n // that best matches the file basename.\n for (const f of facts) {\n if (f.markerKeys.length > 0 || f.ownerKeys.length > 0 || f.isPage || f.isLayout) continue\n const i18nRefs = f.tKeys.filter(k => i18nKeys.has(k) && !instrumentedKeys.has(k))\n if (i18nRefs.length === 0) continue\n const base = fileBaseCamel(f.path)\n const key = i18nRefs.includes(base) ? base : i18nRefs[0]!\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Renders i18n content (t.${key}) but has no data-saastro marker — the editor can't see it.`,\n remedy: `Add a marker to the section's root element so the Studio enumerates it (fields then come from t.${key}).`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n }\n\n // 2b) Route pages (src/pages/**). Mirror the @saastro/studio autoWrapPages\n // decision using the SHARED parser (resolvePageKey / hasPageContentAnchor /\n // hasFrontmatterFieldPrefix / hasPageMarker), so the verdict equals what the\n // plugin actually stamps — no re-implementation, no drift. The plugin stamps\n // iff: not a frontmatter-fieldPrefix section, no existing marker, the route\n // matches a referenced i18n namespace, there's a content element to anchor,\n // AND autoWrapPages is enabled in the site's astro.config.\n for (const f of facts) {\n // Gate ONLY on src/pages membership (mirrors the plugin, which has no\n // name/path filter beyond routeBaseName). NOT on isLayout: a route page\n // named like a layout (e.g. src/pages/Layout.astro) is still a real route\n // the plugin stamps — routeBaseName already rejects non-src/pages files.\n if (!f.isPage) continue\n if (hasFrontmatterFieldPrefix(f.content)) continue // section path (transformAstroSource), not a page wrap\n if (hasPageMarker(f.content)) continue // already carries a real marker\n if (!routeBaseName(f.path)) continue // dynamic / non-keyable route\n const routeBase = routeBaseName(f.path)!\n const key = resolvePageKey(f.content, f.path)\n const anchor = hasPageContentAnchor(f.content)\n\n if (key && anchor) {\n // Already covered by child sections: the page mounts sub-namespace\n // sections (e.g. colaboradores.astro renders <ColabHero\n // fieldPrefix=\"colaboradores.hero\" />, .benefits, .dark). The plugin does\n // NOT auto-wrap such a page — it has fieldPrefix props — and it doesn't\n // need to: the sub-sections are individually editable. Flagging a missing\n // `sec:colaboradores` marker is a false positive.\n const coveredBySubsections = Array.from(instrumentedKeys).some(k =>\n k.startsWith(`${key}.`),\n )\n if (coveredBySubsections) continue\n\n if (autoWrapPagesEnabled === true) {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'editable_i18n',\n reason: `Route page rendering t.${key} on a content element matching its path — auto-instrumented as sec:${key} (autoWrapPages is on).`,\n remedy: '',\n })\n } else if (autoWrapPagesEnabled === 'unknown') {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Route page renders t.${key} on a content element matching its path, but astro.config sets autoWrap/autoWrapPages via a variable — the doctor can't verify pages auto-instrument.`,\n remedy: `Use literal flags — saastroStudio({ autoWrap: true, autoWrapPages: true }) — so the doctor can verify it, or add the marker by hand.`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n } else {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Route page renders t.${key} on a content element matching its path, but autoWrapPages is OFF so no marker ships.`,\n remedy: `Enable it once — saastroStudio({ autoWrap: true, autoWrapPages: true }) in astro.config — or add the marker by hand.`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n }\n continue\n }\n if (key && !anchor) {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'missing_marker',\n reason: `Route page renders t.${key} only through child components (no HTML element to anchor) — autoWrapPages can't mark it here.`,\n remedy: `Mark the component that renders the content (give it fieldPrefix or data-saastro=\"sec:${key}\"), or wrap the page body in a plain element.`,\n snippet: `data-saastro=\"sec:${key}\"`,\n })\n continue\n }\n // No namespace matches the route. If it renders some resolvable i18n on an\n // element, the route name just doesn't match → suggest a hand marker.\n if (anchor) {\n const refs = f.tKeys.filter(k => i18nResolves(i18nKeys, k) && !instrumentedKeys.has(k))\n if (refs.length > 0) {\n const k2 = refs[0]!\n push({\n file: f.path,\n sectionPrefix: k2,\n status: 'missing_marker',\n reason: `Route page renders i18n content (t.${k2}) that doesn't match its path \"${routeBase}\", and has no marker.`,\n remedy: `Add a marker so the Studio enumerates it (fields then come from t.${k2}).`,\n snippet: `data-saastro=\"sec:${k2}\"`,\n })\n }\n }\n }\n\n // 3) autowrap_gap — additive warnings for derived-array props that won't\n // auto-instrument (#160), on .astro files only.\n for (const f of facts) {\n if (!f.path.endsWith('.astro')) continue\n const gaps = findAutoWrapGaps(f.content)\n if (gaps.length === 0) continue\n const key = f.markerKeys[0] ?? f.ownerKeys[0] ?? fileBaseCamel(f.path)\n for (const g of gaps) {\n push({\n file: f.path,\n sectionPrefix: key,\n status: 'autowrap_gap',\n reason: `Array prop \"${g.field}\" is iterated via a derived variable — autoWrap can't instrument its items.`,\n remedy: 'Add a hand-written data-saastro-field marker on the iterated element.',\n snippet: g.snippet,\n })\n }\n }\n\n const byStatus: Record<DoctorStatus, number> = {\n editable_props: 0,\n editable_i18n: 0,\n no_schema_no_i18n: 0,\n missing_marker: 0,\n autowrap_gap: 0,\n }\n for (const s of sections) byStatus[s.status]++\n const editable = byStatus.editable_props + byStatus.editable_i18n\n const needsAttention =\n byStatus.no_schema_no_i18n + byStatus.missing_marker + byStatus.autowrap_gap\n\n return {\n sections,\n stats: {\n files: files.length,\n candidates: instrumentedKeys.size,\n editable,\n needsAttention,\n byStatus,\n },\n }\n}\n","/**\n * astro.config flag detection for the Studio doctor. Pure + I/O-free.\n *\n * Decides whether a site's astro.config ACTIVELY enables page auto-wrapping, so\n * the doctor's verdict equals what the `@saastro/studio` plugin actually stamps.\n * Shared by the Hub's doctor route and the theme's `studio:check` CLI so both\n * read the config the same way — no drift.\n */\n\n/** Doctor's view of the astro.config flags: on / off / present-but-not-verifiable. */\nexport type AutoWrapConfigState = boolean | 'unknown'\n\n/** Strip JS comments + string literals so a flag mentioned in a comment / JSDoc\n * @example / string doesn't read as enabled. Best-effort (we test for presence,\n * not evaluate), so over-stripping is fine.\n *\n * Single-pass scanner — NOT sequential regex replaces. The old version stripped\n * strings before line-comments, so an apostrophe inside a `//` comment\n * (`// ...which doesn't exist`) opened a bogus single-quoted span that ate real\n * code — including the `autoWrap: true` flag — making the doctor report\n * autoWrapPages OFF on a config that literally enables it. Stripping comments\n * first instead would break `//` inside string literals (e.g. `'http://…'`).\n * Tracking context in one pass handles both. Regex literals are left as-is\n * (copied verbatim); they don't carry flag names so it's harmless. */\nexport function stripCommentsAndStrings(s: string): string {\n let out = ''\n let i = 0\n const n = s.length\n while (i < n) {\n const ch = s[i]\n const nx = s[i + 1]\n // line comment → blank to EOL\n if (ch === '/' && nx === '/') {\n i += 2\n while (i < n && s[i] !== '\\n') i++\n out += ' '\n continue\n }\n // block comment → blank through the close\n if (ch === '/' && nx === '*') {\n i += 2\n while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++\n i += 2\n out += ' '\n continue\n }\n // string literal (', \", `) → keep the delimiters, drop the body\n if (ch === '\"' || ch === \"'\" || ch === '`') {\n const q = ch\n out += q\n i++\n while (i < n && s[i] !== q) {\n if (s[i] === '\\\\') i++ // skip the escaped char\n i++\n }\n out += q // closing delimiter (or EOF)\n i++\n continue\n }\n out += ch\n i++\n }\n return out\n}\n\n/** One flag's literal state in stripped config source. */\nfunction flagState(c: string, name: string): 'true' | 'false' | 'absent' | 'unknown' {\n if (new RegExp(`\\\\b${name}\\\\s*:\\\\s*true\\\\b`).test(c)) return 'true'\n if (new RegExp(`\\\\b${name}\\\\s*:\\\\s*false\\\\b`).test(c)) return 'false'\n // Mentioned but not with a literal value — shorthand (`{ autoWrapPages }`)\n // or a variable (`autoWrapPages: flags.pages`). We can't evaluate it.\n if (new RegExp(`\\\\b${name}\\\\b`).test(c)) return 'unknown'\n return 'absent'\n}\n\n/**\n * Does the astro.config ACTIVELY enable page auto-wrapping? The plugin is inert\n * unless `autoWrap: true` AND only wraps pages when `autoWrapPages: true`\n * (vite/index.ts: `enabled = opts.autoWrap === true`, `load()` bails when not\n * enabled). We require BOTH, on comment/string-stripped source so a documented-\n * but-not-active flag doesn't flip it on. Best-effort source scan, NOT a JS\n * eval — but instead of silently reporting OFF when a flag is set via a\n * variable/shorthand, we return `'unknown'` so the doctor can say so. A flag\n * that's absent entirely (e.g. the whole options object is spread in) still\n * reads as OFF, matching the plugin's default. Pure + exported for unit tests.\n */\nexport function detectAutoWrapPagesInConfig(source: string): AutoWrapConfigState {\n const c = stripCommentsAndStrings(source)\n const wrap = flagState(c, 'autoWrap')\n const pages = flagState(c, 'autoWrapPages')\n if (wrap === 'false' || pages === 'false') return false\n if (wrap === 'absent' || pages === 'absent') return false\n if (wrap === 'true' && pages === 'true') return true\n return 'unknown'\n}\n","/**\n * Studio doctor — extra repo checks (H2). Pure + I/O-free, so the Hub's Setup\n * doctor and the theme's `studio:check` CLI run the SAME rules (no drift).\n *\n * Covers the gate items the section classifier (`analyzeSections`, doctor.ts)\n * doesn't:\n * - i18n parity — every translation key present across all locales\n * - token compliance — no hardcoded hex colors in .astro (oklch / var(--*))\n * - form compliance — no raw <form>; forms go through <HubForm>\n * - allowlist — editable copy lives in i18n, not hardcoded in a section\n * - spans integrity — rich-spans leaves are well-formed + locale-consistent\n *\n * (The playbook's \"section registered in saastrocms.config / studio.config\" gate\n * is intentionally NOT implemented: section keys are per-project examples, not a\n * closed canon — the doctor validates internal coherence, not list membership.)\n *\n * Each finding carries a severity: `error` fails the gate (counts toward\n * `needsAttention`), `warn` is advisory. `runDoctorChecks` aggregates them.\n */\n\nimport { stripCommentsAndStrings } from './doctor-config.js'\nimport { SPAN_MARKS, isSpansShape, isSpansValue, isValidMark } from './spans.js'\n\nexport type CheckStatus =\n | 'i18n_parity' // a key present in one locale, missing in another\n | 'hardcoded_hex' // hex color literal where a token belongs\n | 'raw_form' // <form> element instead of <HubForm>\n | 'allowlist_violation' // hardcoded editable copy in a marked section\n | 'spans_malformed' // spans-like array that violates the rich-spans shape\n | 'spans_shape_mismatch' // key is rich spans in one locale, plain string in another\n | 'theme_font_not_overridable' // @theme inlines a literal font family → Studio font swaps are no-ops\n\nexport type CheckSeverity = 'error' | 'warn'\n\nexport interface CheckFinding {\n file: string\n status: CheckStatus\n severity: CheckSeverity\n /** One-line human explanation. */\n reason: string\n /** What to do about it. */\n remedy: string\n /** The offending token (key, hex, snippet) for display. */\n detail?: string\n}\n\nexport interface ChecksReport {\n findings: CheckFinding[]\n stats: {\n /** error-severity findings — these fail the gate. */\n needsAttention: number\n /** advisory findings. */\n warnings: number\n byStatus: Record<CheckStatus, number>\n }\n}\n\n// ── 1) i18n parity ──────────────────────────────────────────────────────────\n\n/** A parsed translations file for one locale. */\nexport interface LocaleTranslations {\n /** Locale code, e.g. 'en'. */\n locale: string\n /** Translations file path (anchors findings). */\n file: string\n /** Parsed JSON/YAML object (arrays + scalars treated as leaves). */\n data: unknown\n}\n\n/** Flatten a nested translations object to dotted leaf-key paths. Arrays are\n * leaves (their shape can legitimately differ per locale). */\nfunction flattenKeys(obj: unknown, prefix = ''): string[] {\n if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) {\n return prefix ? [prefix] : []\n }\n const out: string[] = []\n for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {\n const path = prefix ? `${prefix}.${k}` : k\n if (v != null && typeof v === 'object' && !Array.isArray(v)) {\n out.push(...flattenKeys(v, path))\n } else {\n out.push(path)\n }\n }\n return out\n}\n\n/** Report keys missing in any locale relative to the union across all locales. */\nexport function checkI18nParity(locales: ReadonlyArray<LocaleTranslations>): CheckFinding[] {\n if (locales.length < 2) return []\n const withKeys = locales.map(l => ({ ...l, keys: new Set(flattenKeys(l.data)) }))\n const union = new Set<string>()\n for (const l of withKeys) for (const k of l.keys) union.add(k)\n const findings: CheckFinding[] = []\n for (const l of withKeys) {\n for (const key of union) {\n if (l.keys.has(key)) continue\n findings.push({\n file: l.file,\n status: 'i18n_parity',\n severity: 'error',\n reason: `Translation key \"${key}\" is missing in locale \"${l.locale}\".`,\n remedy: `Add \"${key}\" to ${l.file} so every locale has parity.`,\n detail: key,\n })\n }\n }\n return findings\n}\n\n// ── 2) token compliance ─────────────────────────────────────────────────────\n\nconst HEX_RE = /#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\\b/g\n\n/** Collect hex color literals from a color-bearing fragment. */\nfunction collectHex(fragment: string, into: Set<string>): void {\n for (const m of fragment.matchAll(HEX_RE)) into.add('#' + m[1]!.toLowerCase())\n}\n\n/** Hex colors that leak into a component instead of a token. Scans the places a\n * brand color realistically lands: `style=` attrs, `<style>` blocks, and\n * Tailwind arbitrary values `[#fff]`. Anchor ids / hrefs (`#section`) never look\n * like hex, so they don't false-positive. Inline `<svg>` is skipped: SVG paint\n * (icons, OG-image generators) legitimately uses literal colors — oklch tokens\n * don't apply to a standalone graphic rendered to an image. */\nexport function checkTokenCompliance(\n files: ReadonlyArray<{ path: string; content: string }>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n for (const f of files) {\n if (!f.path.endsWith('.astro')) continue\n // Drop inline SVG graphics — their fills/gradients aren't themeable UI.\n const content = f.content.replace(/<svg[\\s\\S]*?<\\/svg>/gi, ' ')\n const hits = new Set<string>()\n // Tailwind arbitrary values: bg-[#fff], text-[#abc123], [#fff]\n for (const m of content.matchAll(/\\[#([0-9a-fA-F]{3,8})\\]/g)) hits.add('#' + m[1]!.toLowerCase())\n // String style attributes: style=\"color:#fff\"\n for (const m of content.matchAll(/style\\s*=\\s*[\"']([^\"']*)[\"']/g)) collectHex(m[1]!, hits)\n // JSX object style: style={{ color: '#fff' }}\n for (const m of content.matchAll(/style\\s*=\\s*\\{\\{([^}]*)\\}\\}/g)) collectHex(m[1]!, hits)\n // <style> blocks\n for (const m of content.matchAll(/<style[^>]*>([\\s\\S]*?)<\\/style>/g)) collectHex(m[1]!, hits)\n for (const hex of hits) {\n findings.push({\n file: f.path,\n status: 'hardcoded_hex',\n severity: 'error',\n reason: `Hardcoded hex color \"${hex}\" — use a design token (oklch var) instead.`,\n remedy: `Replace ${hex} with a token: var(--primary)/--accent or add --brand in src/styles/global.css.`,\n detail: hex,\n })\n }\n }\n return findings\n}\n\n// ── 3) form compliance ──────────────────────────────────────────────────────\n\nconst RAW_FORM_RE = /<form[\\s/>]/\n\n/** A raw lowercase <form> element (HubForm renders <HubForm …>, never a bare\n * <form>). Strips comments + strings first so a `<form>` mentioned in a comment\n * or string literal doesn't trip it. */\nexport function checkFormCompliance(\n files: ReadonlyArray<{ path: string; content: string }>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n for (const f of files) {\n if (!/\\.(astro|tsx|jsx)$/.test(f.path)) continue\n if (!RAW_FORM_RE.test(stripCommentsAndStrings(f.content))) continue\n findings.push({\n file: f.path,\n status: 'raw_form',\n severity: 'error',\n reason: 'Raw <form> element — forms must go through <HubForm> (@saastro/forms).',\n remedy: 'Replace the <form> with <HubForm> so submits hit submit.saastro.io with validation/captcha.',\n detail: '<form>',\n })\n }\n return findings\n}\n\n// ── 4) allowlist compliance ─────────────────────────────────────────────────\n\n/** Publishable paths the Hub writes back. Editable copy must live here, never\n * hardcoded in a component (which the Hub freezes at connect time). */\nconst ALLOWLIST_PREFIXES = ['src/pages/', 'src/content/', 'src/i18n/']\nconst ALLOWLIST_FILES = ['studio.config.json']\n\nfunction isAllowlisted(path: string): boolean {\n if (ALLOWLIST_FILES.some(f => path.endsWith(f))) return true\n if (path.endsWith('.md') || path.endsWith('.mdx')) return true\n return ALLOWLIST_PREFIXES.some(p => path.includes(p))\n}\n\n/** Hardcoded sentence-like text content (≥2 words) directly rendered as the\n * child of an element — i.e. NOT an `{interpolation}`. Best-effort. */\nconst LITERAL_TEXT_RE = />\\s*([A-Z][A-Za-z0-9'’,.!?-]*(?:\\s+[A-Za-z0-9'’,.!?-]+)+)\\s*</g\n\n/** Editable copy hardcoded inside a marked (`data-saastro`) section that lives\n * OUTSIDE the publish allowlist → the client can't edit it and it's lost on the\n * next Hub publish. Advisory (best-effort heuristic): real i18n-driven sections\n * render `{t.…}`/`{prop}`, not literal sentences. */\nexport function checkAllowlistCompliance(\n files: ReadonlyArray<{ path: string; content: string }>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n for (const f of files) {\n if (!f.path.endsWith('.astro')) continue\n if (isAllowlisted(f.path)) continue // pages/content/i18n are publishable\n if (!/data-saastro\\s*=/.test(f.content)) continue // only marked (editable) sections\n const literals = new Set<string>()\n for (const m of f.content.matchAll(LITERAL_TEXT_RE)) {\n const text = m[1]!.trim()\n if (text.length >= 6) literals.add(text)\n }\n for (const text of literals) {\n findings.push({\n file: f.path,\n status: 'allowlist_violation',\n severity: 'warn',\n reason: `Hardcoded copy \"${truncate(text)}\" in a marked section — not editable via the Hub (outside the i18n allowlist).`,\n remedy: 'Move the text into an i18n namespace and render it via {t.<key>.<field>}.',\n detail: text,\n })\n }\n }\n return findings\n}\n\nfunction truncate(s: string): string {\n return s.length > 48 ? s.slice(0, 45) + '…' : s\n}\n\n// ── 5) rich-spans integrity ─────────────────────────────────────────────────\n\ntype SpansLeafKind = 'spans' | 'spans-unmarked' | 'spans-malformed' | 'string' | 'other'\n\n/** Looks like an ATTEMPT at rich spans: array of objects where some element\n * carries a `text` key. Generic i18n arrays (strings, `{label, href}` items)\n * don't match, so they stay out of this check entirely. */\nfunction isSpansLike(v: unknown): v is unknown[] {\n return (\n Array.isArray(v) &&\n v.length > 0 &&\n v.some(\n item =>\n item !== null &&\n typeof item === 'object' &&\n !Array.isArray(item) &&\n 'text' in (item as Record<string, unknown>),\n )\n )\n}\n\n/** Human-readable reason a spans-like array fails `isSpansValue`. */\nfunction spansDefect(v: unknown[]): string {\n for (let i = 0; i < v.length; i++) {\n const item = v[i]\n if (item === null || typeof item !== 'object' || Array.isArray(item))\n return `element ${i} is not a span object`\n const span = item as Record<string, unknown>\n if (typeof span.text !== 'string') return `element ${i} has a non-string \"text\"`\n for (const k of Object.keys(span)) {\n if (k !== 'text' && k !== 'marks') return `element ${i} has unknown key \"${k}\"`\n }\n if (span.marks !== undefined) {\n if (!Array.isArray(span.marks)) return `element ${i} has a non-array \"marks\"`\n for (const m of span.marks) {\n if (!isValidMark(m))\n return `element ${i} uses unknown mark \"${String(m)}\" (allowed: ${SPAN_MARKS.join(', ')}, c:<token>, s:sm|lg|xl, f:display|body|code)`\n }\n }\n }\n return 'unknown defect'\n}\n\n/** Flatten one locale's tree to leaf paths classified for the spans check. */\nfunction classifyLeaves(obj: unknown, prefix: string, out: Map<string, SpansLeafKind>): void {\n if (obj === null || typeof obj !== 'object') {\n if (prefix) out.set(prefix, typeof obj === 'string' ? 'string' : 'other')\n return\n }\n if (Array.isArray(obj)) {\n if (isSpansValue(obj)) {\n out.set(prefix, 'spans')\n } else if (isSpansShape(obj)) {\n // Grammar-valid but no marks anywhere: under the marked-only grammar\n // (audit E2 #28) this is legitimate generic content (`[{text}, …]`\n // testimonial/quote arrays), not an attempt at rich spans. No finding —\n // and it must not count as 'spans' for the cross-locale mismatch either.\n out.set(prefix, 'spans-unmarked')\n } else if (isSpansLike(obj)) {\n out.set(prefix, 'spans-malformed')\n } else {\n // Generic array: recurse — spans can sit inside collection items.\n obj.forEach((item, i) => classifyLeaves(item, prefix ? `${prefix}.${i}` : String(i), out))\n }\n return\n }\n for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {\n classifyLeaves(v, prefix ? `${prefix}.${k}` : k, out)\n }\n}\n\n/**\n * Rich-spans integrity:\n * - `spans_malformed` (error): a spans-like array that violates the shape\n * (unknown mark, non-string text, stray keys). Published as-is it renders\n * `[object Object]` on themes without the spans-aware renderer.\n * - `spans_shape_mismatch` (warn): a key that is rich spans in one locale\n * but a plain string in another — the dual renderer degrades fine, but\n * the spans migration is meant to be locale-atomic (deepMerge leaks the\n * default locale's array into locales missing the key).\n */\nexport function checkSpansIntegrity(\n locales: ReadonlyArray<LocaleTranslations>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n const perLocale = locales.map(l => {\n const map = new Map<string, SpansLeafKind>()\n classifyLeaves(l.data, '', map)\n return { ...l, map }\n })\n\n for (const l of perLocale) {\n for (const [path, kind] of l.map) {\n if (kind !== 'spans-malformed') continue\n const raw = getAtDottedPath(l.data, path)\n findings.push({\n file: l.file,\n status: 'spans_malformed',\n severity: 'error',\n reason: `Key \"${path}\" (${l.locale}) looks like rich spans but is malformed: ${\n Array.isArray(raw) ? spansDefect(raw) : 'not an array'\n }.`,\n remedy: `Fix the value to [{ text, marks?: [${SPAN_MARKS.map(m => `'${m}'`).join(' | ')} | 'c:<token>' | 's:sm|lg|xl' | 'f:display|body|code'] }] or revert it to a plain string.`,\n detail: path,\n })\n }\n }\n\n const spansAnywhere = new Set<string>()\n for (const l of perLocale) {\n for (const [path, kind] of l.map) if (kind === 'spans') spansAnywhere.add(path)\n }\n for (const path of spansAnywhere) {\n for (const l of perLocale) {\n if (l.map.get(path) !== 'string') continue\n findings.push({\n file: l.file,\n status: 'spans_shape_mismatch',\n severity: 'warn',\n reason: `Key \"${path}\" is rich spans in another locale but a plain string in \"${l.locale}\" — spans migrations should cover all locales atomically.`,\n remedy: `Convert \"${path}\" in ${l.file} to the same rich-spans shape (or revert every locale to a string).`,\n detail: path,\n })\n }\n }\n return findings\n}\n\nfunction getAtDottedPath(obj: unknown, dotted: string): unknown {\n let cursor: unknown = obj\n for (const seg of dotted.split('.')) {\n if (cursor === null || typeof cursor !== 'object') return undefined\n cursor = Array.isArray(cursor)\n ? cursor[Number(seg)]\n : (cursor as Record<string, unknown>)[seg]\n }\n return cursor\n}\n\n// ── 6) theme font overridability ─────────────────────────────────────────────\n\n/** Pull the body of the first `@theme` / `@theme inline` block. */\nfunction extractThemeBlock(css: string): string | null {\n // Declarations only (no nested braces) → match to the first closing brace.\n const m = css.match(/@theme[^{]*\\{([\\s\\S]*?)\\}/)\n return m ? m[1]! : null\n}\n\n/**\n * Tailwind 4 gotcha: a font family written as a LITERAL inside `@theme\n * inline` (`--font-sans: 'Geist', sans-serif`) gets baked into the\n * `.font-sans` utility at build time, so a runtime `:root` override of\n * that var is a no-op — the Studio theme editor can't swap the font.\n * The fix is indirection: keep the family in a `:root` var\n * (`--font-body`/`--font-display`) and reference it from `@theme`\n * (`--font-sans: var(--font-body)`).\n *\n * Advisory (`warn`): colors already use this indirection in saastro-theme,\n * so color edits work regardless; only font swaps are affected.\n */\nexport function checkThemeFontOverridable(\n files: ReadonlyArray<{ path: string; content: string }>,\n): CheckFinding[] {\n const findings: CheckFinding[] = []\n for (const f of files) {\n if (!f.path.endsWith('.css')) continue\n const block = extractThemeBlock(f.content)\n if (block == null) continue\n // The two vars Tailwind utilities resolve for body/heading text.\n for (const m of block.matchAll(/--font-(sans|heading)\\s*:\\s*([^;]+)/g)) {\n const which = m[1]!\n const value = m[2]!.trim()\n if (/^var\\(\\s*--font-(body|display)\\b/.test(value)) continue // already indirected\n if (/^var\\(/.test(value)) continue // references some other var — assume intentional\n findings.push({\n file: f.path,\n status: 'theme_font_not_overridable',\n severity: 'warn',\n reason: `--font-${which} is a literal family inside @theme — Studio font swaps won't apply (Tailwind inlines it at build).`,\n remedy:\n 'Move the family to a :root var and reference it: ' +\n ':root{--font-body:…;--font-display:…} + @theme{--font-sans:var(--font-body);--font-heading:var(--font-display)}.',\n detail: `--font-${which}: ${truncate(value)}`,\n })\n break // one finding per file is enough signal\n }\n }\n return findings\n}\n\n// ── aggregator ───────────────────────────────────────────────────────────────\n\nexport interface DoctorChecksInput {\n files: ReadonlyArray<{ path: string; content: string }>\n locales: ReadonlyArray<LocaleTranslations>\n}\n\n/** Run all five checks and tally them (error count = needsAttention). */\nexport function runDoctorChecks(input: DoctorChecksInput): ChecksReport {\n const findings: CheckFinding[] = [\n ...checkI18nParity(input.locales),\n ...checkTokenCompliance(input.files),\n ...checkFormCompliance(input.files),\n ...checkAllowlistCompliance(input.files),\n ...checkSpansIntegrity(input.locales),\n ...checkThemeFontOverridable(input.files),\n ]\n const byStatus: Record<CheckStatus, number> = {\n i18n_parity: 0,\n hardcoded_hex: 0,\n raw_form: 0,\n allowlist_violation: 0,\n spans_malformed: 0,\n spans_shape_mismatch: 0,\n theme_font_not_overridable: 0,\n }\n let needsAttention = 0\n let warnings = 0\n for (const f of findings) {\n byStatus[f.status]++\n if (f.severity === 'error') needsAttention++\n else warnings++\n }\n return { findings, stats: { needsAttention, warnings, byStatus } }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
saastro
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-3MKWTNQA.js";
|
|
4
|
+
import "./chunk-G3MUVKWX.js";
|
|
5
|
+
import "./chunk-TU7VF6SY.js";
|
|
6
6
|
import {
|
|
7
7
|
editableImage
|
|
8
8
|
} from "./chunk-FLFH5RVH.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
saastro
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
5
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-3MKWTNQA.js";
|
|
4
|
+
import "../chunk-G3MUVKWX.js";
|
|
5
|
+
import "../chunk-TU7VF6SY.js";
|
|
6
6
|
import "../chunk-PKDMKMD6.js";
|
|
7
7
|
import "../chunk-PZ5AY32C.js";
|
|
8
8
|
export {
|
package/dist/middleware/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
injectThemeStyle,
|
|
2
3
|
injectVersionMetaTag,
|
|
3
4
|
injectVisibilityStyle
|
|
4
|
-
} from "../chunk-
|
|
5
|
+
} from "../chunk-TU7VF6SY.js";
|
|
5
6
|
import "../chunk-PZ5AY32C.js";
|
|
6
7
|
|
|
7
8
|
// src/middleware/index.ts
|
|
@@ -13,6 +14,7 @@ var onRequest = async (_context, next) => {
|
|
|
13
14
|
const html = await response.text();
|
|
14
15
|
let patched = injectVersionMetaTag(html);
|
|
15
16
|
patched = injectVisibilityStyle(patched);
|
|
17
|
+
patched = injectThemeStyle(patched);
|
|
16
18
|
if (patched === html) {
|
|
17
19
|
return makeHtmlResponse(html, response);
|
|
18
20
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middleware/index.ts"],"sourcesContent":["/**\n * Astro middleware that injects the Saastro Studio meta tags into every\n * HTML response. Companion to the Vite plugin's `transformIndexHtml`\n * hook, which only fires for static HTML served by Vite's dev server —\n * SSR routes (`output: 'server'` or `'hybrid'`) bypass it entirely.\n *\n * Registered by the integration via\n * `addMiddleware({ entrypoint: '@saastro/studio/middleware', order: 'pre' })`\n * so it runs before any user middleware.\n *\n * Behavior:\n * - Non-HTML responses (CSS, JS, JSON, redirects, images) pass through\n * untouched — we never buffer their bodies.\n * - HTML responses get their `<head>` patched with two meta tags. The\n * patch is idempotent: a response that already has\n * `saastro:studio-version` is returned as-is.\n * - Response status, statusText, and all headers (minus `content-length`\n * and `etag`, which are invalidated by the body rewrite) are\n * preserved.\n */\n\nimport { injectVersionMetaTag } from '../vite/meta-tag.js'\nimport { injectVisibilityStyle } from '../visibility-css.js'\n\ninterface MiddlewareContext {\n request: Request\n url?: URL\n}\n\ntype Next = () => Promise<Response>\n\nconst HTML_RE = /text\\/html/i\n\nexport const onRequest = async (\n _context: MiddlewareContext,\n next: Next,\n): Promise<Response> => {\n const response = await next()\n const contentType = response.headers.get('content-type') ?? ''\n if (!HTML_RE.test(contentType)) return response\n\n const html = await response.text()\n // Layered injection: meta tags first (for the Hub overlay), then the\n // `studio.config.json`-driven visibility CSS. Both helpers are\n // idempotent so a response that already carries either tag is left\n // untouched in that pass.\n let patched = injectVersionMetaTag(html)\n // `injectVisibilityStyle` defaults to `'.'` (resolved relative to\n // the current working directory) when no project root is passed —\n // the middleware can't easily determine Vite's resolved root, but\n // the CWD is the project root in every realistic deployment.\n patched = injectVisibilityStyle(patched)\n if (patched === html) {\n // Nothing changed, but `text()` already consumed the original\n // stream. Re-emit with a fresh body so downstream adapters get a\n // readable response.\n return makeHtmlResponse(html, response)\n }\n return makeHtmlResponse(patched, response)\n}\n\nfunction makeHtmlResponse(body: string, source: Response): Response {\n const headers = new Headers(source.headers)\n // The body length changed (or we re-emitted). The original\n // `content-length` / `etag` are invalid — drop them and let the\n // runtime/proxy recompute or ignore.\n headers.delete('content-length')\n headers.delete('etag')\n return new Response(body, {\n status: source.status,\n statusText: source.statusText,\n headers,\n })\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../../src/middleware/index.ts"],"sourcesContent":["/**\n * Astro middleware that injects the Saastro Studio meta tags into every\n * HTML response. Companion to the Vite plugin's `transformIndexHtml`\n * hook, which only fires for static HTML served by Vite's dev server —\n * SSR routes (`output: 'server'` or `'hybrid'`) bypass it entirely.\n *\n * Registered by the integration via\n * `addMiddleware({ entrypoint: '@saastro/studio/middleware', order: 'pre' })`\n * so it runs before any user middleware.\n *\n * Behavior:\n * - Non-HTML responses (CSS, JS, JSON, redirects, images) pass through\n * untouched — we never buffer their bodies.\n * - HTML responses get their `<head>` patched with two meta tags. The\n * patch is idempotent: a response that already has\n * `saastro:studio-version` is returned as-is.\n * - Response status, statusText, and all headers (minus `content-length`\n * and `etag`, which are invalidated by the body rewrite) are\n * preserved.\n */\n\nimport { injectVersionMetaTag } from '../vite/meta-tag.js'\nimport { injectVisibilityStyle } from '../visibility-css.js'\nimport { injectThemeStyle } from '../theme-css.js'\n\ninterface MiddlewareContext {\n request: Request\n url?: URL\n}\n\ntype Next = () => Promise<Response>\n\nconst HTML_RE = /text\\/html/i\n\nexport const onRequest = async (\n _context: MiddlewareContext,\n next: Next,\n): Promise<Response> => {\n const response = await next()\n const contentType = response.headers.get('content-type') ?? ''\n if (!HTML_RE.test(contentType)) return response\n\n const html = await response.text()\n // Layered injection: meta tags first (for the Hub overlay), then the\n // `studio.config.json`-driven visibility CSS. Both helpers are\n // idempotent so a response that already carries either tag is left\n // untouched in that pass.\n let patched = injectVersionMetaTag(html)\n // `injectVisibilityStyle` defaults to `'.'` (resolved relative to\n // the current working directory) when no project root is passed —\n // the middleware can't easily determine Vite's resolved root, but\n // the CWD is the project root in every realistic deployment.\n patched = injectVisibilityStyle(patched)\n // Theme override LAST so its `:root` vars cascade over the bundled CSS.\n patched = injectThemeStyle(patched)\n if (patched === html) {\n // Nothing changed, but `text()` already consumed the original\n // stream. Re-emit with a fresh body so downstream adapters get a\n // readable response.\n return makeHtmlResponse(html, response)\n }\n return makeHtmlResponse(patched, response)\n}\n\nfunction makeHtmlResponse(body: string, source: Response): Response {\n const headers = new Headers(source.headers)\n // The body length changed (or we re-emitted). The original\n // `content-length` / `etag` are invalid — drop them and let the\n // runtime/proxy recompute or ignore.\n headers.delete('content-length')\n headers.delete('etag')\n return new Response(body, {\n status: source.status,\n statusText: source.statusText,\n headers,\n })\n}\n"],"mappings":";;;;;;;;AAgCA,IAAM,UAAU;AAET,IAAM,YAAY,OACvB,UACA,SACsB;AACtB,QAAM,WAAW,MAAM,KAAK;AAC5B,QAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;AAC5D,MAAI,CAAC,QAAQ,KAAK,WAAW,EAAG,QAAO;AAEvC,QAAM,OAAO,MAAM,SAAS,KAAK;AAKjC,MAAI,UAAU,qBAAqB,IAAI;AAKvC,YAAU,sBAAsB,OAAO;AAEvC,YAAU,iBAAiB,OAAO;AAClC,MAAI,YAAY,MAAM;AAIpB,WAAO,iBAAiB,MAAM,QAAQ;AAAA,EACxC;AACA,SAAO,iBAAiB,SAAS,QAAQ;AAC3C;AAEA,SAAS,iBAAiB,MAAc,QAA4B;AAClE,QAAM,UAAU,IAAI,QAAQ,OAAO,OAAO;AAI1C,UAAQ,OAAO,gBAAgB;AAC/B,UAAQ,OAAO,MAAM;AACrB,SAAO,IAAI,SAAS,MAAM;AAAA,IACxB,QAAQ,OAAO;AAAA,IACf,YAAY,OAAO;AAAA,IACnB;AAAA,EACF,CAAC;AACH;","names":[]}
|
package/dist/richtext/index.d.ts
CHANGED
|
@@ -24,32 +24,67 @@ import { z } from 'zod';
|
|
|
24
24
|
* (atomic migration; enforced by the spans doctor check).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Simple (parameterless) marks. Owner decisions 2026-07-06: accent + bold
|
|
29
|
+
* first; italic + tokenized color/size/font added the same day (marks v2).
|
|
30
|
+
*/
|
|
31
|
+
declare const SPAN_MARKS: readonly ["accent", "bold", "italic"];
|
|
32
|
+
type SimpleSpanMark = (typeof SPAN_MARKS)[number];
|
|
33
|
+
/**
|
|
34
|
+
* Tokenized marks (marks v2) — the value part is always a SEMANTIC name,
|
|
35
|
+
* never a raw CSS value:
|
|
36
|
+
*
|
|
37
|
+
* c:<token> color — the site's own CSS custom property name (without
|
|
38
|
+
* the leading --), e.g. `c:accent-300`, `c:brand-jcenlo`.
|
|
39
|
+
* Renders as `color: var(--<token>)`, so it resolves against
|
|
40
|
+
* whatever the site's palette declares.
|
|
41
|
+
* s:sm|lg|xl font size — closed 3-step scale relative to the element
|
|
42
|
+
* (0.85em / 1.15em / 1.35em). No arbitrary sizes.
|
|
43
|
+
* f:display|body|code font family — the site's semantic font slots
|
|
44
|
+
* (`var(--font-display)` / `var(--font-body)` / `var(--code)`).
|
|
45
|
+
* Arbitrary families stay theme-editor territory.
|
|
46
|
+
*/
|
|
47
|
+
declare const COLOR_MARK_RE: RegExp;
|
|
48
|
+
declare const SIZE_MARK_RE: RegExp;
|
|
49
|
+
declare const FONT_MARK_RE: RegExp;
|
|
50
|
+
type SpanMark = string;
|
|
51
|
+
declare function isValidMark(mark: unknown): mark is SpanMark;
|
|
52
|
+
declare const spanMarkSchema: z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>;
|
|
30
53
|
declare const richSpanSchema: z.ZodObject<{
|
|
31
54
|
text: z.ZodString;
|
|
32
|
-
marks: z.ZodOptional<z.ZodArray<z.
|
|
33
|
-
accent: "accent";
|
|
34
|
-
bold: "bold";
|
|
35
|
-
}>>>;
|
|
55
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>>>;
|
|
36
56
|
}, z.core.$strip>;
|
|
37
57
|
type RichSpan = z.infer<typeof richSpanSchema>;
|
|
38
58
|
/** A spans value is a non-empty array of spans. */
|
|
39
59
|
declare const richSpansValueSchema: z.ZodArray<z.ZodObject<{
|
|
40
60
|
text: z.ZodString;
|
|
41
|
-
marks: z.ZodOptional<z.ZodArray<z.
|
|
42
|
-
accent: "accent";
|
|
43
|
-
bold: "bold";
|
|
44
|
-
}>>>;
|
|
61
|
+
marks: z.ZodOptional<z.ZodArray<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>>>;
|
|
45
62
|
}, z.core.$strip>>;
|
|
46
63
|
type RichSpansValue = z.infer<typeof richSpansValueSchema>;
|
|
47
64
|
/**
|
|
48
|
-
* Structural guard for the spans
|
|
49
|
-
* must be a plain object whose `text` is a string and whose
|
|
50
|
-
* present, only contains whitelisted marks.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
65
|
+
* Structural guard for the spans GRAMMAR only. Deliberately strict: every
|
|
66
|
+
* element must be a plain object whose `text` is a string and whose
|
|
67
|
+
* `marks`, if present, only contains whitelisted marks. It does NOT decide
|
|
68
|
+
* whether the value IS rich spans — see `isSpansValue`. Editors use this
|
|
69
|
+
* to keep round-tripping a value mid-edit (a run whose last mark was just
|
|
70
|
+
* toggled off is still grammar-valid), and message validators use it to
|
|
71
|
+
* accept an unmark action crossing the postMessage boundary.
|
|
72
|
+
*/
|
|
73
|
+
declare function isSpansShape(value: unknown): value is RichSpansValue;
|
|
74
|
+
/** True when at least one span carries a non-empty mark list. */
|
|
75
|
+
declare function spansHasMarks(value: RichSpansValue): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* "Spans iff marked" — the canonical rich-spans detector (audit E2 #28).
|
|
78
|
+
*
|
|
79
|
+
* A value is rich spans ONLY when it satisfies the grammar AND at least one
|
|
80
|
+
* span actually carries a mark. A `[{text}, {text}]` array with no marks
|
|
81
|
+
* anywhere is indistinguishable from a legitimate generic content array
|
|
82
|
+
* (testimonials/quotes/ticker items with a single `text` key), so it must
|
|
83
|
+
* keep flowing through the generic array code paths — the stega encoder
|
|
84
|
+
* registers its items per-path, the panel renders a FieldArray, etc.
|
|
85
|
+
* Writers enforce the complementary invariant: a spans value whose last
|
|
86
|
+
* mark is removed is normalised back to the plain string
|
|
87
|
+
* (`spansToPlainText`) before staging, so unmarked spans never persist.
|
|
53
88
|
*/
|
|
54
89
|
declare function isSpansValue(value: unknown): value is RichSpansValue;
|
|
55
90
|
/** Flatten a spans value to its visible plain text (marks dropped). */
|
|
@@ -59,5 +94,49 @@ declare function spansToPlainText(value: RichSpansValue): string;
|
|
|
59
94
|
* (summaries, stega visible text, graceful degradation).
|
|
60
95
|
*/
|
|
61
96
|
declare function leafToPlainText(value: string | RichSpansValue): string;
|
|
97
|
+
declare const SIZE_MARK_EM: Record<string, string>;
|
|
98
|
+
declare const FONT_MARK_VAR: Record<string, string>;
|
|
99
|
+
/**
|
|
100
|
+
* CSS declarations for the tokenized marks of a span (color/size/font).
|
|
101
|
+
* Returns '' when none apply. `accent`/`bold`/`italic` are NOT covered
|
|
102
|
+
* here — they map to class/element (`.ac`, <strong>, <em>) renderer-side.
|
|
103
|
+
*/
|
|
104
|
+
declare function marksToInlineStyle(marks: readonly SpanMark[] | undefined): string;
|
|
105
|
+
/** Serialized form for the lossless round-trip attribute (data-saastro-marks). */
|
|
106
|
+
declare function marksToAttr(marks: readonly SpanMark[] | undefined): string;
|
|
107
|
+
/** Parse the round-trip attribute back to a validated, sorted mark list. */
|
|
108
|
+
declare function marksFromAttr(attr: string | null | undefined): SpanMark[];
|
|
109
|
+
/**
|
|
110
|
+
* Exclusivity group of a mark. Tokenized marks are EXCLUSIVE within their
|
|
111
|
+
* group — one color, one size, one font per run — and `accent` shares the
|
|
112
|
+
* color group with `c:<token>` (both paint the run's text color).
|
|
113
|
+
*/
|
|
114
|
+
declare function markGroupPrefix(mark: SpanMark): 'color' | 'size' | 'font' | null;
|
|
115
|
+
/** True when both marks belong to the same (non-null) exclusivity group. */
|
|
116
|
+
declare function sameMarkGroup(a: SpanMark, b: SpanMark): boolean;
|
|
117
|
+
/** Normalise: sort marks, merge adjacent equal-mark spans, drop empties, never []. */
|
|
118
|
+
declare function normalizeSpans(spans: readonly RichSpan[]): RichSpansValue;
|
|
119
|
+
/**
|
|
120
|
+
* Toggle `mark` across the [start, end) character range of the flattened
|
|
121
|
+
* text — the familiar bold-button semantics: if every character in the
|
|
122
|
+
* range already carries the mark it is removed, otherwise it is added.
|
|
123
|
+
* Adding a grouped mark (color/size/font — see `markGroupPrefix`) evicts
|
|
124
|
+
* its group siblings from the range so a run never accumulates e.g.
|
|
125
|
+
* `accent` + `c:<token>` (audit E2 #38/#40 — this eviction is canonical;
|
|
126
|
+
* the overlay's `toggleSpansMark` mirrors it).
|
|
127
|
+
*/
|
|
128
|
+
declare function toggleMarkOnRange(spans: RichSpansValue, start: number, end: number, mark: SpanMark): RichSpansValue;
|
|
129
|
+
/**
|
|
130
|
+
* Deep-flatten every spans-SHAPED leaf of a translations tree to its
|
|
131
|
+
* concatenated plain text. This is the universal degradation a site's
|
|
132
|
+
* `getTranslations` applies so every `{t.x.y}` interpolation keeps
|
|
133
|
+
* rendering strings — never `[object Object]` (Astro) or a hooks crash
|
|
134
|
+
* (React islands). Rich rendering opts in per call site via <RichText>
|
|
135
|
+
* fed from the UN-flattened tree (`getRichTranslations`).
|
|
136
|
+
*
|
|
137
|
+
* Uses `isSpansShape` (structure only): unmarked spans-shaped arrays
|
|
138
|
+
* flatten too — content-wise they ARE plain text.
|
|
139
|
+
*/
|
|
140
|
+
declare function flattenSpansDeep<T>(value: T): T;
|
|
62
141
|
|
|
63
|
-
export { type RichSpan, type RichSpansValue, SPAN_MARKS, type SpanMark, isSpansValue, leafToPlainText, richSpanSchema, richSpansValueSchema, spansToPlainText };
|
|
142
|
+
export { COLOR_MARK_RE, FONT_MARK_RE, FONT_MARK_VAR, type RichSpan, type RichSpansValue, SIZE_MARK_EM, SIZE_MARK_RE, SPAN_MARKS, type SimpleSpanMark, type SpanMark, flattenSpansDeep, isSpansShape, isSpansValue, isValidMark, leafToPlainText, markGroupPrefix, marksFromAttr, marksToAttr, marksToInlineStyle, normalizeSpans, richSpanSchema, richSpansValueSchema, sameMarkGroup, spanMarkSchema, spansHasMarks, spansToPlainText, toggleMarkOnRange };
|
package/dist/richtext/index.js
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
import {
|
|
2
|
+
COLOR_MARK_RE,
|
|
3
|
+
FONT_MARK_RE,
|
|
4
|
+
FONT_MARK_VAR,
|
|
5
|
+
SIZE_MARK_EM,
|
|
6
|
+
SIZE_MARK_RE,
|
|
2
7
|
SPAN_MARKS,
|
|
8
|
+
flattenSpansDeep,
|
|
9
|
+
isSpansShape,
|
|
3
10
|
isSpansValue,
|
|
11
|
+
isValidMark,
|
|
4
12
|
leafToPlainText,
|
|
13
|
+
marksFromAttr,
|
|
14
|
+
marksToAttr,
|
|
15
|
+
marksToInlineStyle,
|
|
5
16
|
richSpanSchema,
|
|
6
17
|
richSpansValueSchema,
|
|
7
18
|
spansToPlainText
|
|
8
|
-
} from "../chunk-
|
|
19
|
+
} from "../chunk-T3ZZAM65.js";
|
|
9
20
|
import "../chunk-PZ5AY32C.js";
|
|
10
21
|
export {
|
|
22
|
+
COLOR_MARK_RE,
|
|
23
|
+
FONT_MARK_RE,
|
|
24
|
+
FONT_MARK_VAR,
|
|
25
|
+
SIZE_MARK_EM,
|
|
26
|
+
SIZE_MARK_RE,
|
|
11
27
|
SPAN_MARKS,
|
|
28
|
+
flattenSpansDeep,
|
|
29
|
+
isSpansShape,
|
|
12
30
|
isSpansValue,
|
|
31
|
+
isValidMark,
|
|
13
32
|
leafToPlainText,
|
|
33
|
+
marksFromAttr,
|
|
34
|
+
marksToAttr,
|
|
35
|
+
marksToInlineStyle,
|
|
14
36
|
richSpanSchema,
|
|
15
37
|
richSpansValueSchema,
|
|
16
38
|
spansToPlainText
|