@saastro/studio 0.7.1 → 0.8.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.
@@ -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 *\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'\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\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// ── aggregator ───────────────────────────────────────────────────────────────\n\nexport interface DoctorChecksInput {\n files: ReadonlyArray<{ path: string; content: string }>\n locales: ReadonlyArray<LocaleTranslations>\n}\n\n/** Run all four 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 ]\n const byStatus: Record<CheckStatus, number> = {\n i18n_parity: 0,\n hardcoded_hex: 0,\n raw_form: 0,\n allowlist_violation: 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, 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,6 +1,7 @@
1
1
  import {
2
2
  editableImage
3
3
  } from "../chunk-FLFH5RVH.js";
4
+ import "../chunk-PZ5AY32C.js";
4
5
 
5
6
  // src/image/cf-image.ts
6
7
  var DEFAULT_MEDIA_HOST = "media.saastro.io";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/image/cf-image.ts"],"sourcesContent":["import type {\n SrcKind,\n CfTransformOpts,\n BuildImageInput,\n BuildImageResult,\n CfImageUrlOpts,\n} from './types.js'\n\n/** Canonical Saastro media (R2 custom domain). Override per-tenant via `mediaHost`. */\nexport const DEFAULT_MEDIA_HOST = 'media.saastro.io'\nexport const DEFAULT_WIDTHS: readonly number[] = [480, 768, 1024, 1536, 2048]\nexport const DEFAULT_QUALITY = 80\n\n/** Vector/animated/already-optimal formats CF can't meaningfully resize/recode → serve raw. */\nconst NO_TRANSFORM_EXT = /\\.(svg|gif|ico|avif)$/i\n\n/**\n * Classify a content image string by where its bytes live:\n * - 'repo' → a `public/` asset referenced root-relative (`/images/...`)\n * - 'r2' → a Saastro media-library upload on the media host\n * - 'remote' → any other absolute URL (third-party) → never transformed\n */\nexport function classifySrc(src: string, mediaHost: string = DEFAULT_MEDIA_HOST): SrcKind {\n if (src.startsWith('/')) return 'repo'\n try {\n const u = new URL(src)\n return u.host === mediaHost ? 'r2' : 'remote'\n } catch {\n // relative path without a leading slash → treat as a repo asset\n return 'repo'\n }\n}\n\nexport function isTransformable(src: string): boolean {\n const path = src.split(/[?#]/)[0] ?? src\n return !NO_TRANSFORM_EXT.test(path)\n}\n\nfunction cfOptions(o: CfTransformOpts): string {\n const parts = [\n `width=${o.width}`,\n `format=${o.format ?? 'auto'}`,\n `quality=${o.quality ?? DEFAULT_QUALITY}`,\n ]\n if (o.fit) parts.push(`fit=${o.fit}`)\n return parts.join(',')\n}\n\n/** Repo asset → root-relative; CF intercepts `/cdn-cgi/image/*` at the zone edge. */\nfunction cfRepoUrl(src: string, o: CfTransformOpts): string {\n return `/cdn-cgi/image/${cfOptions(o)}${src}`\n}\n\n/** R2 media → transform on the media host where the object + R2 binding live. */\nfunction cfR2Url(src: string, o: CfTransformOpts): string {\n const u = new URL(src)\n return `https://${u.host}/cdn-cgi/image/${cfOptions(o)}${u.pathname}`\n}\n\n/**\n * Build the element-level `src` plus a transformed `srcset` for a content image.\n * When `optimize` is false (dev / no CF edge) or the source isn't transformable\n * (svg/gif/remote), returns the raw src with no srcset so the caller renders a\n * plain `<img>` — identical to today's behavior, nothing breaks.\n */\nexport function buildImage(input: BuildImageInput): BuildImageResult {\n const mediaHost = input.mediaHost ?? DEFAULT_MEDIA_HOST\n const kind = classifySrc(input.src, mediaHost)\n const canTransform = input.optimize && kind !== 'remote' && isTransformable(input.src)\n\n if (!canTransform) {\n return { rawSrc: input.src, srcset: null, optimizedSrc: null, kind }\n }\n\n const quality = input.quality ?? DEFAULT_QUALITY\n const widths = input.widths.length > 0 ? input.widths : [...DEFAULT_WIDTHS]\n const toUrl = (w: number): string => {\n const o: CfTransformOpts = { width: w, quality, fit: input.fit }\n return kind === 'r2' ? cfR2Url(input.src, o) : cfRepoUrl(input.src, o)\n }\n\n const srcset = widths.map((w) => `${toUrl(w)} ${w}w`).join(', ')\n const largest = widths[widths.length - 1]!\n const optimizedSrc = toUrl(largest)\n return { rawSrc: input.src, srcset, optimizedSrc, kind }\n}\n\n/**\n * Single optimized URL for ONE image — for CSS backgrounds\n * (`background-image: url(...)`), inline styles, `<meta>` og:image, etc. —\n * anywhere a `srcset` doesn't apply. Returns the raw src unchanged for\n * non-transformable sources (svg/gif/remote) or when `optimize` is false, so\n * it's always safe to drop in.\n *\n * Dev-gating note: this is a PURE helper — it can't read `import.meta.env.DEV`\n * (it runs from the compiled runtime, not the consumer's Vite context). In an\n * `.astro` file, gate it yourself so local dev serves the raw bytes:\n * const bg = cfImageUrl(src, { width: 1920, optimize: !import.meta.env.DEV })\n */\nexport function cfImageUrl(src: string, opts: CfImageUrlOpts): string {\n if (opts.optimize === false) return src\n const mediaHost = opts.mediaHost ?? DEFAULT_MEDIA_HOST\n const kind = classifySrc(src, mediaHost)\n if (kind === 'remote' || !isTransformable(src)) return src\n const o: CfTransformOpts = { width: opts.width, quality: opts.quality, fit: opts.fit }\n return kind === 'r2' ? cfR2Url(src, o) : cfRepoUrl(src, o)\n}\n"],"mappings":";;;;;AASO,IAAM,qBAAqB;AAC3B,IAAM,iBAAoC,CAAC,KAAK,KAAK,MAAM,MAAM,IAAI;AACrE,IAAM,kBAAkB;AAG/B,IAAM,mBAAmB;AAQlB,SAAS,YAAY,KAAa,YAAoB,oBAA6B;AACxF,MAAI,IAAI,WAAW,GAAG,EAAG,QAAO;AAChC,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,WAAO,EAAE,SAAS,YAAY,OAAO;AAAA,EACvC,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,gBAAgB,KAAsB;AACpD,QAAM,OAAO,IAAI,MAAM,MAAM,EAAE,CAAC,KAAK;AACrC,SAAO,CAAC,iBAAiB,KAAK,IAAI;AACpC;AAEA,SAAS,UAAU,GAA4B;AAC7C,QAAM,QAAQ;AAAA,IACZ,SAAS,EAAE,KAAK;AAAA,IAChB,UAAU,EAAE,UAAU,MAAM;AAAA,IAC5B,WAAW,EAAE,WAAW,eAAe;AAAA,EACzC;AACA,MAAI,EAAE,IAAK,OAAM,KAAK,OAAO,EAAE,GAAG,EAAE;AACpC,SAAO,MAAM,KAAK,GAAG;AACvB;AAGA,SAAS,UAAU,KAAa,GAA4B;AAC1D,SAAO,kBAAkB,UAAU,CAAC,CAAC,GAAG,GAAG;AAC7C;AAGA,SAAS,QAAQ,KAAa,GAA4B;AACxD,QAAM,IAAI,IAAI,IAAI,GAAG;AACrB,SAAO,WAAW,EAAE,IAAI,kBAAkB,UAAU,CAAC,CAAC,GAAG,EAAE,QAAQ;AACrE;AAQO,SAAS,WAAW,OAA0C;AACnE,QAAM,YAAY,MAAM,aAAa;AACrC,QAAM,OAAO,YAAY,MAAM,KAAK,SAAS;AAC7C,QAAM,eAAe,MAAM,YAAY,SAAS,YAAY,gBAAgB,MAAM,GAAG;AAErF,MAAI,CAAC,cAAc;AACjB,WAAO,EAAE,QAAQ,MAAM,KAAK,QAAQ,MAAM,cAAc,MAAM,KAAK;AAAA,EACrE;AAEA,QAAM,UAAU,MAAM,WAAW;AACjC,QAAM,SAAS,MAAM,OAAO,SAAS,IAAI,MAAM,SAAS,CAAC,GAAG,cAAc;AAC1E,QAAM,QAAQ,CAAC,MAAsB;AACnC,UAAM,IAAqB,EAAE,OAAO,GAAG,SAAS,KAAK,MAAM,IAAI;AAC/D,WAAO,SAAS,OAAO,QAAQ,MAAM,KAAK,CAAC,IAAI,UAAU,MAAM,KAAK,CAAC;AAAA,EACvE;AAEA,QAAM,SAAS,OAAO,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,IAAI;AAC/D,QAAM,UAAU,OAAO,OAAO,SAAS,CAAC;AACxC,QAAM,eAAe,MAAM,OAAO;AAClC,SAAO,EAAE,QAAQ,MAAM,KAAK,QAAQ,cAAc,KAAK;AACzD;AAcO,SAAS,WAAW,KAAa,MAA8B;AACpE,MAAI,KAAK,aAAa,MAAO,QAAO;AACpC,QAAM,YAAY,KAAK,aAAa;AACpC,QAAM,OAAO,YAAY,KAAK,SAAS;AACvC,MAAI,SAAS,YAAY,CAAC,gBAAgB,GAAG,EAAG,QAAO;AACvD,QAAM,IAAqB,EAAE,OAAO,KAAK,OAAO,SAAS,KAAK,SAAS,KAAK,KAAK,IAAI;AACrF,SAAO,SAAS,OAAO,QAAQ,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC;AAC3D;","names":[]}
1
+ {"version":3,"sources":["../../src/image/cf-image.ts"],"sourcesContent":["import type {\n SrcKind,\n CfTransformOpts,\n BuildImageInput,\n BuildImageResult,\n CfImageUrlOpts,\n} from './types.js'\n\n/** Canonical Saastro media (R2 custom domain). Override per-tenant via `mediaHost`. */\nexport const DEFAULT_MEDIA_HOST = 'media.saastro.io'\nexport const DEFAULT_WIDTHS: readonly number[] = [480, 768, 1024, 1536, 2048]\nexport const DEFAULT_QUALITY = 80\n\n/** Vector/animated/already-optimal formats CF can't meaningfully resize/recode → serve raw. */\nconst NO_TRANSFORM_EXT = /\\.(svg|gif|ico|avif)$/i\n\n/**\n * Classify a content image string by where its bytes live:\n * - 'repo' → a `public/` asset referenced root-relative (`/images/...`)\n * - 'r2' → a Saastro media-library upload on the media host\n * - 'remote' → any other absolute URL (third-party) → never transformed\n */\nexport function classifySrc(src: string, mediaHost: string = DEFAULT_MEDIA_HOST): SrcKind {\n if (src.startsWith('/')) return 'repo'\n try {\n const u = new URL(src)\n return u.host === mediaHost ? 'r2' : 'remote'\n } catch {\n // relative path without a leading slash → treat as a repo asset\n return 'repo'\n }\n}\n\nexport function isTransformable(src: string): boolean {\n const path = src.split(/[?#]/)[0] ?? src\n return !NO_TRANSFORM_EXT.test(path)\n}\n\nfunction cfOptions(o: CfTransformOpts): string {\n const parts = [\n `width=${o.width}`,\n `format=${o.format ?? 'auto'}`,\n `quality=${o.quality ?? DEFAULT_QUALITY}`,\n ]\n if (o.fit) parts.push(`fit=${o.fit}`)\n return parts.join(',')\n}\n\n/** Repo asset → root-relative; CF intercepts `/cdn-cgi/image/*` at the zone edge. */\nfunction cfRepoUrl(src: string, o: CfTransformOpts): string {\n return `/cdn-cgi/image/${cfOptions(o)}${src}`\n}\n\n/** R2 media → transform on the media host where the object + R2 binding live. */\nfunction cfR2Url(src: string, o: CfTransformOpts): string {\n const u = new URL(src)\n return `https://${u.host}/cdn-cgi/image/${cfOptions(o)}${u.pathname}`\n}\n\n/**\n * Build the element-level `src` plus a transformed `srcset` for a content image.\n * When `optimize` is false (dev / no CF edge) or the source isn't transformable\n * (svg/gif/remote), returns the raw src with no srcset so the caller renders a\n * plain `<img>` — identical to today's behavior, nothing breaks.\n */\nexport function buildImage(input: BuildImageInput): BuildImageResult {\n const mediaHost = input.mediaHost ?? DEFAULT_MEDIA_HOST\n const kind = classifySrc(input.src, mediaHost)\n const canTransform = input.optimize && kind !== 'remote' && isTransformable(input.src)\n\n if (!canTransform) {\n return { rawSrc: input.src, srcset: null, optimizedSrc: null, kind }\n }\n\n const quality = input.quality ?? DEFAULT_QUALITY\n const widths = input.widths.length > 0 ? input.widths : [...DEFAULT_WIDTHS]\n const toUrl = (w: number): string => {\n const o: CfTransformOpts = { width: w, quality, fit: input.fit }\n return kind === 'r2' ? cfR2Url(input.src, o) : cfRepoUrl(input.src, o)\n }\n\n const srcset = widths.map((w) => `${toUrl(w)} ${w}w`).join(', ')\n const largest = widths[widths.length - 1]!\n const optimizedSrc = toUrl(largest)\n return { rawSrc: input.src, srcset, optimizedSrc, kind }\n}\n\n/**\n * Single optimized URL for ONE image — for CSS backgrounds\n * (`background-image: url(...)`), inline styles, `<meta>` og:image, etc. —\n * anywhere a `srcset` doesn't apply. Returns the raw src unchanged for\n * non-transformable sources (svg/gif/remote) or when `optimize` is false, so\n * it's always safe to drop in.\n *\n * Dev-gating note: this is a PURE helper — it can't read `import.meta.env.DEV`\n * (it runs from the compiled runtime, not the consumer's Vite context). In an\n * `.astro` file, gate it yourself so local dev serves the raw bytes:\n * const bg = cfImageUrl(src, { width: 1920, optimize: !import.meta.env.DEV })\n */\nexport function cfImageUrl(src: string, opts: CfImageUrlOpts): string {\n if (opts.optimize === false) return src\n const mediaHost = opts.mediaHost ?? DEFAULT_MEDIA_HOST\n const kind = classifySrc(src, mediaHost)\n if (kind === 'remote' || !isTransformable(src)) return src\n const o: CfTransformOpts = { width: opts.width, quality: opts.quality, fit: opts.fit }\n return kind === 'r2' ? cfR2Url(src, o) : cfRepoUrl(src, o)\n}\n"],"mappings":";;;;;;AASO,IAAM,qBAAqB;AAC3B,IAAM,iBAAoC,CAAC,KAAK,KAAK,MAAM,MAAM,IAAI;AACrE,IAAM,kBAAkB;AAG/B,IAAM,mBAAmB;AAQlB,SAAS,YAAY,KAAa,YAAoB,oBAA6B;AACxF,MAAI,IAAI,WAAW,GAAG,EAAG,QAAO;AAChC,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,WAAO,EAAE,SAAS,YAAY,OAAO;AAAA,EACvC,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,gBAAgB,KAAsB;AACpD,QAAM,OAAO,IAAI,MAAM,MAAM,EAAE,CAAC,KAAK;AACrC,SAAO,CAAC,iBAAiB,KAAK,IAAI;AACpC;AAEA,SAAS,UAAU,GAA4B;AAC7C,QAAM,QAAQ;AAAA,IACZ,SAAS,EAAE,KAAK;AAAA,IAChB,UAAU,EAAE,UAAU,MAAM;AAAA,IAC5B,WAAW,EAAE,WAAW,eAAe;AAAA,EACzC;AACA,MAAI,EAAE,IAAK,OAAM,KAAK,OAAO,EAAE,GAAG,EAAE;AACpC,SAAO,MAAM,KAAK,GAAG;AACvB;AAGA,SAAS,UAAU,KAAa,GAA4B;AAC1D,SAAO,kBAAkB,UAAU,CAAC,CAAC,GAAG,GAAG;AAC7C;AAGA,SAAS,QAAQ,KAAa,GAA4B;AACxD,QAAM,IAAI,IAAI,IAAI,GAAG;AACrB,SAAO,WAAW,EAAE,IAAI,kBAAkB,UAAU,CAAC,CAAC,GAAG,EAAE,QAAQ;AACrE;AAQO,SAAS,WAAW,OAA0C;AACnE,QAAM,YAAY,MAAM,aAAa;AACrC,QAAM,OAAO,YAAY,MAAM,KAAK,SAAS;AAC7C,QAAM,eAAe,MAAM,YAAY,SAAS,YAAY,gBAAgB,MAAM,GAAG;AAErF,MAAI,CAAC,cAAc;AACjB,WAAO,EAAE,QAAQ,MAAM,KAAK,QAAQ,MAAM,cAAc,MAAM,KAAK;AAAA,EACrE;AAEA,QAAM,UAAU,MAAM,WAAW;AACjC,QAAM,SAAS,MAAM,OAAO,SAAS,IAAI,MAAM,SAAS,CAAC,GAAG,cAAc;AAC1E,QAAM,QAAQ,CAAC,MAAsB;AACnC,UAAM,IAAqB,EAAE,OAAO,GAAG,SAAS,KAAK,MAAM,IAAI;AAC/D,WAAO,SAAS,OAAO,QAAQ,MAAM,KAAK,CAAC,IAAI,UAAU,MAAM,KAAK,CAAC;AAAA,EACvE;AAEA,QAAM,SAAS,OAAO,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,IAAI;AAC/D,QAAM,UAAU,OAAO,OAAO,SAAS,CAAC;AACxC,QAAM,eAAe,MAAM,OAAO;AAClC,SAAO,EAAE,QAAQ,MAAM,KAAK,QAAQ,cAAc,KAAK;AACzD;AAcO,SAAS,WAAW,KAAa,MAA8B;AACpE,MAAI,KAAK,aAAa,MAAO,QAAO;AACpC,QAAM,YAAY,KAAK,aAAa;AACpC,QAAM,OAAO,YAAY,KAAK,SAAS;AACvC,MAAI,SAAS,YAAY,CAAC,gBAAgB,GAAG,EAAG,QAAO;AACvD,QAAM,IAAqB,EAAE,OAAO,KAAK,OAAO,SAAS,KAAK,SAAS,KAAK,KAAK,IAAI;AACrF,SAAO,SAAS,OAAO,QAAQ,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC;AAC3D;","names":[]}
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  saastro
3
- } from "./chunk-S2IKJ3QM.js";
4
- import "./chunk-7U2GMTVW.js";
5
- import "./chunk-5QZN4IGF.js";
3
+ } from "./chunk-JMSDBWL2.js";
4
+ import "./chunk-K7DTYPRE.js";
5
+ import "./chunk-4L5C226B.js";
6
6
  import {
7
7
  editableImage
8
8
  } from "./chunk-FLFH5RVH.js";
