@saastro/studio 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
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
+ /**
28
+ * Simple (parameterless) marks. Owner decisions 2026-07-06: accent + bold
29
+ * first; italic + tokenized color/size/font added the same day (marks v2).
30
+ */
31
+ declare const SPAN_MARKS: readonly ["accent", "bold", "italic"];
32
+ type SimpleSpanMark = (typeof SPAN_MARKS)[number];
33
+ /**
34
+ * Tokenized marks (marks v2) — the value part is always a SEMANTIC name,
35
+ * never a raw CSS value:
36
+ *
37
+ * c:<token> color — the site's own CSS custom property name (without
38
+ * the leading --), e.g. `c:accent-300`, `c:brand-jcenlo`.
39
+ * Renders as `color: var(--<token>)`, so it resolves against
40
+ * whatever the site's palette declares.
41
+ * s:sm|lg|xl font size — closed 3-step scale relative to the element
42
+ * (0.85em / 1.15em / 1.35em). No arbitrary sizes.
43
+ * f:display|body|code font family — the site's semantic font slots
44
+ * (`var(--font-display)` / `var(--font-body)` / `var(--code)`).
45
+ * Arbitrary families stay theme-editor territory.
46
+ */
47
+ declare const COLOR_MARK_RE: RegExp;
48
+ declare const SIZE_MARK_RE: RegExp;
49
+ declare const FONT_MARK_RE: RegExp;
50
+ type SpanMark = string;
51
+ declare function isValidMark(mark: unknown): mark is SpanMark;
52
+ declare const spanMarkSchema: z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>;
53
+ declare const richSpanSchema: z.ZodObject<{
54
+ text: z.ZodString;
55
+ marks: z.ZodOptional<z.ZodArray<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>>>;
56
+ }, z.core.$strip>;
57
+ type RichSpan = z.infer<typeof richSpanSchema>;
58
+ /** A spans value is a non-empty array of spans. */
59
+ declare const richSpansValueSchema: z.ZodArray<z.ZodObject<{
60
+ text: z.ZodString;
61
+ marks: z.ZodOptional<z.ZodArray<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>>>;
62
+ }, z.core.$strip>>;
63
+ type RichSpansValue = z.infer<typeof richSpansValueSchema>;
64
+ /**
65
+ * Structural guard for the spans GRAMMAR only. Deliberately strict: every
66
+ * element must be a plain object whose `text` is a string and whose
67
+ * `marks`, if present, only contains whitelisted marks. It does NOT decide
68
+ * whether the value IS rich spans — see `isSpansValue`. Editors use this
69
+ * to keep round-tripping a value mid-edit (a run whose last mark was just
70
+ * toggled off is still grammar-valid), and message validators use it to
71
+ * accept an unmark action crossing the postMessage boundary.
72
+ */
73
+ declare function isSpansShape(value: unknown): value is RichSpansValue;
74
+ /** True when at least one span carries a non-empty mark list. */
75
+ declare function spansHasMarks(value: RichSpansValue): boolean;
76
+ /**
77
+ * "Spans iff marked" — the canonical rich-spans detector (audit E2 #28).
78
+ *
79
+ * A value is rich spans ONLY when it satisfies the grammar AND at least one
80
+ * span actually carries a mark. A `[{text}, {text}]` array with no marks
81
+ * anywhere is indistinguishable from a legitimate generic content array
82
+ * (testimonials/quotes/ticker items with a single `text` key), so it must
83
+ * keep flowing through the generic array code paths — the stega encoder
84
+ * registers its items per-path, the panel renders a FieldArray, etc.
85
+ * Writers enforce the complementary invariant: a spans value whose last
86
+ * mark is removed is normalised back to the plain string
87
+ * (`spansToPlainText`) before staging, so unmarked spans never persist.
88
+ */
89
+ declare function isSpansValue(value: unknown): value is RichSpansValue;
90
+ /** Flatten a spans value to its visible plain text (marks dropped). */
91
+ declare function spansToPlainText(value: RichSpansValue): string;
92
+ /**
93
+ * Plain-text view of a dual leaf. Use anywhere a string is required
94
+ * (summaries, stega visible text, graceful degradation).
95
+ */
96
+ declare function leafToPlainText(value: string | RichSpansValue): string;
97
+ declare const SIZE_MARK_EM: Record<string, string>;
98
+ declare const FONT_MARK_VAR: Record<string, string>;
99
+ /**
100
+ * CSS declarations for the tokenized marks of a span (color/size/font).
101
+ * Returns '' when none apply. `accent`/`bold`/`italic` are NOT covered
102
+ * here — they map to class/element (`.ac`, <strong>, <em>) renderer-side.
103
+ */
104
+ declare function marksToInlineStyle(marks: readonly SpanMark[] | undefined): string;
105
+ /** Serialized form for the lossless round-trip attribute (data-saastro-marks). */
106
+ declare function marksToAttr(marks: readonly SpanMark[] | undefined): string;
107
+ /** Parse the round-trip attribute back to a validated, sorted mark list. */
108
+ declare function marksFromAttr(attr: string | null | undefined): SpanMark[];
109
+ /**
110
+ * Exclusivity group of a mark. Tokenized marks are EXCLUSIVE within their
111
+ * group — one color, one size, one font per run — and `accent` shares the
112
+ * color group with `c:<token>` (both paint the run's text color).
113
+ */
114
+ declare function markGroupPrefix(mark: SpanMark): 'color' | 'size' | 'font' | null;
115
+ /** True when both marks belong to the same (non-null) exclusivity group. */
116
+ declare function sameMarkGroup(a: SpanMark, b: SpanMark): boolean;
117
+ /** Normalise: sort marks, merge adjacent equal-mark spans, drop empties, never []. */
118
+ declare function normalizeSpans(spans: readonly RichSpan[]): RichSpansValue;
119
+ /**
120
+ * Toggle `mark` across the [start, end) character range of the flattened
121
+ * text — the familiar bold-button semantics: if every character in the
122
+ * range already carries the mark it is removed, otherwise it is added.
123
+ * Adding a grouped mark (color/size/font — see `markGroupPrefix`) evicts
124
+ * its group siblings from the range so a run never accumulates e.g.
125
+ * `accent` + `c:<token>` (audit E2 #38/#40 — this eviction is canonical;
126
+ * the overlay's `toggleSpansMark` mirrors it).
127
+ */
128
+ declare function toggleMarkOnRange(spans: RichSpansValue, start: number, end: number, mark: SpanMark): RichSpansValue;
129
+ /**
130
+ * Deep-flatten every spans-SHAPED leaf of a translations tree to its
131
+ * concatenated plain text. This is the universal degradation a site's
132
+ * `getTranslations` applies so every `{t.x.y}` interpolation keeps
133
+ * rendering strings — never `[object Object]` (Astro) or a hooks crash
134
+ * (React islands). Rich rendering opts in per call site via <RichText>
135
+ * fed from the UN-flattened tree (`getRichTranslations`).
136
+ *
137
+ * Uses `isSpansShape` (structure only): unmarked spans-shaped arrays
138
+ * flatten too — content-wise they ARE plain text.
139
+ */
140
+ declare function flattenSpansDeep<T>(value: T): T;
141
+
142
+ export { COLOR_MARK_RE, FONT_MARK_RE, FONT_MARK_VAR, type RichSpan, type RichSpansValue, SIZE_MARK_EM, SIZE_MARK_RE, SPAN_MARKS, type SimpleSpanMark, type SpanMark, flattenSpansDeep, isSpansShape, isSpansValue, isValidMark, leafToPlainText, markGroupPrefix, marksFromAttr, marksToAttr, marksToInlineStyle, normalizeSpans, richSpanSchema, richSpansValueSchema, sameMarkGroup, spanMarkSchema, spansHasMarks, spansToPlainText, toggleMarkOnRange };
@@ -0,0 +1,40 @@
1
+ import {
2
+ COLOR_MARK_RE,
3
+ FONT_MARK_RE,
4
+ FONT_MARK_VAR,
5
+ SIZE_MARK_EM,
6
+ SIZE_MARK_RE,
7
+ SPAN_MARKS,
8
+ flattenSpansDeep,
9
+ isSpansShape,
10
+ isSpansValue,
11
+ isValidMark,
12
+ leafToPlainText,
13
+ marksFromAttr,
14
+ marksToAttr,
15
+ marksToInlineStyle,
16
+ richSpanSchema,
17
+ richSpansValueSchema,
18
+ spansToPlainText
19
+ } from "../chunk-T3ZZAM65.js";
20
+ import "../chunk-PZ5AY32C.js";
21
+ export {
22
+ COLOR_MARK_RE,
23
+ FONT_MARK_RE,
24
+ FONT_MARK_VAR,
25
+ SIZE_MARK_EM,
26
+ SIZE_MARK_RE,
27
+ SPAN_MARKS,
28
+ flattenSpansDeep,
29
+ isSpansShape,
30
+ isSpansValue,
31
+ isValidMark,
32
+ leafToPlainText,
33
+ marksFromAttr,
34
+ marksToAttr,
35
+ marksToInlineStyle,
36
+ richSpanSchema,
37
+ richSpansValueSchema,
38
+ spansToPlainText
39
+ };
40
+ //# 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-G3MUVKWX.js";
4
+ import "../chunk-TU7VF6SY.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.9.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,93 @@
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?}]`) → per span:
11
+ * accent → class `ac` (the theme's semantic accent class;
12
+ * override via `accentClass`)
13
+ * bold / italic → nested <strong> / <em>
14
+ * c:<token> → `color: var(--<token>)` (site palette token)
15
+ * s:sm|lg|xl → relative font-size step
16
+ * f:display|body|code → the site's semantic font-slot vars
17
+ * Marked spans carry `data-saastro-marks` so the Studio canvas can
18
+ * serialize the element back to the exact spans value (lossless
19
+ * round-trip). All presentation derives from the SHARED mapping in
20
+ * @saastro/studio/richtext — token names are regex-validated, raw CSS
21
+ * values never travel in content, and set:html is never used.
22
+ * - Anything else (a malformed value) degrades to its concatenated plain
23
+ * text when possible, never `[object Object]`.
24
+ *
25
+ * Ships with @saastro/studio (consumed via the `./RichText.astro` export).
26
+ */
27
+ import {
28
+ isSpansValue,
29
+ leafToPlainText,
30
+ marksToAttr,
31
+ marksToInlineStyle,
32
+ } from '@saastro/studio/richtext'
33
+
34
+ interface Props {
35
+ /** The i18n leaf: plain string or rich spans array. */
36
+ value: unknown
37
+ /** Class applied to accent spans; defaults to the theme's semantic `ac`. */
38
+ accentClass?: string
39
+ }
40
+
41
+ const { value, accentClass = 'ac' } = Astro.props
42
+
43
+ const spans = isSpansValue(value)
44
+ ? value.map(s => {
45
+ const marks = s.marks ?? []
46
+ const style = marksToInlineStyle(marks)
47
+ return {
48
+ text: s.text,
49
+ bold: marks.includes('bold'),
50
+ italic: marks.includes('italic'),
51
+ accent: marks.includes('accent'),
52
+ style: style.length > 0 ? style : undefined,
53
+ attr: marksToAttr(marks),
54
+ }
55
+ })
56
+ : null
57
+
58
+ const plain = spans
59
+ ? null
60
+ : typeof value === 'string'
61
+ ? value
62
+ : Array.isArray(value)
63
+ ? leafToPlainText(value.filter(s => typeof (s as { text?: unknown })?.text === 'string') as never)
64
+ : value == null
65
+ ? ''
66
+ : String(value)
67
+ ---
68
+
69
+ {
70
+ spans
71
+ ? spans.map(s =>
72
+ s.attr ? (
73
+ <span
74
+ class={s.accent ? accentClass : undefined}
75
+ style={s.style}
76
+ data-saastro-marks={s.attr}
77
+ >
78
+ {s.bold && s.italic ? (
79
+ <strong><em>{s.text}</em></strong>
80
+ ) : s.bold ? (
81
+ <strong>{s.text}</strong>
82
+ ) : s.italic ? (
83
+ <em>{s.text}</em>
84
+ ) : (
85
+ s.text
86
+ )}
87
+ </span>
88
+ ) : (
89
+ s.text
90
+ ),
91
+ )
92
+ : plain
93
+ }
@@ -1,71 +0,0 @@
1
- // src/vite/meta-tag.ts
2
- var STUDIO_PROTOCOL_VERSION = "2";
3
- var PACKAGE_VERSION = "0.7.1".length > 0 ? "0.7.1" : "0.0.0+unknown";
4
- var META_TAGS = [
5
- `<meta name="saastro:studio-version" content="${STUDIO_PROTOCOL_VERSION}">`,
6
- `<meta name="saastro:studio-package" content="${PACKAGE_VERSION}">`
7
- ].join("\n ");
8
- function injectVersionMetaTag(html) {
9
- if (html.includes("saastro:studio-version")) return html;
10
- return html.replace(/<head([^>]*)>/i, `<head$1>
11
- ${META_TAGS}`);
12
- }
13
-
14
- // src/visibility-css.ts
15
- var CONFIG_FILENAME = "studio.config.json";
16
- var STYLE_ID = "saastro-studio-hidden";
17
- function readVisibilityConfigSync(projectRoot = ".") {
18
- try {
19
- const req = globalThis.require;
20
- if (typeof req !== "function") return {};
21
- const fs = req("node:fs");
22
- const path = req("node:path");
23
- const raw = fs.readFileSync(path.resolve(projectRoot, CONFIG_FILENAME), "utf8");
24
- if (!raw.trim()) return {};
25
- const parsed = JSON.parse(raw);
26
- if (parsed && typeof parsed === "object") return parsed;
27
- return {};
28
- } catch {
29
- return {};
30
- }
31
- }
32
- function collectHiddenFieldPrefixes(config) {
33
- const set = /* @__PURE__ */ new Set();
34
- for (const prefixes of Object.values(config.hidden ?? {})) {
35
- if (!Array.isArray(prefixes)) continue;
36
- for (const p of prefixes) {
37
- if (typeof p === "string" && p.length > 0) set.add(p);
38
- }
39
- }
40
- return Array.from(set).sort();
41
- }
42
- function buildVisibilityStyleTag(config) {
43
- const prefixes = collectHiddenFieldPrefixes(config);
44
- if (prefixes.length === 0) return "";
45
- const rules = prefixes.map((fp) => {
46
- const safe = `sec:${fp}`.replace(/["\\]/g, "\\$&");
47
- return `[data-saastro="${safe}"]{display:none !important}`;
48
- }).join("");
49
- return `<style id="${STYLE_ID}">${rules}</style>`;
50
- }
51
- function injectVisibilityStyle(html, _projectRoot = ".") {
52
- if (html.includes(`id="${STYLE_ID}"`)) return html;
53
- let tag = "";
54
- try {
55
- if (typeof __SAASTRO_STUDIO_HIDDEN_TAG__ === "string") {
56
- tag = __SAASTRO_STUDIO_HIDDEN_TAG__;
57
- }
58
- } catch {
59
- }
60
- if (!tag) return html;
61
- return html.replace(/<head([^>]*)>/i, `<head$1>
62
- ${tag}`);
63
- }
64
-
65
- export {
66
- injectVersionMetaTag,
67
- readVisibilityConfigSync,
68
- buildVisibilityStyleTag,
69
- injectVisibilityStyle
70
- };
71
- //# sourceMappingURL=chunk-5QZN4IGF.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/vite/meta-tag.ts","../src/visibility-css.ts"],"sourcesContent":["/**\n * Injects two meta tags into the HTML <head>:\n * - <meta name=\"saastro:studio-version\" content=\"2\"> — protocol version\n * - <meta name=\"saastro:studio-package\" content=\"X.Y.Z\"> — npm package version\n *\n * B8 — spec v5.3 §2 bumps the postMessage protocol number to \"2\". The\n * `studio-version` field reads as the PROTOCOL version (the contract Hub\n * relies on when wiring overlay <-> host messages), not the npm package\n * version of `@saastro/studio`. The package version still ships in a\n * separate `saastro:studio-package` tag for telemetry.\n *\n * The package version comes from a tsup `define` substitution\n * (`__SAASTRO_STUDIO_VERSION__`) injected at build time. The previous\n * implementation used `createRequire(import.meta.url)` to read\n * `package.json` at runtime, but tsup's chunked output places dist\n * files at `dist/chunk-XXX.js` and `../../package.json` resolves\n * OUTSIDE the package — the require always threw and the catch fell\n * back to a hardcoded `'0.1.0'`. Build-time substitution is bullet-\n * proof regardless of how tsup chunks the bundle.\n *\n * This is separate from the auto-wrap transform hook —\n * transformIndexHtml runs on rendered HTML output, not on .astro\n * source files.\n */\n\n/**\n * postMessage protocol version. Bumped to \"2\" in spec v5.3 to add\n * `studio:sections:enumerate` + `studio:select:section` exchanges.\n * Increment when adding/changing the iframe⇄host wire format.\n */\nconst STUDIO_PROTOCOL_VERSION = '2'\n\ndeclare const __SAASTRO_STUDIO_VERSION__: string | undefined\n\nconst PACKAGE_VERSION =\n typeof __SAASTRO_STUDIO_VERSION__ === 'string' && __SAASTRO_STUDIO_VERSION__.length > 0\n ? __SAASTRO_STUDIO_VERSION__\n : '0.0.0+unknown'\n\nconst META_TAGS = [\n `<meta name=\"saastro:studio-version\" content=\"${STUDIO_PROTOCOL_VERSION}\">`,\n `<meta name=\"saastro:studio-package\" content=\"${PACKAGE_VERSION}\">`,\n].join('\\n ')\n\n/**\n * Inject the two studio meta tags into the HTML head. Idempotent —\n * skips injection if the protocol-version tag is already present\n * (matches both the new shape and the legacy single-tag shape, so\n * customer sites with old metadata aren't double-tagged on rebuild).\n */\nexport function injectVersionMetaTag(html: string): string {\n if (html.includes('saastro:studio-version')) return html\n return html.replace(/<head([^>]*)>/i, `<head$1>\\n ${META_TAGS}`)\n}\n","/**\n * Read `studio.config.json` from a project root and build the\n * `<style>` block that hides sections flagged as `hidden`.\n *\n * The config shape is the one written by the Studio Hub at publish time:\n *\n * {\n * \"hidden\": {\n * \"<pageFile>\": [\"<fieldPrefix>\", ...]\n * }\n * }\n *\n * The CSS targets the `data-saastro=\"sec:<fieldPrefix>\"` markers the\n * auto-wrap plugin emits — so hiding is \"exactly the section root,\n * nothing more\". Selectors are flattened across all `pageFile` entries:\n * Studio's `fieldPrefix` naming convention is `<sectionName>.<pageName>`\n * which keeps them unique site-wide, so this loses no precision.\n *\n * Both the Vite `transformIndexHtml` hook (static-build sites) and the\n * Astro middleware (SSR routes) call into the same helpers so the\n * injected CSS is identical regardless of render mode.\n */\n\n// **CRITICAL — DO NOT add static node:fs / node:path imports here.**\n// This module is loaded by `@saastro/studio/middleware`, which runs\n// inside Cloudflare Workers at SSR boot. CF Workers does not support\n// `node:fs` even with the `nodejs_compat` flag (sync fs I/O is\n// fundamentally incompatible with the worker model) — a top-level\n// `import { readFileSync } from 'node:fs'` causes the module to fail\n// to load with `Error: No such module \"node:fs\"`, which breaks the\n// entire middleware chain and makes Astro return 500 on every SSR\n// route.\n//\n// The `node:fs` fallback (used by `astro dev` in Node) loads via\n// dynamic import inside `readVisibilityConfig`, wrapped in try/catch.\n// Workers never reaches that path because the build-time `define`\n// (`__SAASTRO_STUDIO_HIDDEN_TAG__`) short-circuits the readFileSync\n// call entirely.\n\nconst CONFIG_FILENAME = 'studio.config.json'\nconst STYLE_ID = 'saastro-studio-hidden'\n\n/**\n * Compile-time constant injected by the `@saastro/studio` Vite plugin\n * (see `config()` hook in `vite/index.ts`). esbuild substitutes the\n * identifier at customer-bundle time with a JSON string of the\n * full `<style>` tag (or an empty string when nothing is hidden), so\n * the middleware doesn't need filesystem access at runtime — a\n * hard requirement on Cloudflare Workers, where `node:fs` is a no-op.\n *\n * Declared as a free variable on the global scope so esbuild's\n * `define` substitution recognises it as a literal-replacement\n * target. When the plugin isn't installed, the identifier remains\n * undefined and the middleware falls back to the readFileSync path\n * (works in node-based deployments).\n */\ndeclare const __SAASTRO_STUDIO_HIDDEN_TAG__: string | undefined\n\ninterface StudioConfig {\n hidden?: Record<string, string[]>\n}\n\n/**\n * Read studio.config.json from `projectRoot`. Returns an empty config\n * if the file is missing or malformed — a publish that hasn't written\n * the config yet behaves like \"nothing hidden\".\n *\n * Defaulting `projectRoot` to `'.'` lets callers omit it when they\n * cannot easily determine the resolved Vite root — `node:path.resolve`\n * resolves relative to the current working directory, which is the\n * project root in every realistic deployment of the middleware.\n */\n/**\n * Build-time-only helper. Imports `node:fs` lazily so this module\n * stays loadable on CF Workers (where `node:fs` is not available).\n * The Vite plugin (`vite/index.ts`, runs in Node) calls this during\n * `config()` to inline the visibility tag as a `define` constant.\n * The runtime middleware (`middleware/index.ts`, runs in Workers)\n * never calls it — it reads the inlined define directly.\n *\n * Returns `{}` when the file isn't there, isn't readable, or when\n * `node:fs` simply isn't available (e.g. bundled into a non-Node\n * runtime that somehow reached this branch).\n */\nexport async function readVisibilityConfig(\n projectRoot: string = '.',\n): Promise<StudioConfig> {\n try {\n const [{ readFileSync }, { resolve }] = await Promise.all([\n import('node:fs'),\n import('node:path'),\n ])\n const raw = readFileSync(resolve(projectRoot, CONFIG_FILENAME), 'utf8')\n if (!raw.trim()) return {}\n const parsed = JSON.parse(raw) as unknown\n if (parsed && typeof parsed === 'object') return parsed as StudioConfig\n return {}\n } catch {\n return {}\n }\n}\n\n/**\n * Synchronous variant kept for back-compat with callers that can't\n * await (e.g. test fixtures, legacy plugin hooks). Same lazy node:fs\n * import via `require` — works in Node CJS contexts, returns `{}`\n * on CF Workers / pure-ESM environments where `require` is undefined.\n *\n * The dynamic `globalThis.require` lookup avoids esbuild bundling the\n * module reference, which means CF Workers never throws\n * \"No such module 'node:fs'\" at import time.\n */\nexport function readVisibilityConfigSync(projectRoot: string = '.'): StudioConfig {\n try {\n const req = (globalThis as { require?: (id: string) => unknown }).require\n if (typeof req !== 'function') return {}\n const fs = req('node:fs') as { readFileSync: (p: string, e: string) => string }\n const path = req('node:path') as { resolve: (...parts: string[]) => string }\n const raw = fs.readFileSync(path.resolve(projectRoot, CONFIG_FILENAME), 'utf8')\n if (!raw.trim()) return {}\n const parsed = JSON.parse(raw) as unknown\n if (parsed && typeof parsed === 'object') return parsed as StudioConfig\n return {}\n } catch {\n return {}\n }\n}\n\n/**\n * Collect every fieldPrefix flagged as hidden across all pageFile\n * entries. Deduplicates so a prefix that appears under multiple\n * pageFiles only emits one selector.\n */\nexport function collectHiddenFieldPrefixes(config: StudioConfig): string[] {\n const set = new Set<string>()\n for (const prefixes of Object.values(config.hidden ?? {})) {\n if (!Array.isArray(prefixes)) continue\n for (const p of prefixes) {\n if (typeof p === 'string' && p.length > 0) set.add(p)\n }\n }\n return Array.from(set).sort()\n}\n\n/**\n * Build the `<style>` block. Returns an empty string when there's\n * nothing to hide so callers can no-op without conditional branching.\n */\nexport function buildVisibilityStyleTag(config: StudioConfig): string {\n const prefixes = collectHiddenFieldPrefixes(config)\n if (prefixes.length === 0) return ''\n const rules = prefixes\n .map(fp => {\n // CSS attribute-selector grammar: only `\\` and `\"` need escaping\n // inside `[attr=\"...\"]`. fieldPrefix is conventionally\n // `[a-zA-Z0-9._-]+` so this is belt-and-braces.\n const safe = `sec:${fp}`.replace(/[\"\\\\]/g, '\\\\$&')\n return `[data-saastro=\"${safe}\"]{display:none !important}`\n })\n .join('')\n return `<style id=\"${STYLE_ID}\">${rules}</style>`\n}\n\n/**\n * Inject the visibility `<style>` into an HTML document's `<head>`.\n * Idempotent: a page that already carries a `${STYLE_ID}` style is\n * returned unchanged.\n */\nexport function injectVisibilityStyle(html: string, _projectRoot: string = '.'): string {\n if (html.includes(`id=\"${STYLE_ID}\"`)) return html\n // Prefer the build-time-injected tag when the customer's Vite build\n // populated `__SAASTRO_STUDIO_HIDDEN_TAG__` via the plugin's `config`\n // hook. This is the only path that works on Cloudflare Workers, where\n // `node:fs` is a no-op. The readFileSync fallback handles node-based\n // deployments (or local `astro dev` before the plugin's define lands).\n let tag = ''\n try {\n if (typeof __SAASTRO_STUDIO_HIDDEN_TAG__ === 'string') {\n tag = __SAASTRO_STUDIO_HIDDEN_TAG__\n }\n } catch {\n // ReferenceError swallowed — identifier wasn't substituted.\n }\n // No `readFileSync` fallback in this runtime path — that branch\n // only worked on Node and breaks CF Workers (`node:fs` import\n // throws \"No such module\"). When the build-time define isn't\n // populated (e.g. someone uses the middleware without the Vite\n // plugin), the visibility CSS just isn't injected — graceful\n // degradation, the meta tag still works.\n if (!tag) return html\n return html.replace(/<head([^>]*)>/i, `<head$1>\\n ${tag}`)\n}\n"],"mappings":";AA8BA,IAAM,0BAA0B;AAIhC,IAAM,kBAC8C,QAA2B,SAAS,IAClF,UACA;AAEN,IAAM,YAAY;AAAA,EAChB,gDAAgD,uBAAuB;AAAA,EACvE,gDAAgD,eAAe;AACjE,EAAE,KAAK,MAAM;AAQN,SAAS,qBAAqB,MAAsB;AACzD,MAAI,KAAK,SAAS,wBAAwB,EAAG,QAAO;AACpD,SAAO,KAAK,QAAQ,kBAAkB;AAAA,IAAe,SAAS,EAAE;AAClE;;;ACdA,IAAM,kBAAkB;AACxB,IAAM,WAAW;AAwEV,SAAS,yBAAyB,cAAsB,KAAmB;AAChF,MAAI;AACF,UAAM,MAAO,WAAqD;AAClE,QAAI,OAAO,QAAQ,WAAY,QAAO,CAAC;AACvC,UAAM,KAAK,IAAI,SAAS;AACxB,UAAM,OAAO,IAAI,WAAW;AAC5B,UAAM,MAAM,GAAG,aAAa,KAAK,QAAQ,aAAa,eAAe,GAAG,MAAM;AAC9E,QAAI,CAAC,IAAI,KAAK,EAAG,QAAO,CAAC;AACzB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QAAI,UAAU,OAAO,WAAW,SAAU,QAAO;AACjD,WAAO,CAAC;AAAA,EACV,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAOO,SAAS,2BAA2B,QAAgC;AACzE,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,YAAY,OAAO,OAAO,OAAO,UAAU,CAAC,CAAC,GAAG;AACzD,QAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG;AAC9B,eAAW,KAAK,UAAU;AACxB,UAAI,OAAO,MAAM,YAAY,EAAE,SAAS,EAAG,KAAI,IAAI,CAAC;AAAA,IACtD;AAAA,EACF;AACA,SAAO,MAAM,KAAK,GAAG,EAAE,KAAK;AAC9B;AAMO,SAAS,wBAAwB,QAA8B;AACpE,QAAM,WAAW,2BAA2B,MAAM;AAClD,MAAI,SAAS,WAAW,EAAG,QAAO;AAClC,QAAM,QAAQ,SACX,IAAI,QAAM;AAIT,UAAM,OAAO,OAAO,EAAE,GAAG,QAAQ,UAAU,MAAM;AACjD,WAAO,kBAAkB,IAAI;AAAA,EAC/B,CAAC,EACA,KAAK,EAAE;AACV,SAAO,cAAc,QAAQ,KAAK,KAAK;AACzC;AAOO,SAAS,sBAAsB,MAAc,eAAuB,KAAa;AACtF,MAAI,KAAK,SAAS,OAAO,QAAQ,GAAG,EAAG,QAAO;AAM9C,MAAI,MAAM;AACV,MAAI;AACF,QAAI,OAAO,kCAAkC,UAAU;AACrD,YAAM;AAAA,IACR;AAAA,EACF,QAAQ;AAAA,EAER;AAOA,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,KAAK,QAAQ,kBAAkB;AAAA,IAAe,GAAG,EAAE;AAC5D;","names":[]}