@saastro/studio 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-C2QUBB5U.js → chunk-DKDGF6IQ.js} +84 -210
- package/dist/chunk-DKDGF6IQ.js.map +1 -0
- package/dist/{chunk-VH3YYJEZ.js → chunk-MN3J6PBW.js} +2 -2
- package/dist/chunk-PKDMKMD6.js +212 -0
- package/dist/chunk-PKDMKMD6.js.map +1 -0
- package/dist/{chunk-F4AD4HVW.js → chunk-ZHCSQE2N.js} +2 -2
- package/dist/doctor.d.ts +216 -0
- package/dist/doctor.js +495 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/integration/index.js +4 -3
- package/dist/middleware/index.js +1 -1
- package/dist/vite/index.js +3 -2
- package/package.json +16 -13
- package/dist/chunk-C2QUBB5U.js.map +0 -1
- /package/dist/{chunk-VH3YYJEZ.js.map → chunk-MN3J6PBW.js.map} +0 -0
- /package/dist/{chunk-F4AD4HVW.js.map → chunk-ZHCSQE2N.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../studio-core/src/auto-wrap-core.ts"],"sourcesContent":["/**\n * Studio auto-wrap CORE — pure, I/O-free string instrumentation shared by TWO\n * consumers (same dual-consumer pattern as `component-schema`):\n *\n * 1. The `@saastro/studio` Vite plugin (`vite/auto-wrap.ts` re-exports this)\n * runs the transforms at BUILD time to stamp `data-saastro` markers.\n * 2. The Hub's Studio \"doctor\" (server route) runs the READ-side predicates\n * (`resolvePageKey`, `hasPageContentAnchor`, `hasFrontmatterFieldPrefix`,\n * `hasPageMarker`, `routeBaseName`) on raw `.astro` fetched from the repo to\n * decide whether a page WILL be auto-instrumented — so its verdict agrees\n * with what the plugin actually stamps, by construction (no drift).\n *\n * Two transforms, both regex/scanner-based (the plugin runs them in Vite's\n * `load` hook with `enforce: 'pre'`, before Astro compiles the template):\n *\n * 1. `transformAstroSource` — section components that declare a `fieldPrefix`\n * prop get `data-saastro=\"sec:${fieldPrefix}\"` injected on their root\n * element (developer-owned root).\n * 2. `transformAstroPage` (opt-in `autoWrapPages`) — whole `src/pages/**` route\n * files that have NO marker and NO `fieldPrefix` get a route-derived\n * `data-saastro=\"sec:<key>\"` stamped on their first content element, where\n * `<key>` is the page's own i18n namespace (see `resolvePageKey`). Lets a\n * brand-new page become Studio-editable with zero code in the customer repo.\n *\n * ## Limitations\n * - Component roots (PascalCase tags) are skipped — the marker must land on a\n * rendered HTML element so the overlay can hit-test it.\n * - The page transform only fires when the page references an i18n namespace\n * matching its route (`t.<key>`); anything more ambiguous is left for a\n * hand-placed marker (the Hub's Studio doctor reports it).\n */\n\n// Matches the frontmatter block at the very top of the file.\nconst FRONTMATTER_RE = /^\\s*---\\s*\\n([\\s\\S]*?)\\n---\\s*\\n/\n\n// Matches both HTML attribute form (`data-saastro=`) and the JSX spread\n// form we inject (`\"data-saastro\":`). Plain substring check is enough —\n// `data-saastro` is unique to this instrumentation.\nconst SAASTRO_ATTR_RE = /data-saastro/\n\n/** `fieldPrefix` as an identifier (tested on comment/string-stripped code). */\nconst FIELD_PREFIX_REFERENCE_RE = /\\bfieldPrefix\\b/\n\n/**\n * Strip JS comments + string literals so a `fieldPrefix` mentioned in a\n * comment / JSDoc / string literal does NOT count as a declaration. Best-effort\n * (we test for identifier presence, not evaluate) — over-stripping is fine.\n * Known blind spot: a usage that lives ONLY inside a template-literal\n * interpolation (`` `${fieldPrefix}` ``) is stripped with the literal; real\n * section components always also destructure/declare it, so this never gates\n * one out in practice.\n */\nfunction stripCommentsAndStrings(s: string): string {\n return s\n .replace(/\\/\\*[\\s\\S]*?\\*\\//g, ' ') // block comments\n .replace(/`(?:[^`\\\\]|\\\\.)*`/g, '``') // template literals (before line-comment strip)\n .replace(/'(?:[^'\\\\]|\\\\.)*'/g, \"''\") // single-quoted\n .replace(/\"(?:[^\"\\\\]|\\\\.)*\"/g, '\"\"') // double-quoted\n .replace(/\\/\\/[^\\n]*/g, ' ') // line comments\n}\n\n/**\n * FRONTMATTER-ONLY `fieldPrefix` gate, shared by `transformAstroSource` and\n * `hasFrontmatterFieldPrefix` so the plugin's entry-gate and the doctor's\n * routing verdict are literally the same predicate. `fieldPrefix` must appear\n * as CODE between the `---` fences: a stray mention in a comment or string no\n * longer misroutes a page onto the section path (which would both suppress the\n * page transform AND inject a spread referencing an undeclared variable —\n * a render-time ReferenceError, not a no-op). A `fieldPrefix=` on a CHILD\n * component in the template does not count either (frontmatter-only).\n */\nfunction frontmatterDeclaresFieldPrefix(fmBody: string): boolean {\n return FIELD_PREFIX_REFERENCE_RE.test(stripCommentsAndStrings(fmBody))\n}\n\n/** Does the file declare `fieldPrefix` in its frontmatter? (plugin's section-vs-page gate) */\nexport function hasFrontmatterFieldPrefix(source: string): boolean {\n const fm = FRONTMATTER_RE.exec(source)\n return fm ? frontmatterDeclaresFieldPrefix(fm[1]!) : false\n}\n\n/** Matches an opening tag NAME at a given offset: group 1 = tag name. */\nconst OPEN_TAG_NAME_RE = /^<([a-zA-Z][a-zA-Z0-9]*)/\n\n/**\n * Attribute spread injected on a section root. Uses conditional form so no\n * `data-saastro` attribute appears in production HTML when the prop is falsy\n * (avoids `data-saastro=\"sec:\"` partial matches).\n *\n * H9 — also emit `draggable=\"true\"` so HTML5 drag-and-drop fires `dragstart`\n * on the section root (overlay-script.ts wires the listeners; pre-fix wrappers\n * were never draggable).\n */\nconst INJECTION =\n ' {...(fieldPrefix ? { \"data-saastro\": `sec:${fieldPrefix}`, draggable: \"true\" } : {})}'\n\n// ── Quote/brace-aware tag utilities (shared by both transforms) ─────────────\n//\n// A naive `/<...([^<>]*?)...>/` regex treats the first literal `>` as the tag\n// close — which corrupts the source when an attribute value or an Astro\n// expression attribute contains `>` (e.g. `class={count > 3 ? 'a' : 'b'}`).\n// `findTagEnd` instead walks the tag respecting quotes (`\"`, `'`, backtick) and\n// `{...}` expression depth, so the marker is always spliced before the REAL\n// tag end.\n\n/**\n * Index of the `>` that closes the tag starting at `start` (which points at\n * `<`), respecting quoted attribute values and `{...}` expression attributes.\n * Returns -1 if the tag is unterminated.\n */\nfunction findTagEnd(s: string, start: number): number {\n let i = start + 1\n let quote = ''\n let brace = 0\n while (i < s.length) {\n const c = s[i]!\n if (quote) {\n if (c === quote) quote = ''\n } else if (c === '\"' || c === \"'\" || c === '`') {\n quote = c\n } else if (c === '{') {\n brace++\n } else if (c === '}') {\n if (brace > 0) brace--\n } else if (c === '>' && brace === 0) {\n return i\n }\n i++\n }\n return -1\n}\n\n/**\n * Splice `injection` (a string beginning with a single leading space, e.g.\n * ` data-saastro=\"sec:x\"` or the section spread) into the tag spanning\n * [tagStart, tagEnd], preserving existing attributes and any self-closing\n * slash. Pure string surgery — never reorders or drops attributes.\n */\nfunction injectIntoTag(\n template: string,\n tagStart: number,\n tagName: string,\n tagEnd: number,\n injection: string,\n): string {\n const between = template.slice(tagStart + 1 + tagName.length, tagEnd)\n const selfCloseMatch = /\\s*\\/\\s*$/.exec(between)\n let attrs = between\n let selfClose = false\n if (selfCloseMatch) {\n selfClose = true\n attrs = between.slice(0, selfCloseMatch.index)\n }\n attrs = attrs.replace(/\\s+$/, '')\n const rebuilt = `<${tagName}${attrs}${injection}${selfClose ? ' />' : '>'}`\n return template.slice(0, tagStart) + rebuilt + template.slice(tagEnd + 1)\n}\n\n/** Offset of the first opening tag (`<` + letter) in the template, or -1. */\nfunction firstTagStart(template: string): number {\n return template.search(/<[a-zA-Z]/)\n}\n\n/**\n * Locate the root content element a section marker WOULD be injected into,\n * reported as absolute byte offsets into `source`. Returns null in exactly the\n * same cases `transformAstroSource` bails (no frontmatter `fieldPrefix`, no\n * tag, a component/PascalCase root, an unterminated tag, or already marked) —\n * the two share this logic so the Hub's auto-mark writer targets precisely\n * what the plugin would have stamped, by construction.\n *\n * - `start` — absolute offset of the root element's opening `<`.\n * - `end` — absolute offset of that tag's closing `>`.\n * - `tagName` — the resolved (lowercase) tag name.\n *\n * Pure; exported from the package's `auto-wrap` entry for the doctor/auto-mark.\n */\nexport function findSectionMarkerInsertion(\n source: string,\n): { start: number; end: number; tagName: string } | null {\n const fm = FRONTMATTER_RE.exec(source)\n if (!fm) return null\n if (!frontmatterDeclaresFieldPrefix(fm[1]!)) return null\n\n const fmLen = fm[0].length\n const template = source.slice(fmLen)\n\n const start = firstTagStart(template)\n if (start === -1) return null\n\n const nameMatch = OPEN_TAG_NAME_RE.exec(template.slice(start))\n if (!nameMatch) return null\n\n const tagName = nameMatch[1]!\n if (tagName[0]! !== tagName[0]!.toLowerCase()) return null // component root\n\n const tagEnd = findTagEnd(template, start)\n if (tagEnd === -1) return null\n\n const between = template.slice(start + 1 + tagName.length, tagEnd)\n if (SAASTRO_ATTR_RE.test(between)) return null // already marked\n\n return { start: fmLen + start, end: fmLen + tagEnd, tagName }\n}\n\n/**\n * Like `findSectionMarkerInsertion` but WITHOUT the frontmatter-`fieldPrefix`\n * gate. The plugin (`transformAstroSource`) only auto-wraps files that DECLARE\n * a `fieldPrefix` (it derives the key from it). The Hub's auto-mark writer\n * targets the OTHER case the doctor flags as `missing_marker`: a component that\n * renders `t.<key>` but has NO `fieldPrefix` and NO marker — the key comes from\n * the doctor, not the file, so we only need to locate the root content element.\n * Same skips otherwise (no tag, component/PascalCase root, unterminated tag,\n * already marked). Returns absolute offsets into `source`. Pure.\n */\nexport function findRootTagInsertion(\n source: string,\n): { start: number; end: number; tagName: string } | null {\n const fm = FRONTMATTER_RE.exec(source)\n const fmLen = fm ? fm[0].length : 0\n const template = source.slice(fmLen)\n\n const start = firstTagStart(template)\n if (start === -1) return null\n\n const nameMatch = OPEN_TAG_NAME_RE.exec(template.slice(start))\n if (!nameMatch) return null\n\n const tagName = nameMatch[1]!\n if (tagName[0]! !== tagName[0]!.toLowerCase()) return null // component root\n\n const tagEnd = findTagEnd(template, start)\n if (tagEnd === -1) return null\n\n const between = template.slice(start + 1 + tagName.length, tagEnd)\n if (SAASTRO_ATTR_RE.test(between)) return null // already marked\n\n return { start: fmLen + start, end: fmLen + tagEnd, tagName }\n}\n\n/**\n * Transform a single .astro section source. Injects the conditional\n * `data-saastro` spread on the root element when the file declares a\n * `fieldPrefix`. Exported for unit tests.\n */\nexport function transformAstroSource(source: string): string {\n const hit = findSectionMarkerInsertion(source)\n if (!hit) return source\n // `injectIntoTag` works on the template slice — convert back to template\n // coordinates (subtract the frontmatter length once).\n const fm = FRONTMATTER_RE.exec(source)!\n const fmLen = fm[0].length\n const template = source.slice(fmLen)\n return (\n source.slice(0, fmLen) +\n injectIntoTag(template, hit.start - fmLen, hit.tagName, hit.end - fmLen, INJECTION)\n )\n}\n\n// ── autoWrapPages: whole-page instrumentation (opt-in) ──────────────────────\n\n/** Elements whose BODY must be skipped wholesale (can hold tag-like tokens). */\nconst SKIP_BODY_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title'])\n\n/** Structural tags that are never the section anchor — walk past them. */\nconst SKIP_STRUCTURAL_TAGS = new Set(['html', 'head', 'body', 'link', 'meta', 'base', 'slot'])\n\n/**\n * A REAL existing marker (HTML attribute `data-saastro=` or the JSX spread\n * `\"data-saastro\":`). Deliberately stricter than `SAASTRO_ATTR_RE` so an\n * incidental mention of the word `data-saastro` (in a comment, a string\n * literal, a `querySelectorAll('[data-saastro]')`) does NOT suppress wrapping.\n */\nconst PAGE_MARKER_RE = /data-saastro\\s*=|[\"']data-saastro[\"']\\s*:/\n\n/** `t.<ns>` / `Astro.locals.t.<ns>` usage — the i18n namespaces a file reads. */\nconst T_USAGE_RE = /(?:Astro\\.locals\\.)?\\bt\\.([A-Za-z_$][\\w$]*)/g\n\n/** Index just past the matching `</tag>` close, or -1. */\nfunction indexAfterCloseTag(s: string, lower: string, from: number): number {\n const re = new RegExp(`</\\\\s*${lower}\\\\s*>`, 'i')\n const m = re.exec(s.slice(from))\n return m ? from + m.index + m[0].length : -1\n}\n\n/**\n * Locate the first top-level CONTENT element of an Astro template: a lowercase\n * HTML tag at expression-depth 0, outside comments and `<script>`/`<style>`\n * bodies, skipping component roots (PascalCase) and structural tags. Returns\n * the splice coordinates, or null when there's no suitable element.\n */\nfunction findFirstContentTag(\n template: string,\n): { tagStart: number; tagName: string; tagEnd: number } | null {\n const n = template.length\n let i = 0\n let brace = 0\n while (i < n) {\n const c = template[i]!\n if (c === '{') {\n brace++\n i++\n continue\n }\n if (c === '}') {\n if (brace > 0) brace--\n i++\n continue\n }\n if (c !== '<') {\n i++\n continue\n }\n // HTML comment — skip the whole `<!-- ... -->` range.\n if (template.startsWith('<!--', i)) {\n const end = template.indexOf('-->', i + 4)\n i = end === -1 ? n : end + 3\n continue\n }\n const nameMatch = OPEN_TAG_NAME_RE.exec(template.slice(i))\n if (!nameMatch) {\n i++\n continue\n }\n const tagName = nameMatch[1]!\n const tagEnd = findTagEnd(template, i)\n if (tagEnd === -1) return null // unterminated tag — bail conservatively\n const lower = tagName.toLowerCase()\n\n if (SKIP_BODY_TAGS.has(lower)) {\n const after = indexAfterCloseTag(template, lower, tagEnd + 1)\n i = after === -1 ? n : after\n continue\n }\n const isComponent = tagName[0]! !== tagName[0]!.toLowerCase()\n if (isComponent || SKIP_STRUCTURAL_TAGS.has(lower) || brace > 0) {\n // Component root / structural tag / element inside a top-level\n // expression block — not a stable anchor; walk past the open tag.\n i = tagEnd + 1\n continue\n }\n return { tagStart: i, tagName, tagEnd }\n }\n return null\n}\n\n/** Lower-case + strip separators, so casing/convention differences collapse. */\nfunction normalizeKey(s: string): string {\n return s.toLowerCase().replace(/[-_\\s.]/g, '')\n}\n\n/**\n * The route's section base name, or null for non-page / dynamic routes:\n *\n * src/pages/show.astro → \"show\"\n * src/pages/our-team.astro → \"our-team\"\n * src/pages/index.astro → \"home\"\n * src/pages/blog/index.astro → \"blog\"\n * src/pages/[locale]/show.astro → \"show\" (route params transparent)\n * src/pages/en/index.astro → \"home\" (static locale dir transparent)\n * src/pages/[slug].astro → null (parametric leaf — no namespace)\n * src/components/Hero.astro → null (not a route file)\n */\nexport function routeBaseName(path: string): string | null {\n const m = /(?:^|\\/)src\\/pages\\/(.+\\.astro)$/.exec(path)\n if (!m) return null\n const rel = m[1]!.replace(/\\.astro$/, '')\n const segments = rel.split('/').filter(Boolean)\n const isDynamic = (s: string) => s.includes('[') || s.includes(']')\n const isLocale = (s: string) => /^[a-z]{2}(-[a-z]{2})?$/i.test(s)\n\n let last = segments.pop()\n if (!last || isDynamic(last)) return null\n\n if (last === 'index') {\n // Nearest STATIC, non-locale parent dir names the section; root → \"home\".\n let parent: string | undefined\n while (segments.length) {\n const s = segments.pop()!\n if (!isDynamic(s) && !isLocale(s)) {\n parent = s\n break\n }\n }\n last = parent ?? 'home'\n }\n return last\n}\n\n/** Unique set of i18n namespaces a source references via `t.<ns>`. */\nfunction scanTUsage(source: string): Set<string> {\n const out = new Set<string>()\n T_USAGE_RE.lastIndex = 0\n let m: RegExpExecArray | null\n while ((m = T_USAGE_RE.exec(source)) !== null) out.add(m[1]!)\n return out\n}\n\n/**\n * Resolve the section key for a page, or null. The key is the page's OWN i18n\n * namespace (so the Hub's i18n-forward resolver is guaranteed to find it) — we\n * take the `t.<ns>` reference whose normalized form matches the route base.\n * This sidesteps casing/convention guessing entirely (`our-team` route resolves\n * to a `ourTeam` OR `our_team` namespace, FAQ → `faq`), and refuses to stamp\n * when the page renders no namespace matching its route (e.g. a static-locale\n * index, or a page whose copy lives in components) — leaving it for the doctor.\n */\nexport function resolvePageKey(source: string, path: string): string | null {\n const base = routeBaseName(path)\n if (!base) return null\n const target = normalizeKey(base)\n for (const ns of scanTUsage(source)) {\n if (normalizeKey(ns) === target) return ns\n }\n return null\n}\n\n/**\n * Stamp `data-saastro=\"sec:${key}\"` on a page's first content element. Pure;\n * exported for unit tests. Conservative and idempotent:\n * - skips when the page already carries a REAL marker (`PAGE_MARKER_RE`),\n * ignoring incidental mentions of the word;\n * - lands on the first lowercase content element at expression-depth 0,\n * skipping comments, `<script>`/`<style>` bodies, component roots and\n * structural tags;\n * - injects a LITERAL attribute (no `fieldPrefix` binding exists on a page,\n * and no `draggable` — page roots aren't drag-reorderable sections).\n */\nexport function transformAstroPage(source: string, key: string): string {\n const fm = FRONTMATTER_RE.exec(source)\n const fmLen = fm ? fm[0].length : 0\n const template = source.slice(fmLen)\n\n if (PAGE_MARKER_RE.test(template)) return source\n\n const hit = findFirstContentTag(template)\n if (!hit) return source\n\n const injected = injectIntoTag(\n template,\n hit.tagStart,\n hit.tagName,\n hit.tagEnd,\n ` data-saastro=\"sec:${key}\"`,\n )\n return source.slice(0, fmLen) + injected\n}\n\n// ── Read-side predicates for the Hub Studio doctor ──────────────────────────\n// These mirror EXACTLY the conditions `transformAstroPage` checks, so the\n// doctor's \"will this page be auto-instrumented?\" verdict equals what the\n// plugin actually stamps. Same frontmatter strip + same PAGE_MARKER_RE + same\n// findFirstContentTag — no re-implementation, no drift.\n\n/** Does the page already carry a REAL `data-saastro` marker? (template-only, like the transform). */\nexport function hasPageMarker(source: string): boolean {\n const fm = FRONTMATTER_RE.exec(source)\n return PAGE_MARKER_RE.test(source.slice(fm ? fm[0].length : 0))\n}\n\n/**\n * Is there a content element `transformAstroPage` could anchor a marker on?\n * Returns false for pages that only compose PascalCase components (no HTML\n * element), or whose only lowercase tags live in comments / `<script>` /\n * `<style>` bodies / top-level `{expr}` blocks — i.e. exactly the cases the\n * plugin refuses, so the doctor never claims an un-anchorable page is editable.\n */\nexport function hasPageContentAnchor(source: string): boolean {\n const fm = FRONTMATTER_RE.exec(source)\n return findFirstContentTag(source.slice(fm ? fm[0].length : 0)) !== null\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
studioAutoWrap
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-DKDGF6IQ.js";
|
|
4
4
|
|
|
5
5
|
// src/integration/index.ts
|
|
6
6
|
function saastro(options = {}) {
|
|
@@ -25,4 +25,4 @@ function saastro(options = {}) {
|
|
|
25
25
|
export {
|
|
26
26
|
saastro
|
|
27
27
|
};
|
|
28
|
-
//# sourceMappingURL=chunk-
|
|
28
|
+
//# sourceMappingURL=chunk-ZHCSQE2N.js.map
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Inlined from @saastro/studio-core (private) at build time — see scripts/inline-doctor-dts.mjs
|
|
2
|
+
/**
|
|
3
|
+
* Studio "doctor" — deterministic, I/O-free analyzer that classifies every
|
|
4
|
+
* candidate section in a connected site repo by whether the Studio can edit it,
|
|
5
|
+
* and (when it can't) names the precise missing convention + a ready-to-paste
|
|
6
|
+
* snippet. NO LLM — pure regex + i18n-key set membership, so it's unit-testable
|
|
7
|
+
* and identical run from the Hub or a CLI.
|
|
8
|
+
*
|
|
9
|
+
* It mirrors what the runtime resolver (`sections.ts` → `findOwnerFiles`) and the
|
|
10
|
+
* overlay's marker (`data-saastro="sec:<key>"`) actually do, so the report agrees
|
|
11
|
+
* with the live editor:
|
|
12
|
+
* - a section is editable via SCHEMA when a file declares its `fieldPrefix`
|
|
13
|
+
* (call-site literal or `?? '<key>'` / destructuring default) AND has an
|
|
14
|
+
* `interface *Props` (Hero-style),
|
|
15
|
+
* - editable via I18N when it carries a `data-saastro="sec:<key>"` marker (or a
|
|
16
|
+
* fieldPrefix) whose `<key>` resolves to a translations namespace (the
|
|
17
|
+
* platform i18n-forward path),
|
|
18
|
+
* - a stranded section is one with a marker but neither schema nor i18n, or a
|
|
19
|
+
* component that renders `t.<key>` content but carries no marker at all.
|
|
20
|
+
*
|
|
21
|
+
* The route (`doctor-route.ts`) does the I/O (list repo + read i18n keys) and
|
|
22
|
+
* feeds this module; everything here is pure.
|
|
23
|
+
*/
|
|
24
|
+
type DoctorStatus = 'editable_props' | 'editable_i18n' | 'no_schema_no_i18n' | 'missing_marker' | 'autowrap_gap';
|
|
25
|
+
interface DoctorSection {
|
|
26
|
+
/** Repo-relative file the finding is anchored to. */
|
|
27
|
+
file: string;
|
|
28
|
+
/** The section key (data-saastro / fieldPrefix / suggested i18n namespace). */
|
|
29
|
+
sectionPrefix: string;
|
|
30
|
+
status: DoctorStatus;
|
|
31
|
+
/** One-line human explanation of why this status. */
|
|
32
|
+
reason: string;
|
|
33
|
+
/** What to do about it (empty for the already-editable statuses). */
|
|
34
|
+
remedy: string;
|
|
35
|
+
/** A ready-to-paste snippet, when there is a mechanical fix. */
|
|
36
|
+
snippet?: string;
|
|
37
|
+
}
|
|
38
|
+
interface DoctorReport {
|
|
39
|
+
sections: DoctorSection[];
|
|
40
|
+
stats: {
|
|
41
|
+
files: number;
|
|
42
|
+
candidates: number;
|
|
43
|
+
editable: number;
|
|
44
|
+
needsAttention: number;
|
|
45
|
+
byStatus: Record<DoctorStatus, number>;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Classify every candidate section across the repo. Pure: same files + i18n keys
|
|
50
|
+
* → same report.
|
|
51
|
+
*/
|
|
52
|
+
declare function analyzeSections(files: ReadonlyArray<{
|
|
53
|
+
path: string;
|
|
54
|
+
content: string;
|
|
55
|
+
}>, i18nKeys: ReadonlySet<string>, autoWrapPagesEnabled?: boolean | 'unknown'): DoctorReport;
|
|
56
|
+
|
|
57
|
+
export { type DoctorReport, type DoctorSection, type DoctorStatus, analyzeSections };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* autoWrap gap report (brownfield onboarding Fase 3). Pure + I/O-free.
|
|
61
|
+
*
|
|
62
|
+
* `@saastro/studio`'s autoWrap auto-instruments ~95% of a component's fields for
|
|
63
|
+
* the visual editor. The 5% it can't: an ARRAY prop iterated through a DERIVED
|
|
64
|
+
* variable — `const cols = items.map(...)` in the frontmatter, then `cols.map(…)`
|
|
65
|
+
* in the template. autoWrap's template regex looks for `items.map(` in the body,
|
|
66
|
+
* doesn't find it, and stamps no per-item markers, so those items aren't editable
|
|
67
|
+
* (the #160 warning). This detector finds those props so the onboarding UI can
|
|
68
|
+
* point the author at the hand-written `data-saastro-field` escape-hatch.
|
|
69
|
+
*
|
|
70
|
+
* Mirrors the trigger in `packages/studio/src/vite/instrument-fields.ts`
|
|
71
|
+
* (`hasMapReference` + `hasHandWrittenFieldMarker`): a gap is a prop that is
|
|
72
|
+
* `.map()`-ed in the frontmatter, NOT `.map()`-ed in the template (so autoWrap
|
|
73
|
+
* misses it), and carries no hand-written marker yet.
|
|
74
|
+
*/
|
|
75
|
+
interface AutoWrapGap {
|
|
76
|
+
/** The prop whose items won't be editable until marked by hand. */
|
|
77
|
+
field: string;
|
|
78
|
+
/** Why autoWrap can't reach it. */
|
|
79
|
+
reason: 'derived_variable';
|
|
80
|
+
/** A ready-to-paste escape-hatch marker. */
|
|
81
|
+
snippet: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Find array props that autoWrap can't auto-instrument in one `.astro` file.
|
|
85
|
+
* Pure: same input → same output. Never throws.
|
|
86
|
+
*/
|
|
87
|
+
declare function findAutoWrapGaps(content: string): AutoWrapGap[];
|
|
88
|
+
|
|
89
|
+
export { type AutoWrapGap, findAutoWrapGaps };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* astro.config flag detection for the Studio doctor. Pure + I/O-free.
|
|
93
|
+
*
|
|
94
|
+
* Decides whether a site's astro.config ACTIVELY enables page auto-wrapping, so
|
|
95
|
+
* the doctor's verdict equals what the `@saastro/studio` plugin actually stamps.
|
|
96
|
+
* Shared by the Hub's doctor route and the theme's `studio:check` CLI so both
|
|
97
|
+
* read the config the same way — no drift.
|
|
98
|
+
*/
|
|
99
|
+
/** Doctor's view of the astro.config flags: on / off / present-but-not-verifiable. */
|
|
100
|
+
type AutoWrapConfigState = boolean | 'unknown';
|
|
101
|
+
/** Strip JS comments + string literals so a flag mentioned in a comment / JSDoc
|
|
102
|
+
* @example / string doesn't read as enabled. Best-effort (we test for presence,
|
|
103
|
+
* not evaluate), so over-stripping is fine.
|
|
104
|
+
*
|
|
105
|
+
* Single-pass scanner — NOT sequential regex replaces. The old version stripped
|
|
106
|
+
* strings before line-comments, so an apostrophe inside a `//` comment
|
|
107
|
+
* (`// ...which doesn't exist`) opened a bogus single-quoted span that ate real
|
|
108
|
+
* code — including the `autoWrap: true` flag — making the doctor report
|
|
109
|
+
* autoWrapPages OFF on a config that literally enables it. Stripping comments
|
|
110
|
+
* first instead would break `//` inside string literals (e.g. `'http://…'`).
|
|
111
|
+
* Tracking context in one pass handles both. Regex literals are left as-is
|
|
112
|
+
* (copied verbatim); they don't carry flag names so it's harmless. */
|
|
113
|
+
declare function stripCommentsAndStrings(s: string): string;
|
|
114
|
+
/**
|
|
115
|
+
* Does the astro.config ACTIVELY enable page auto-wrapping? The plugin is inert
|
|
116
|
+
* unless `autoWrap: true` AND only wraps pages when `autoWrapPages: true`
|
|
117
|
+
* (vite/index.ts: `enabled = opts.autoWrap === true`, `load()` bails when not
|
|
118
|
+
* enabled). We require BOTH, on comment/string-stripped source so a documented-
|
|
119
|
+
* but-not-active flag doesn't flip it on. Best-effort source scan, NOT a JS
|
|
120
|
+
* eval — but instead of silently reporting OFF when a flag is set via a
|
|
121
|
+
* variable/shorthand, we return `'unknown'` so the doctor can say so. A flag
|
|
122
|
+
* that's absent entirely (e.g. the whole options object is spread in) still
|
|
123
|
+
* reads as OFF, matching the plugin's default. Pure + exported for unit tests.
|
|
124
|
+
*/
|
|
125
|
+
declare function detectAutoWrapPagesInConfig(source: string): AutoWrapConfigState;
|
|
126
|
+
|
|
127
|
+
export { type AutoWrapConfigState, detectAutoWrapPagesInConfig, stripCommentsAndStrings };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Studio doctor — extra repo checks (H2). Pure + I/O-free, so the Hub's Setup
|
|
131
|
+
* doctor and the theme's `studio:check` CLI run the SAME rules (no drift).
|
|
132
|
+
*
|
|
133
|
+
* Covers the gate items the section classifier (`analyzeSections`, doctor.ts)
|
|
134
|
+
* doesn't:
|
|
135
|
+
* - i18n parity — every translation key present across all locales
|
|
136
|
+
* - token compliance — no hardcoded hex colors in .astro (oklch / var(--*))
|
|
137
|
+
* - form compliance — no raw <form>; forms go through <HubForm>
|
|
138
|
+
* - allowlist — editable copy lives in i18n, not hardcoded in a section
|
|
139
|
+
*
|
|
140
|
+
* (The playbook's "section registered in saastrocms.config / studio.config" gate
|
|
141
|
+
* is intentionally NOT implemented: section keys are per-project examples, not a
|
|
142
|
+
* closed canon — the doctor validates internal coherence, not list membership.)
|
|
143
|
+
*
|
|
144
|
+
* Each finding carries a severity: `error` fails the gate (counts toward
|
|
145
|
+
* `needsAttention`), `warn` is advisory. `runDoctorChecks` aggregates them.
|
|
146
|
+
*/
|
|
147
|
+
type CheckStatus = 'i18n_parity' | 'hardcoded_hex' | 'raw_form' | 'allowlist_violation';
|
|
148
|
+
type CheckSeverity = 'error' | 'warn';
|
|
149
|
+
interface CheckFinding {
|
|
150
|
+
file: string;
|
|
151
|
+
status: CheckStatus;
|
|
152
|
+
severity: CheckSeverity;
|
|
153
|
+
/** One-line human explanation. */
|
|
154
|
+
reason: string;
|
|
155
|
+
/** What to do about it. */
|
|
156
|
+
remedy: string;
|
|
157
|
+
/** The offending token (key, hex, snippet) for display. */
|
|
158
|
+
detail?: string;
|
|
159
|
+
}
|
|
160
|
+
interface ChecksReport {
|
|
161
|
+
findings: CheckFinding[];
|
|
162
|
+
stats: {
|
|
163
|
+
/** error-severity findings — these fail the gate. */
|
|
164
|
+
needsAttention: number;
|
|
165
|
+
/** advisory findings. */
|
|
166
|
+
warnings: number;
|
|
167
|
+
byStatus: Record<CheckStatus, number>;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** A parsed translations file for one locale. */
|
|
171
|
+
interface LocaleTranslations {
|
|
172
|
+
/** Locale code, e.g. 'en'. */
|
|
173
|
+
locale: string;
|
|
174
|
+
/** Translations file path (anchors findings). */
|
|
175
|
+
file: string;
|
|
176
|
+
/** Parsed JSON/YAML object (arrays + scalars treated as leaves). */
|
|
177
|
+
data: unknown;
|
|
178
|
+
}
|
|
179
|
+
/** Report keys missing in any locale relative to the union across all locales. */
|
|
180
|
+
declare function checkI18nParity(locales: ReadonlyArray<LocaleTranslations>): CheckFinding[];
|
|
181
|
+
/** Hex colors that leak into a component instead of a token. Scans the places a
|
|
182
|
+
* brand color realistically lands: `style=` attrs, `<style>` blocks, and
|
|
183
|
+
* Tailwind arbitrary values `[#fff]`. Anchor ids / hrefs (`#section`) never look
|
|
184
|
+
* like hex, so they don't false-positive. Inline `<svg>` is skipped: SVG paint
|
|
185
|
+
* (icons, OG-image generators) legitimately uses literal colors — oklch tokens
|
|
186
|
+
* don't apply to a standalone graphic rendered to an image. */
|
|
187
|
+
declare function checkTokenCompliance(files: ReadonlyArray<{
|
|
188
|
+
path: string;
|
|
189
|
+
content: string;
|
|
190
|
+
}>): CheckFinding[];
|
|
191
|
+
/** A raw lowercase <form> element (HubForm renders <HubForm …>, never a bare
|
|
192
|
+
* <form>). Strips comments + strings first so a `<form>` mentioned in a comment
|
|
193
|
+
* or string literal doesn't trip it. */
|
|
194
|
+
declare function checkFormCompliance(files: ReadonlyArray<{
|
|
195
|
+
path: string;
|
|
196
|
+
content: string;
|
|
197
|
+
}>): CheckFinding[];
|
|
198
|
+
/** Editable copy hardcoded inside a marked (`data-saastro`) section that lives
|
|
199
|
+
* OUTSIDE the publish allowlist → the client can't edit it and it's lost on the
|
|
200
|
+
* next Hub publish. Advisory (best-effort heuristic): real i18n-driven sections
|
|
201
|
+
* render `{t.…}`/`{prop}`, not literal sentences. */
|
|
202
|
+
declare function checkAllowlistCompliance(files: ReadonlyArray<{
|
|
203
|
+
path: string;
|
|
204
|
+
content: string;
|
|
205
|
+
}>): CheckFinding[];
|
|
206
|
+
interface DoctorChecksInput {
|
|
207
|
+
files: ReadonlyArray<{
|
|
208
|
+
path: string;
|
|
209
|
+
content: string;
|
|
210
|
+
}>;
|
|
211
|
+
locales: ReadonlyArray<LocaleTranslations>;
|
|
212
|
+
}
|
|
213
|
+
/** Run all four checks and tally them (error count = needsAttention). */
|
|
214
|
+
declare function runDoctorChecks(input: DoctorChecksInput): ChecksReport;
|
|
215
|
+
|
|
216
|
+
export { type CheckFinding, type CheckSeverity, type CheckStatus, type ChecksReport, type DoctorChecksInput, type LocaleTranslations, checkAllowlistCompliance, checkFormCompliance, checkI18nParity, checkTokenCompliance, runDoctorChecks };
|