9
9
  import "./chunk-PKDMKMD6.js";
10
+ import "./chunk-PZ5AY32C.js";
10
11
 
11
12
  // src/helpers/editable-section.ts
12
13
  function editableSection(fieldPrefix) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/helpers/editable-section.ts","../src/helpers/editable-slot.ts","../src/helpers/editable-array-item.ts","../src/index.ts"],"sourcesContent":["import type { EditableAttrs } from './types.js'\n\n/**\n * Section marker — drop on the root element of a component that\n * receives `fieldPrefix`. Lets the Studio overlay hover/select/drag\n * the whole section.\n *\n * Returns empty object when fieldPrefix is falsy — safe to spread unconditionally.\n */\nexport function editableSection(fieldPrefix: string | undefined): EditableAttrs {\n if (!fieldPrefix) return {}\n return { 'data-saastro': `sec:${fieldPrefix}` }\n}\n","import type { EditableAttrs } from './types.js'\n\n/**\n * Slot marker — used for discrete named edits within a section\n * (e.g. a CTA label inside a hero). `fieldPrefix` scopes the slot\n * to its parent component; `slotName` is the local identifier.\n *\n * Returns empty object when either argument is falsy — safe to spread unconditionally.\n */\nexport function editableSlot(\n fieldPrefix: string | undefined,\n slotName: string,\n): EditableAttrs {\n if (!fieldPrefix || !slotName) return {}\n return { 'data-saastro': `slot:${fieldPrefix}.${slotName}` }\n}\n","import type { EditableAttrs } from './types.js'\n\n/**\n * Array-item marker — used on each rendered item inside a `.map(...)`\n * over repeatable content (pricing plans, testimonials, feature cards).\n * Studio uses this to select/duplicate/reorder individual items.\n *\n * Format: `arr:{arrPath}[{index}]` — brackets follow JS/TS convention\n * (lodash, JSON Pointer) and avoid ambiguity with numeric literal keys.\n *\n * Returns empty object when fieldPrefix is falsy or index is negative/non-finite.\n */\nexport function editableArrayItem(\n fieldPrefix: string | undefined,\n index: number,\n): EditableAttrs {\n if (!fieldPrefix || index < 0 || !Number.isFinite(index)) return {}\n return { 'data-saastro': `arr:${fieldPrefix}[${index}]` }\n}\n","/**\n * @saastro/studio — build-time instrumentation for Saastro Studio.\n *\n * This package provides:\n * - **Astro Integration** (default export): `saastro({ autoWrap: true })` —\n * registers the Vite plugin through Astro's own pipeline so ordering against\n * the `.astro` compiler is deterministic on cold builds.\n * - **Editable helpers**: `editableSection`, `editableImage`, `editableSlot`,\n * `editableArrayItem` + the `studioEditable` namespace.\n * - **Vite plugin** (separate entry, back-compat): `@saastro/studio/vite`.\n *\n * NOTE: This package is the *runtime instrumentation* installed in Astro\n * sites. The Studio editor UI lives at hub.saastro.io and is NOT part of this\n * package.\n *\n * @saastro/studio and @saastro/cms are peer packages — neither depends on the\n * other.\n */\n\nimport saastro from './integration/index.js'\n\nexport default saastro\nexport { saastro }\nexport type { SaastroStudioOptions } from './integration/index.js'\n\nexport type { EditableAttrs } from './helpers/types.js'\nexport { editableSection } from './helpers/editable-section.js'\nexport { editableImage } from './helpers/editable-image.js'\nexport { editableSlot } from './helpers/editable-slot.js'\nexport { editableArrayItem } from './helpers/editable-array-item.js'\n\nimport { editableSection } from './helpers/editable-section.js'\nimport { editableImage } from './helpers/editable-image.js'\nimport { editableSlot } from './helpers/editable-slot.js'\nimport { editableArrayItem } from './helpers/editable-array-item.js'\n\n/**\n * Convenience namespace — all four helpers grouped under one object.\n */\nexport const studioEditable = {\n section: editableSection,\n image: editableImage,\n slot: editableSlot,\n arrayItem: editableArrayItem,\n} as const\n"],"mappings":";;;;;;;;;;;AASO,SAAS,gBAAgB,aAAgD;AAC9E,MAAI,CAAC,YAAa,QAAO,CAAC;AAC1B,SAAO,EAAE,gBAAgB,OAAO,WAAW,GAAG;AAChD;;;ACHO,SAAS,aACd,aACA,UACe;AACf,MAAI,CAAC,eAAe,CAAC,SAAU,QAAO,CAAC;AACvC,SAAO,EAAE,gBAAgB,QAAQ,WAAW,IAAI,QAAQ,GAAG;AAC7D;;;ACHO,SAAS,kBACd,aACA,OACe;AACf,MAAI,CAAC,eAAe,QAAQ,KAAK,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO,CAAC;AAClE,SAAO,EAAE,gBAAgB,OAAO,WAAW,IAAI,KAAK,IAAI;AAC1D;;;ACGA,IAAO,cAAQ;AAkBR,IAAM,iBAAiB;AAAA,EAC5B,SAAS;AAAA,EACT,OAAO;AAAA,EACP,MAAM;AAAA,EACN,WAAW;AACb;","names":[]}
1
+ {"version":3,"sources":["../src/helpers/editable-section.ts","../src/helpers/editable-slot.ts","../src/helpers/editable-array-item.ts","../src/index.ts"],"sourcesContent":["import type { EditableAttrs } from './types.js'\n\n/**\n * Section marker — drop on the root element of a component that\n * receives `fieldPrefix`. Lets the Studio overlay hover/select/drag\n * the whole section.\n *\n * Returns empty object when fieldPrefix is falsy — safe to spread unconditionally.\n */\nexport function editableSection(fieldPrefix: string | undefined): EditableAttrs {\n if (!fieldPrefix) return {}\n return { 'data-saastro': `sec:${fieldPrefix}` }\n}\n","import type { EditableAttrs } from './types.js'\n\n/**\n * Slot marker — used for discrete named edits within a section\n * (e.g. a CTA label inside a hero). `fieldPrefix` scopes the slot\n * to its parent component; `slotName` is the local identifier.\n *\n * Returns empty object when either argument is falsy — safe to spread unconditionally.\n */\nexport function editableSlot(\n fieldPrefix: string | undefined,\n slotName: string,\n): EditableAttrs {\n if (!fieldPrefix || !slotName) return {}\n return { 'data-saastro': `slot:${fieldPrefix}.${slotName}` }\n}\n","import type { EditableAttrs } from './types.js'\n\n/**\n * Array-item marker — used on each rendered item inside a `.map(...)`\n * over repeatable content (pricing plans, testimonials, feature cards).\n * Studio uses this to select/duplicate/reorder individual items.\n *\n * Format: `arr:{arrPath}[{index}]` — brackets follow JS/TS convention\n * (lodash, JSON Pointer) and avoid ambiguity with numeric literal keys.\n *\n * Returns empty object when fieldPrefix is falsy or index is negative/non-finite.\n */\nexport function editableArrayItem(\n fieldPrefix: string | undefined,\n index: number,\n): EditableAttrs {\n if (!fieldPrefix || index < 0 || !Number.isFinite(index)) return {}\n return { 'data-saastro': `arr:${fieldPrefix}[${index}]` }\n}\n","/**\n * @saastro/studio — build-time instrumentation for Saastro Studio.\n *\n * This package provides:\n * - **Astro Integration** (default export): `saastro({ autoWrap: true })` —\n * registers the Vite plugin through Astro's own pipeline so ordering against\n * the `.astro` compiler is deterministic on cold builds.\n * - **Editable helpers**: `editableSection`, `editableImage`, `editableSlot`,\n * `editableArrayItem` + the `studioEditable` namespace.\n * - **Vite plugin** (separate entry, back-compat): `@saastro/studio/vite`.\n *\n * NOTE: This package is the *runtime instrumentation* installed in Astro\n * sites. The Studio editor UI lives at hub.saastro.io and is NOT part of this\n * package.\n *\n * @saastro/studio and @saastro/cms are peer packages — neither depends on the\n * other.\n */\n\nimport saastro from './integration/index.js'\n\nexport default saastro\nexport { saastro }\nexport type { SaastroStudioOptions } from './integration/index.js'\n\nexport type { EditableAttrs } from './helpers/types.js'\nexport { editableSection } from './helpers/editable-section.js'\nexport { editableImage } from './helpers/editable-image.js'\nexport { editableSlot } from './helpers/editable-slot.js'\nexport { editableArrayItem } from './helpers/editable-array-item.js'\n\nimport { editableSection } from './helpers/editable-section.js'\nimport { editableImage } from './helpers/editable-image.js'\nimport { editableSlot } from './helpers/editable-slot.js'\nimport { editableArrayItem } from './helpers/editable-array-item.js'\n\n/**\n * Convenience namespace — all four helpers grouped under one object.\n */\nexport const studioEditable = {\n section: editableSection,\n image: editableImage,\n slot: editableSlot,\n arrayItem: editableArrayItem,\n} as const\n"],"mappings":";;;;;;;;;;;;AASO,SAAS,gBAAgB,aAAgD;AAC9E,MAAI,CAAC,YAAa,QAAO,CAAC;AAC1B,SAAO,EAAE,gBAAgB,OAAO,WAAW,GAAG;AAChD;;;ACHO,SAAS,aACd,aACA,UACe;AACf,MAAI,CAAC,eAAe,CAAC,SAAU,QAAO,CAAC;AACvC,SAAO,EAAE,gBAAgB,QAAQ,WAAW,IAAI,QAAQ,GAAG;AAC7D;;;ACHO,SAAS,kBACd,aACA,OACe;AACf,MAAI,CAAC,eAAe,QAAQ,KAAK,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO,CAAC;AAClE,SAAO,EAAE,gBAAgB,OAAO,WAAW,IAAI,KAAK,IAAI;AAC1D;;;ACGA,IAAO,cAAQ;AAkBR,IAAM,iBAAiB;AAAA,EAC5B,SAAS;AAAA,EACT,OAAO;AAAA,EACP,MAAM;AAAA,EACN,WAAW;AACb;","names":[]}
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  saastro
3
- } from "../chunk-S2IKJ3QM.js";
4
- import "../chunk-7U2GMTVW.js";
5
- import "../chunk-5QZN4IGF.js";
3
+ } from "../chunk-JMSDBWL2.js";
4
+ import "../chunk-K7DTYPRE.js";
5
+ import "../chunk-4L5C226B.js";
6
6
  import "../chunk-PKDMKMD6.js";
7
+ import "../chunk-PZ5AY32C.js";
7
8
  export {
8
9
  saastro as default
9
10
  };
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  injectVersionMetaTag,
3
3
  injectVisibilityStyle
4
- } from "../chunk-5QZN4IGF.js";
4
+ } from "../chunk-4L5C226B.js";
5
+ import "../chunk-PZ5AY32C.js";
5
6
 
6
7
  // src/middleware/index.ts
7
8
  var HTML_RE = /text\/html/i;
@@ -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":";;;;;;AA+BA,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;AACvC,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":[]}
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":";;;;;;;AA+BA,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;AACvC,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":[]}
@@ -0,0 +1,63 @@
1
+ // Inlined from @saastro/studio-core (private) at build time — see scripts/inline-doctor-dts.mjs
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Rich spans — the structured inline-emphasis value for i18n leaves.
6
+ *
7
+ * An i18n leaf can be either a plain string or an array of spans:
8
+ *
9
+ * [{ text: 'Crece tu ' }, { text: 'negocio', marks: ['accent'] }]
10
+ *
11
+ * Marks come from a CLOSED whitelist (grow it only on real content demand —
12
+ * every new mark costs an editor widget, a doctor case and a renderer case).
13
+ * Marks map to presentation in the component renderer (`RichText.astro` in
14
+ * @saastro/studio: accent → the theme's semantic `ac` class, bold →
15
+ * <strong>); markup NEVER travels inside the content itself.
16
+ *
17
+ * Sanity/Vercel-style invariants the rest of the pipeline relies on:
18
+ * - The stega encoder registers ONLY the concatenated span text as a
19
+ * matchable value — never the `marks` literals (`'accent'`/`'bold'` are
20
+ * short enough to substring-match arbitrary page text).
21
+ * - Live-preview patches build DOM programmatically from this whitelist —
22
+ * never innerHTML.
23
+ * - A key holding spans in one locale must hold spans in every locale
24
+ * (atomic migration; enforced by the spans doctor check).
25
+ */
26
+
27
+ /** Closed whitelist. Owner decision 2026-07-06: accent + bold only. */
28
+ declare const SPAN_MARKS: readonly ["accent", "bold"];
29
+ type SpanMark = (typeof SPAN_MARKS)[number];
30
+ declare const richSpanSchema: z.ZodObject<{
31
+ text: z.ZodString;
32
+ marks: z.ZodOptional<z.ZodArray<z.ZodEnum<{
33
+ accent: "accent";
34
+ bold: "bold";
35
+ }>>>;
36
+ }, z.core.$strip>;
37
+ type RichSpan = z.infer<typeof richSpanSchema>;
38
+ /** A spans value is a non-empty array of spans. */
39
+ declare const richSpansValueSchema: z.ZodArray<z.ZodObject<{
40
+ text: z.ZodString;
41
+ marks: z.ZodOptional<z.ZodArray<z.ZodEnum<{
42
+ accent: "accent";
43
+ bold: "bold";
44
+ }>>>;
45
+ }, z.core.$strip>>;
46
+ type RichSpansValue = z.infer<typeof richSpansValueSchema>;
47
+ /**
48
+ * Structural guard for the spans shape. Deliberately strict: every element
49
+ * must be a plain object whose `text` is a string and whose `marks`, if
50
+ * present, only contains whitelisted marks. Anything else (generic i18n
51
+ * arrays of strings/objects, mixed arrays) is NOT spans and must keep
52
+ * flowing through the generic array code paths.
53
+ */
54
+ declare function isSpansValue(value: unknown): value is RichSpansValue;
55
+ /** Flatten a spans value to its visible plain text (marks dropped). */
56
+ declare function spansToPlainText(value: RichSpansValue): string;
57
+ /**
58
+ * Plain-text view of a dual leaf. Use anywhere a string is required
59
+ * (summaries, stega visible text, graceful degradation).
60
+ */
61
+ declare function leafToPlainText(value: string | RichSpansValue): string;
62
+
63
+ export { type RichSpan, type RichSpansValue, SPAN_MARKS, type SpanMark, isSpansValue, leafToPlainText, richSpanSchema, richSpansValueSchema, spansToPlainText };
@@ -0,0 +1,18 @@
1
+ import {
2
+ SPAN_MARKS,
3
+ isSpansValue,
4
+ leafToPlainText,
5
+ richSpanSchema,
6
+ richSpansValueSchema,
7
+ spansToPlainText
8
+ } from "../chunk-FGYTDBZU.js";
9
+ import "../chunk-PZ5AY32C.js";
10
+ export {
11
+ SPAN_MARKS,
12
+ isSpansValue,
13
+ leafToPlainText,
14
+ richSpanSchema,
15
+ richSpansValueSchema,
16
+ spansToPlainText
17
+ };
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  studioAutoWrap
3
- } from "../chunk-7U2GMTVW.js";
4
- import "../chunk-5QZN4IGF.js";
3
+ } from "../chunk-K7DTYPRE.js";
4
+ import "../chunk-4L5C226B.js";
5
5
  import "../chunk-PKDMKMD6.js";
6
+ import "../chunk-PZ5AY32C.js";
6
7
  export {
7
8
  studioAutoWrap as default
8
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastro/studio",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Build-time instrumentation for Saastro Studio — Vite plugin + editable helpers for Astro sites managed by Saastro Hub",
5
5
  "license": "MIT",
6
6
  "author": "SAASTRO",
@@ -46,12 +46,27 @@
46
46
  "types": "./dist/doctor.d.ts",
47
47
  "import": "./dist/doctor.js"
48
48
  },
49
- "./Img.astro": "./src/Img.astro"
49
+ "./richtext": {
50
+ "types": "./dist/richtext/index.d.ts",
51
+ "import": "./dist/richtext/index.js"
52
+ },
53
+ "./Img.astro": "./src/Img.astro",
54
+ "./RichText.astro": "./src/RichText.astro"
50
55
  },
51
56
  "files": [
52
57
  "dist",
53
- "src/Img.astro"
58
+ "src/Img.astro",
59
+ "src/RichText.astro"
54
60
  ],
61
+ "scripts": {
62
+ "build": "tsup && node scripts/inline-doctor-dts.mjs",
63
+ "dev": "tsup --watch",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest",
67
+ "clean": "rm -rf dist",
68
+ "prepublishOnly": "pnpm run build"
69
+ },
55
70
  "peerDependencies": {
56
71
  "astro": "^5.0.0 || ^6.0.0 || ^7.0.0",
57
72
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -65,18 +80,10 @@
65
80
  "ts-morph": "^25.0.1"
66
81
  },
67
82
  "devDependencies": {
83
+ "@saastro/studio-core": "workspace:*",
68
84
  "tsup": "^8.5.1",
69
85
  "typescript": "^5.8.3",
70
86
  "vite": "^7.0.0",
71
- "vitest": "^3.2.4",
72
- "@saastro/studio-core": "0.0.1"
73
- },
74
- "scripts": {
75
- "build": "tsup && node scripts/inline-doctor-dts.mjs",
76
- "dev": "tsup --watch",
77
- "typecheck": "tsc --noEmit",
78
- "test": "vitest run",
79
- "test:watch": "vitest",
80
- "clean": "rm -rf dist"
87
+ "vitest": "^3.2.4"
81
88
  }
82
- }
89
+ }
@@ -0,0 +1,70 @@
1
+ ---
2
+ /**
3
+ * <RichText> — renders a dual i18n leaf (plain string | rich spans).
4
+ *
5
+ * Usage in a section component:
6
+ *
7
+ * <h2 data-saastro-field="title"><RichText value={t.hero.title} /></h2>
8
+ *
9
+ * - Plain string → emitted as-is (text interpolation, auto-escaped).
10
+ * - Rich spans (`[{text, marks?}]`, whitelist accent|bold) → each span
11
+ * renders through Astro's template interpolation (never set:html):
12
+ * accent → <span class="ac" data-saastro-mark="accent"> (the theme's
13
+ * semantic accent class; override via `accentClass`)
14
+ * bold → <strong>
15
+ * - Anything else (a malformed value) degrades to its concatenated plain
16
+ * text when possible, never `[object Object]`.
17
+ *
18
+ * Put the `data-saastro-field` marker on the WRAPPING element (as above):
19
+ * the Studio overlay re-renders the wrapper's children on live edits and
20
+ * recognises its own inline marks.
21
+ *
22
+ * Ships with @saastro/studio (consumed via the `./RichText.astro` export).
23
+ * Helpers come from the bundled `@saastro/studio/richtext` subpath — same
24
+ * distribution pattern as Img.astro + `@saastro/studio/image`.
25
+ */
26
+ import { isSpansValue, leafToPlainText } from '@saastro/studio/richtext'
27
+
28
+ interface Props {
29
+ /** The i18n leaf: plain string or rich spans array. */
30
+ value: unknown
31
+ /** Class applied to accent spans; defaults to the theme's semantic `ac`. */
32
+ accentClass?: string
33
+ }
34
+
35
+ const { value, accentClass = 'ac' } = Astro.props
36
+
37
+ const spans = isSpansValue(value)
38
+ ? value.map(s => ({
39
+ text: s.text,
40
+ bold: (s.marks ?? []).includes('bold'),
41
+ accent: (s.marks ?? []).includes('accent'),
42
+ }))
43
+ : null
44
+
45
+ const plain = spans
46
+ ? null
47
+ : typeof value === 'string'
48
+ ? value
49
+ : Array.isArray(value)
50
+ ? leafToPlainText(value.filter(s => typeof (s as { text?: unknown })?.text === 'string') as never)
51
+ : value == null
52
+ ? ''
53
+ : String(value)
54
+ ---
55
+
56
+ {
57
+ spans
58
+ ? spans.map(s =>
59
+ s.accent ? (
60
+ <span class={accentClass} data-saastro-mark="accent">
61
+ {s.bold ? <strong>{s.text}</strong> : s.text}
62
+ </span>
63
+ ) : s.bold ? (
64
+ <strong>{s.text}</strong>
65
+ ) : (
66
+ s.text
67
+ ),
68
+ )
69
+ : plain
70
+ }