@fluid-app/fluid-cli-portal 0.1.47 → 0.1.48

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,7 +1,7 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-twds-ZHy.mjs";
2
2
  import { basename, extname, join, resolve, sep } from "node:path";
3
3
  import { readFile, readdir } from "node:fs/promises";
4
- import { existsSync } from "node:fs";
4
+ import { existsSync, readFileSync } from "node:fs";
5
5
  import { createHash } from "node:crypto";
6
6
  //#region src/vite-plugin/build-manifest.ts
7
7
  /**
@@ -145,12 +145,55 @@ async function buildManifestFromLocalFiles(portalDir) {
145
145
  /**
146
146
  * Vite plugin for local portal content development.
147
147
  *
148
- * Intercepts the manifest API request (`/api/fluid_os/definitions/active`)
149
- * and serves content assembled from the local `portal/` directory.
150
- * Watches portal files for changes and triggers a full page reload.
148
+ * Intercepts the manifest API request and serves content assembled from the
149
+ * local `portal/` directory. Watches portal files for changes and triggers a
150
+ * full page reload.
151
+ *
152
+ * The manifest is intercepted on BOTH paths the SDK has used across versions
153
+ * (`/api/app/manifest` since portal-sdk 0.1.4xx, `/api/fluid_os/definitions/
154
+ * active` before that) so a newer SDK against an older-generation app — or
155
+ * vice versa — still gets the local draft.
151
156
  */
152
157
  var portal_dev_plugin_exports = /* @__PURE__ */ __exportAll({ portalDevPlugin: () => portalDevPlugin });
153
158
  const DEBOUNCE_MS = 100;
159
+ /** Manifest endpoints across SDK generations — all serve the local draft. */
160
+ const MANIFEST_PATHS = ["/api/app/manifest", "/api/fluid_os/definitions/active"];
161
+ const DEFAULT_API_URL = "https://api.fluid.app";
162
+ /**
163
+ * Fallback .env reader for VITE_API_URL, used only when vite's `loadEnv`
164
+ * can't be imported (it's a declared dependency, so this is defensive — but
165
+ * a broken install must degrade to "wrong host" rather than "dev server
166
+ * won't boot"). Mirrors the basic lookup order: live env > .env.local >
167
+ * .env. Unlike loadEnv it ignores .env.[mode] files and variable expansion.
168
+ */
169
+ function readViteApiUrlFallback(root) {
170
+ if (process.env.VITE_API_URL) return process.env.VITE_API_URL;
171
+ if (process.env.NEXT_PUBLIC_API_BASE_URL) return process.env.NEXT_PUBLIC_API_BASE_URL;
172
+ for (const file of [".env.local", ".env"]) {
173
+ const path = join(root, file);
174
+ if (!existsSync(path)) continue;
175
+ for (const line of readFileSync(path, "utf8").split("\n")) {
176
+ const match = /^\s*VITE_API_URL\s*=\s*(?:"([^"]*)"|(.*?))\s*(?:#.*)?$/.exec(line);
177
+ const value = match?.[1] ?? match?.[2];
178
+ if (value) return value;
179
+ }
180
+ }
181
+ }
182
+ /**
183
+ * Resolve VITE_API_URL the way vite itself would — `loadEnv` honors
184
+ * .env.[mode], .env.[mode].local, envDir, and variable expansion, which the
185
+ * fallback parser does not (a mode-specific staging API must not silently
186
+ * fall back to production).
187
+ */
188
+ async function resolveViteApiUrl(root, mode, envDir) {
189
+ try {
190
+ const { loadEnv } = await import("vite");
191
+ const env = loadEnv(mode, envDir ?? root, ["VITE_", "NEXT_PUBLIC_"]);
192
+ return env.VITE_API_URL || process.env.VITE_API_URL || env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || void 0;
193
+ } catch {
194
+ return readViteApiUrlFallback(root);
195
+ }
196
+ }
154
197
  function portalDevPlugin(options) {
155
198
  let portalDir;
156
199
  let cachedManifest = null;
@@ -158,6 +201,18 @@ function portalDevPlugin(options) {
158
201
  return {
159
202
  name: "fluid-portal-dev",
160
203
  enforce: "pre",
204
+ async config(config, env) {
205
+ if (env?.command !== "serve" || options?.sameOriginDev === false) return {};
206
+ const root = config.root ?? process.cwd();
207
+ const target = options?.apiProxyTarget ?? await resolveViteApiUrl(root, env?.mode ?? "development", config.envDir) ?? DEFAULT_API_URL;
208
+ return {
209
+ define: config.define?.["import.meta.env.VITE_API_URL"] === void 0 ? { "import.meta.env.VITE_API_URL": JSON.stringify("") } : {},
210
+ server: { proxy: config.server?.proxy?.["/api"] === void 0 ? { "/api": {
211
+ target,
212
+ changeOrigin: true
213
+ } } : {} }
214
+ };
215
+ },
161
216
  configResolved(config) {
162
217
  const root = config.root ?? process.cwd();
163
218
  portalDir = options?.portalDir ? resolve(root, options.portalDir) : join(root, "portal");
@@ -187,10 +242,9 @@ function portalDevPlugin(options) {
187
242
  server.watcher.on("add", onFileChange);
188
243
  server.watcher.on("unlink", onFileChange);
189
244
  }
190
- const isManifestRequest = (url) => url.includes("/api/app/manifest") || url.includes("/api/fluid_os/definitions/active");
191
245
  server.middlewares.use((req, res, next) => {
192
246
  const url = req.url;
193
- if (!url || !isManifestRequest(url)) {
247
+ if (!url || !MANIFEST_PATHS.some((path) => url.includes(path))) {
194
248
  next();
195
249
  return;
196
250
  }
@@ -230,4 +284,4 @@ function portalDevPlugin(options) {
230
284
  //#endregion
231
285
  export { portal_dev_plugin_exports as n, buildManifestFromLocalFiles as r, portalDevPlugin as t };
232
286
 
233
- //# sourceMappingURL=portal-dev-plugin-DBoPIt_V.mjs.map
287
+ //# sourceMappingURL=portal-dev-plugin-CytSDvBy.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal-dev-plugin-CytSDvBy.mjs","names":[],"sources":["../src/vite-plugin/build-manifest.ts","../src/vite-plugin/portal-dev-plugin.ts"],"sourcesContent":["/**\n * Build a RawManifestResponse from local portal/ files.\n *\n * Reads the portal directory structure produced by `fluid portal pull` and\n * assembles it into the same shape the SDK expects from the\n * `/api/fluid_os/definitions/active` endpoint, so the existing\n * `transformManifestToRepAppData()` pipeline works unchanged.\n */\n\nimport { readFile, readdir } from \"node:fs/promises\";\nimport { join, basename, extname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { createHash } from \"node:crypto\";\n\nimport type {\n LocalScreen,\n LocalTheme,\n LocalNavigation,\n LocalNavigationItem,\n LocalProfile,\n} from \"../utils/transform.js\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Types matching the RawManifestResponse shape from\n// packages/portal/sdk/src/transforms/index.ts\n// ─────────────────────────────────────────────────────────────────────────────\n\ninterface RawNavigationItem {\n id: number;\n label: string;\n slug?: string;\n icon?: string;\n screen_id?: number;\n parent_id?: number;\n source?: string;\n position: number;\n children: RawNavigationItem[];\n}\n\ninterface RawNavigation {\n id: number;\n name?: string;\n definition_id: number;\n navigation_items?: RawNavigationItem[];\n}\n\ninterface RawTheme {\n id: number;\n config?: Record<string, unknown> | null;\n active?: boolean | null;\n name?: string | null;\n}\n\ninterface RawScreen {\n id: number;\n definition_id?: number;\n name?: string | null;\n slug?: string | null;\n component_tree?: unknown;\n}\n\ninterface RawManifestProfile {\n name?: string;\n definition_id: number;\n themes?: RawTheme[];\n navigation?: RawNavigation;\n mobile_navigation?: RawNavigation;\n}\n\ninterface RawManifestResponse {\n manifest: {\n definition_id: number;\n published_version?: number;\n screens?: RawScreen[];\n profile?: RawManifestProfile;\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Synthetic ID generation\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Generate a deterministic synthetic numeric ID from a string key.\n * Uses a CRC32-like hash so the same slug always produces the same ID\n * within a dev session, while avoiding collisions in practice.\n */\nfunction syntheticId(key: string): number {\n const hash = createHash(\"md5\").update(key).digest();\n // Read first 4 bytes as unsigned 32-bit integer, ensure positive\n return hash.readUInt32BE(0);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// File reading helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Read and parse a JSON file, returning null on failure.\n */\nasync function readJsonFile<T>(filePath: string): Promise<T | null> {\n try {\n const content = await readFile(filePath, \"utf-8\");\n return JSON.parse(content) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Read all JSON files in a directory, returning an array of [slug, data] tuples.\n * Slug is the filename without extension.\n */\nasync function readJsonDir<T>(\n dirPath: string,\n): Promise<Array<[slug: string, data: T]>> {\n if (!existsSync(dirPath)) return [];\n\n const entries = await readdir(dirPath, { withFileTypes: true });\n const results: Array<[string, T]> = [];\n\n for (const entry of entries) {\n if (!entry.isFile() || extname(entry.name) !== \".json\") continue;\n\n const slug = basename(entry.name, \".json\");\n const data = await readJsonFile<T>(join(dirPath, entry.name));\n if (data != null) {\n results.push([slug, data]);\n }\n }\n\n return results;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Navigation item conversion\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Convert local navigation items (which reference screens by slug)\n * back to the raw API format (which uses screen_id).\n */\nfunction toRawNavigationItems(\n items: LocalNavigationItem[],\n screenSlugToId: Map<string, number>,\n): RawNavigationItem[] {\n return items.map((item) => {\n const rawItem: RawNavigationItem = {\n id: item.id,\n label: item.label ?? \"Untitled\",\n position: item.position ?? 0,\n children: toRawNavigationItems(item.children ?? [], screenSlugToId),\n };\n\n if (item.slug != null) rawItem.slug = item.slug;\n if (item.icon != null) rawItem.icon = item.icon;\n if (item.source != null) rawItem.source = item.source;\n if (item.parent_id != null) rawItem.parent_id = item.parent_id;\n\n // Resolve screen slug -> synthetic screen ID\n if (item.screen != null) {\n const screenId = screenSlugToId.get(item.screen);\n if (screenId != null) {\n rawItem.screen_id = screenId;\n }\n }\n\n return rawItem;\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Main builder\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Build a `RawManifestResponse` from local portal/ files.\n *\n * The returned object matches the shape from `/api/fluid_os/definitions/active`\n * so the SDK's `transformManifestToRepAppData()` works without modification.\n */\nexport async function buildManifestFromLocalFiles(\n portalDir: string,\n): Promise<RawManifestResponse> {\n // 1. Read definition.json\n const definition = await readJsonFile<{ name?: string }>(\n join(portalDir, \"definition.json\"),\n );\n const definitionName = definition?.name ?? \"Local Dev\";\n const definitionId = syntheticId(`definition:${definitionName}`);\n\n // 2. Read all resource files in parallel\n const [screenEntries, themeEntries, navEntries, profileEntries] =\n await Promise.all([\n readJsonDir<LocalScreen>(join(portalDir, \"screens\")),\n readJsonDir<LocalTheme>(join(portalDir, \"themes\")),\n readJsonDir<LocalNavigation>(join(portalDir, \"navigations\")),\n readJsonDir<LocalProfile>(join(portalDir, \"profiles\")),\n ]);\n\n // 3. Build screen slug -> synthetic ID map\n const screenSlugToId = new Map<string, number>();\n for (const [slug] of screenEntries) {\n screenSlugToId.set(slug, syntheticId(`screen:${slug}`));\n }\n\n // 4. Build raw screens\n const rawScreens: RawScreen[] = screenEntries.map(([slug, screen]) => ({\n id: screenSlugToId.get(slug)!,\n definition_id: definitionId,\n name: screen.name,\n slug,\n component_tree: screen.component_tree,\n }));\n\n // 5. Build raw themes (keyed by slug for profile lookup)\n const themeSlugToRaw = new Map<string, RawTheme>();\n for (const [slug, theme] of themeEntries) {\n themeSlugToRaw.set(slug, {\n id: syntheticId(`theme:${slug}`),\n name: theme.name,\n config: theme.config,\n active: theme.active,\n });\n }\n const rawThemes = Array.from(themeSlugToRaw.values());\n\n // 6. Build navigation slug -> synthetic ID map\n const navSlugToId = new Map<string, number>();\n for (const [slug] of navEntries) {\n navSlugToId.set(slug, syntheticId(`navigation:${slug}`));\n }\n\n // Build raw navigations (keyed by slug for profile lookup)\n const rawNavigations = new Map<string, RawNavigation>();\n for (const [slug, nav] of navEntries) {\n rawNavigations.set(slug, {\n id: navSlugToId.get(slug)!,\n name: nav.name,\n definition_id: definitionId,\n navigation_items: toRawNavigationItems(\n nav.navigation_items ?? [],\n screenSlugToId,\n ),\n });\n }\n\n // 7. Find the default profile (or use the first one)\n const defaultProfile =\n profileEntries.find(([, p]) => p.default)?.[1] ??\n profileEntries[0]?.[1] ??\n null;\n\n // 8. Assemble the profile for the manifest\n let rawProfile: RawManifestProfile | undefined;\n if (defaultProfile) {\n // Resolve navigation slugs to raw navigation objects\n const navigation = defaultProfile.navigation\n ? rawNavigations.get(defaultProfile.navigation)\n : undefined;\n\n const mobileNavigation = defaultProfile.mobile_navigation\n ? rawNavigations.get(defaultProfile.mobile_navigation)\n : undefined;\n\n // Filter themes to those referenced by the profile (by slug)\n const profileThemeSet = new Set(defaultProfile.themes);\n const profileThemes =\n profileThemeSet.size > 0\n ? Array.from(profileThemeSet)\n .map((slug) => themeSlugToRaw.get(slug))\n .filter((t): t is RawTheme => t != null)\n : rawThemes; // If no themes specified in profile, include all\n\n rawProfile = {\n name: defaultProfile.name,\n definition_id: definitionId,\n themes: profileThemes,\n ...(navigation ? { navigation } : {}),\n ...(mobileNavigation ? { mobile_navigation: mobileNavigation } : {}),\n };\n } else if (rawThemes.length > 0 || rawNavigations.size > 0) {\n // No profile files but we have themes/navs — create a synthetic profile\n const firstNav =\n rawNavigations.size > 0\n ? rawNavigations.values().next().value\n : undefined;\n\n rawProfile = {\n name: \"Default\",\n definition_id: definitionId,\n themes: rawThemes,\n ...(firstNav ? { navigation: firstNav } : {}),\n };\n }\n\n // 9. Return assembled manifest\n return {\n manifest: {\n definition_id: definitionId,\n published_version: 0,\n screens: rawScreens,\n ...(rawProfile ? { profile: rawProfile } : {}),\n },\n };\n}\n","/**\n * Vite plugin for local portal content development.\n *\n * Intercepts the manifest API request and serves content assembled from the\n * local `portal/` directory. Watches portal files for changes and triggers a\n * full page reload.\n *\n * The manifest is intercepted on BOTH paths the SDK has used across versions\n * (`/api/app/manifest` since portal-sdk 0.1.4xx, `/api/fluid_os/definitions/\n * active` before that) so a newer SDK against an older-generation app — or\n * vice versa — still gets the local draft.\n */\n\nimport { join, resolve, sep } from \"node:path\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { buildManifestFromLocalFiles } from \"./build-manifest.js\";\n\n/** Options for the portal dev plugin. */\nexport interface PortalDevPluginOptions {\n portalDir?: string;\n /**\n * When true (default), dev-server requests are forced same-origin: the\n * plugin blanks `import.meta.env.VITE_API_URL` in serve mode and proxies\n * `/api` to the configured Fluid host instead. Without this, a `.env`\n * pointing VITE_API_URL at a remote API makes the SPA fetch its manifest\n * cross-origin — silently bypassing this plugin and rendering the remote\n * app instead of the local `portal/` draft. Set false to opt out and let\n * the SPA call the remote API directly.\n */\n sameOriginDev?: boolean;\n /** Override the `/api` dev-proxy target. Defaults to VITE_API_URL from the process env / .env files, then https://api.fluid.app. */\n apiProxyTarget?: string;\n}\n\nconst DEBOUNCE_MS = 100;\n\n/** Manifest endpoints across SDK generations — all serve the local draft. */\nconst MANIFEST_PATHS = [\n \"/api/app/manifest\",\n \"/api/fluid_os/definitions/active\",\n];\n\nconst DEFAULT_API_URL = \"https://api.fluid.app\";\n\n/**\n * Fallback .env reader for VITE_API_URL, used only when vite's `loadEnv`\n * can't be imported (it's a declared dependency, so this is defensive — but\n * a broken install must degrade to \"wrong host\" rather than \"dev server\n * won't boot\"). Mirrors the basic lookup order: live env > .env.local >\n * .env. Unlike loadEnv it ignores .env.[mode] files and variable expansion.\n */\nfunction readViteApiUrlFallback(root: string): string | undefined {\n if (process.env.VITE_API_URL) return process.env.VITE_API_URL;\n if (process.env.NEXT_PUBLIC_API_BASE_URL) {\n return process.env.NEXT_PUBLIC_API_BASE_URL;\n }\n for (const file of [\".env.local\", \".env\"]) {\n const path = join(root, file);\n if (!existsSync(path)) continue;\n for (const line of readFileSync(path, \"utf8\").split(\"\\n\")) {\n // Quoted values stop at the closing `\"`; unquoted values stop before\n // an inline ` # comment` tail (dotenv semantics — a trailing comment\n // must not leak into the proxy target URL).\n const match =\n /^\\s*VITE_API_URL\\s*=\\s*(?:\"([^\"]*)\"|(.*?))\\s*(?:#.*)?$/.exec(line);\n const value = match?.[1] ?? match?.[2];\n if (value) return value;\n }\n }\n return undefined;\n}\n\n/**\n * Resolve VITE_API_URL the way vite itself would — `loadEnv` honors\n * .env.[mode], .env.[mode].local, envDir, and variable expansion, which the\n * fallback parser does not (a mode-specific staging API must not silently\n * fall back to production).\n */\nasync function resolveViteApiUrl(\n root: string,\n mode: string,\n envDir: string | undefined,\n): Promise<string | undefined> {\n try {\n const { loadEnv } = await import(\"vite\");\n const env = loadEnv(mode, envDir ?? root, [\"VITE_\", \"NEXT_PUBLIC_\"]);\n return (\n env.VITE_API_URL ||\n process.env.VITE_API_URL ||\n // Portals migrated from Next.js conventions configure the API host as\n // NEXT_PUBLIC_API_BASE_URL — honored by `fluid portal dev` since it\n // was introduced, so it stays a recognized source here.\n env.NEXT_PUBLIC_API_BASE_URL ||\n process.env.NEXT_PUBLIC_API_BASE_URL ||\n undefined\n );\n } catch {\n return readViteApiUrlFallback(root);\n }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function portalDevPlugin(options?: PortalDevPluginOptions): any {\n let portalDir: string;\n let cachedManifest: string | null = null;\n let buildInFlight: Promise<string> | null = null;\n\n return {\n name: \"fluid-portal-dev\",\n enforce: \"pre\" as const,\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n async config(config: any, env: any) {\n if (env?.command !== \"serve\" || options?.sameOriginDev === false) {\n return {};\n }\n\n const root: string = config.root ?? process.cwd();\n const target =\n options?.apiProxyTarget ??\n (await resolveViteApiUrl(\n root,\n env?.mode ?? \"development\",\n config.envDir,\n )) ??\n DEFAULT_API_URL;\n\n // Vite merges a plugin's returned config OVER the user config, so an\n // app's own explicit define / /api proxy is only preserved by checking\n // for it and yielding here.\n const defines =\n config.define?.[\"import.meta.env.VITE_API_URL\"] === undefined\n ? { \"import.meta.env.VITE_API_URL\": JSON.stringify(\"\") }\n : {};\n const proxy =\n config.server?.proxy?.[\"/api\"] === undefined\n ? { \"/api\": { target, changeOrigin: true } }\n : {};\n\n return {\n define: defines,\n server: { proxy },\n };\n },\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configResolved(config: any) {\n const root: string = config.root ?? process.cwd();\n portalDir = options?.portalDir\n ? resolve(root, options.portalDir)\n : join(root, \"portal\");\n },\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configureServer(server: any) {\n // Idempotency: `fluid portal dev` injects this plugin, and a portal's\n // own vite.config may also wire it. Only the first instance registers\n // the watcher + middleware so we don't double-reload or double-serve.\n if (server.__fluidPortalDevWired) return;\n server.__fluidPortalDevWired = true;\n\n const watchDir = portalDir;\n\n if (existsSync(watchDir)) {\n server.watcher.add(watchDir);\n\n let reloadTimer: ReturnType<typeof setTimeout> | null = null;\n\n const onFileChange = (filePath: string) => {\n if (!filePath.startsWith(watchDir)) return;\n\n cachedManifest = null;\n buildInFlight = null;\n\n console.log(\n `[fluid-portal-dev] File changed: ${filePath.replace(watchDir + sep, \"\")}`,\n );\n\n if (reloadTimer) clearTimeout(reloadTimer);\n reloadTimer = setTimeout(() => {\n reloadTimer = null;\n server.ws.send({ type: \"full-reload\", path: \"*\" });\n }, DEBOUNCE_MS);\n };\n\n server.watcher.on(\"change\", onFileChange);\n server.watcher.on(\"add\", onFileChange);\n server.watcher.on(\"unlink\", onFileChange);\n }\n\n // ── Middleware to intercept manifest requests ───────────────────\n // Every MANIFEST_PATHS route returns the SAME `{ manifest }` payload:\n // the portal-tenant BFF route the current SDK runtime fetches\n // (useFluidApp → app_manifest_show) and the legacy FluidOS route, so\n // the local portal/ draft feeds the running app regardless of which\n // runtime generation it uses (the SDK adapter reads\n // `response.manifest`, matching buildManifestFromLocalFiles' output).\n server.middlewares.use(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (req: any, res: any, next: () => void) => {\n const url: string | undefined = req.url;\n\n if (!url || !MANIFEST_PATHS.some((path) => url.includes(path))) {\n next();\n return;\n }\n\n (async () => {\n try {\n if (!existsSync(portalDir)) {\n // Fall through to the Vite proxy so requests reach the real\n // API when local portal content is unavailable (e.g. --skip-pull).\n next();\n return;\n }\n\n if (cachedManifest == null) {\n if (!buildInFlight) {\n buildInFlight = buildManifestFromLocalFiles(portalDir)\n .then((m) => {\n cachedManifest = JSON.stringify(m);\n buildInFlight = null;\n return cachedManifest;\n })\n .catch((err) => {\n buildInFlight = null; // allow retry on next request\n throw err;\n });\n }\n cachedManifest = await buildInFlight;\n }\n\n res.writeHead(200, {\n \"Content-Type\": \"application/json\",\n \"X-Portal-Dev-Mode\": \"true\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(cachedManifest);\n } catch (err) {\n console.error(\"[fluid-portal-dev] Error building manifest:\", err);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n err instanceof Error\n ? err.message\n : \"Failed to build manifest from local files\",\n }),\n );\n }\n })();\n },\n );\n },\n };\n}\n"],"mappings":"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"}
@@ -4,6 +4,18 @@ import { Plugin } from "vite";
4
4
  /** Options for the portal dev plugin. */
5
5
  interface PortalDevPluginOptions {
6
6
  portalDir?: string;
7
+ /**
8
+ * When true (default), dev-server requests are forced same-origin: the
9
+ * plugin blanks `import.meta.env.VITE_API_URL` in serve mode and proxies
10
+ * `/api` to the configured Fluid host instead. Without this, a `.env`
11
+ * pointing VITE_API_URL at a remote API makes the SPA fetch its manifest
12
+ * cross-origin — silently bypassing this plugin and rendering the remote
13
+ * app instead of the local `portal/` draft. Set false to opt out and let
14
+ * the SPA call the remote API directly.
15
+ */
16
+ sameOriginDev?: boolean;
17
+ /** Override the `/api` dev-proxy target. Defaults to VITE_API_URL from the process env / .env files, then https://api.fluid.app. */
18
+ apiProxyTarget?: string;
7
19
  }
8
20
  declare function portalDevPlugin(options?: PortalDevPluginOptions): any;
9
21
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"vite-plugin.d.mts","names":[],"sources":["../src/vite-plugin/portal-dev-plugin.ts","../src/vite-plugin/build-manifest.ts","../src/vite-plugin/backend-dev-plugin.ts"],"mappings":";;;;UAaiB,sBAAA;EACf,SAAA;AAAA;AAAA,iBAMc,eAAA,CAAgB,OAAA,GAAU,sBAAA;;;UCOhC,iBAAA;EACR,EAAA;EACA,KAAA;EACA,IAAA;EACA,IAAA;EACA,SAAA;EACA,SAAA;EACA,MAAA;EACA,QAAA;EACA,QAAA,EAAU,iBAAA;AAAA;AAAA,UAGF,aAAA;EACR,EAAA;EACA,IAAA;EACA,aAAA;EACA,gBAAA,GAAmB,iBAAA;AAAA;AAAA,UAGX,QAAA;EACR,EAAA;EACA,MAAA,GAAS,MAAA;EACT,MAAA;EACA,IAAA;AAAA;AAAA,UAGQ,SAAA;EACR,EAAA;EACA,aAAA;EACA,IAAA;EACA,IAAA;EACA,cAAA;AAAA;AAAA,UAGQ,kBAAA;EACR,IAAA;EACA,aAAA;EACA,MAAA,GAAS,QAAA;EACT,UAAA,GAAa,aAAA;EACb,iBAAA,GAAoB,aAAA;AAAA;AAAA,UAGZ,mBAAA;EACR,QAAA;IACE,aAAA;IACA,iBAAA;IACA,OAAA,GAAU,SAAA;IACV,OAAA,GAAU,kBAAA;EAAA;AAAA;;;;;;;iBA2GQ,2BAAA,CACpB,SAAA,WACC,OAAA,CAAQ,mBAAA;;;UCrLM,uBAAA;;EAEf,KAAA;EFSe;EEPf,MAAA;AAAA;;AFcF;;;;;;;;;iBEDgB,gBAAA,CAAiB,OAAA,GAAU,uBAAA,GAA0B,MAAA"}
1
+ {"version":3,"file":"vite-plugin.d.mts","names":[],"sources":["../src/vite-plugin/portal-dev-plugin.ts","../src/vite-plugin/build-manifest.ts","../src/vite-plugin/backend-dev-plugin.ts"],"mappings":";;;;UAkBiB,sBAAA;EACf,SAAA;EADF;;;;;;;;;EAWE,aAAA;EAyEc;EAvEd,cAAA;AAAA;AAAA,iBAuEc,eAAA,CAAgB,OAAA,GAAU,sBAAA;;;UC3EhC,iBAAA;EACR,EAAA;EACA,KAAA;EACA,IAAA;EACA,IAAA;EACA,SAAA;EACA,SAAA;EACA,MAAA;EACA,QAAA;EACA,QAAA,EAAU,iBAAA;AAAA;AAAA,UAGF,aAAA;EACR,EAAA;EACA,IAAA;EACA,aAAA;EACA,gBAAA,GAAmB,iBAAA;AAAA;AAAA,UAGX,QAAA;EACR,EAAA;EACA,MAAA,GAAS,MAAA;EACT,MAAA;EACA,IAAA;AAAA;AAAA,UAGQ,SAAA;EACR,EAAA;EACA,aAAA;EACA,IAAA;EACA,IAAA;EACA,cAAA;AAAA;AAAA,UAGQ,kBAAA;EACR,IAAA;EACA,aAAA;EACA,MAAA,GAAS,QAAA;EACT,UAAA,GAAa,aAAA;EACb,iBAAA,GAAoB,aAAA;AAAA;AAAA,UAGZ,mBAAA;EACR,QAAA;IACE,aAAA;IACA,iBAAA;IACA,OAAA,GAAU,SAAA;IACV,OAAA,GAAU,kBAAA;EAAA;AAAA;;;AA/BO;;;;iBA0IC,2BAAA,CACpB,SAAA,WACC,OAAA,CAAQ,mBAAA;;;UCrLM,uBAAA;;EAEf,KAAA;EFce;EEZf,MAAA;AAAA;;;;;;AFgGF;;;;;iBEnFgB,gBAAA,CAAiB,OAAA,GAAU,uBAAA,GAA0B,MAAA"}
@@ -1,3 +1,3 @@
1
- import { r as buildManifestFromLocalFiles, t as portalDevPlugin } from "./portal-dev-plugin-DBoPIt_V.mjs";
1
+ import { r as buildManifestFromLocalFiles, t as portalDevPlugin } from "./portal-dev-plugin-CytSDvBy.mjs";
2
2
  import { t as backendDevPlugin } from "./backend-dev-plugin-B9-S4Is-.mjs";
3
3
  export { backendDevPlugin, buildManifestFromLocalFiles, portalDevPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-portal",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "description": "Fluid CLI plugin for building portal applications",
5
5
  "files": [
6
6
  "dist",
@@ -34,8 +34,8 @@
34
34
  "tsx": "^4.21.0",
35
35
  "vite": "^6.0.0",
36
36
  "@fluid-app/fluid-cli": "0.1.14",
37
- "@fluid-app/portal-core": "0.1.23",
38
- "@fluid-app/fluidos-api-client": "0.1.0"
37
+ "@fluid-app/fluidos-api-client": "0.1.0",
38
+ "@fluid-app/portal-core": "0.1.23"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@swc/core": "^1.15.18",
@@ -1,4 +1,8 @@
1
- VITE_API_URL=https://api.fluid.app
1
+ # Fluid API host. LEAVE UNSET for local development: `fluid portal dev`
2
+ # forces requests same-origin and proxies /api to this host (default
3
+ # https://api.fluid.app) so your local portal/ content is served. Setting an
4
+ # absolute URL here only changes which API the dev proxy targets.
5
+ # VITE_API_URL=
2
6
 
3
7
  # Optional: override the Fluid CLI auth token for this project.
4
8
  # Takes effect when running `pnpm pull`, `pnpm push`, etc.
@@ -35,7 +35,7 @@ pnpm push # fluid portal push
35
35
  pnpm widget:create <name> # scaffold a company-owned portal widget
36
36
  ```
37
37
 
38
- Set `VITE_API_URL` in `.env` if you need to point the SDK at a non-default Fluid API host.
38
+ Set `VITE_API_URL` in `.env` if you need a non-default Fluid API host. In `fluid portal dev` this changes the `/api` dev-proxy target — requests stay same-origin so your local `portal/` content is always the one served.
39
39
 
40
40
  ## Project structure
41
41
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"portal-dev-plugin-DBoPIt_V.mjs","names":[],"sources":["../src/vite-plugin/build-manifest.ts","../src/vite-plugin/portal-dev-plugin.ts"],"sourcesContent":["/**\n * Build a RawManifestResponse from local portal/ files.\n *\n * Reads the portal directory structure produced by `fluid portal pull` and\n * assembles it into the same shape the SDK expects from the\n * `/api/fluid_os/definitions/active` endpoint, so the existing\n * `transformManifestToRepAppData()` pipeline works unchanged.\n */\n\nimport { readFile, readdir } from \"node:fs/promises\";\nimport { join, basename, extname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { createHash } from \"node:crypto\";\n\nimport type {\n LocalScreen,\n LocalTheme,\n LocalNavigation,\n LocalNavigationItem,\n LocalProfile,\n} from \"../utils/transform.js\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Types matching the RawManifestResponse shape from\n// packages/portal/sdk/src/transforms/index.ts\n// ─────────────────────────────────────────────────────────────────────────────\n\ninterface RawNavigationItem {\n id: number;\n label: string;\n slug?: string;\n icon?: string;\n screen_id?: number;\n parent_id?: number;\n source?: string;\n position: number;\n children: RawNavigationItem[];\n}\n\ninterface RawNavigation {\n id: number;\n name?: string;\n definition_id: number;\n navigation_items?: RawNavigationItem[];\n}\n\ninterface RawTheme {\n id: number;\n config?: Record<string, unknown> | null;\n active?: boolean | null;\n name?: string | null;\n}\n\ninterface RawScreen {\n id: number;\n definition_id?: number;\n name?: string | null;\n slug?: string | null;\n component_tree?: unknown;\n}\n\ninterface RawManifestProfile {\n name?: string;\n definition_id: number;\n themes?: RawTheme[];\n navigation?: RawNavigation;\n mobile_navigation?: RawNavigation;\n}\n\ninterface RawManifestResponse {\n manifest: {\n definition_id: number;\n published_version?: number;\n screens?: RawScreen[];\n profile?: RawManifestProfile;\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Synthetic ID generation\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Generate a deterministic synthetic numeric ID from a string key.\n * Uses a CRC32-like hash so the same slug always produces the same ID\n * within a dev session, while avoiding collisions in practice.\n */\nfunction syntheticId(key: string): number {\n const hash = createHash(\"md5\").update(key).digest();\n // Read first 4 bytes as unsigned 32-bit integer, ensure positive\n return hash.readUInt32BE(0);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// File reading helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Read and parse a JSON file, returning null on failure.\n */\nasync function readJsonFile<T>(filePath: string): Promise<T | null> {\n try {\n const content = await readFile(filePath, \"utf-8\");\n return JSON.parse(content) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Read all JSON files in a directory, returning an array of [slug, data] tuples.\n * Slug is the filename without extension.\n */\nasync function readJsonDir<T>(\n dirPath: string,\n): Promise<Array<[slug: string, data: T]>> {\n if (!existsSync(dirPath)) return [];\n\n const entries = await readdir(dirPath, { withFileTypes: true });\n const results: Array<[string, T]> = [];\n\n for (const entry of entries) {\n if (!entry.isFile() || extname(entry.name) !== \".json\") continue;\n\n const slug = basename(entry.name, \".json\");\n const data = await readJsonFile<T>(join(dirPath, entry.name));\n if (data != null) {\n results.push([slug, data]);\n }\n }\n\n return results;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Navigation item conversion\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Convert local navigation items (which reference screens by slug)\n * back to the raw API format (which uses screen_id).\n */\nfunction toRawNavigationItems(\n items: LocalNavigationItem[],\n screenSlugToId: Map<string, number>,\n): RawNavigationItem[] {\n return items.map((item) => {\n const rawItem: RawNavigationItem = {\n id: item.id,\n label: item.label ?? \"Untitled\",\n position: item.position ?? 0,\n children: toRawNavigationItems(item.children ?? [], screenSlugToId),\n };\n\n if (item.slug != null) rawItem.slug = item.slug;\n if (item.icon != null) rawItem.icon = item.icon;\n if (item.source != null) rawItem.source = item.source;\n if (item.parent_id != null) rawItem.parent_id = item.parent_id;\n\n // Resolve screen slug -> synthetic screen ID\n if (item.screen != null) {\n const screenId = screenSlugToId.get(item.screen);\n if (screenId != null) {\n rawItem.screen_id = screenId;\n }\n }\n\n return rawItem;\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Main builder\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Build a `RawManifestResponse` from local portal/ files.\n *\n * The returned object matches the shape from `/api/fluid_os/definitions/active`\n * so the SDK's `transformManifestToRepAppData()` works without modification.\n */\nexport async function buildManifestFromLocalFiles(\n portalDir: string,\n): Promise<RawManifestResponse> {\n // 1. Read definition.json\n const definition = await readJsonFile<{ name?: string }>(\n join(portalDir, \"definition.json\"),\n );\n const definitionName = definition?.name ?? \"Local Dev\";\n const definitionId = syntheticId(`definition:${definitionName}`);\n\n // 2. Read all resource files in parallel\n const [screenEntries, themeEntries, navEntries, profileEntries] =\n await Promise.all([\n readJsonDir<LocalScreen>(join(portalDir, \"screens\")),\n readJsonDir<LocalTheme>(join(portalDir, \"themes\")),\n readJsonDir<LocalNavigation>(join(portalDir, \"navigations\")),\n readJsonDir<LocalProfile>(join(portalDir, \"profiles\")),\n ]);\n\n // 3. Build screen slug -> synthetic ID map\n const screenSlugToId = new Map<string, number>();\n for (const [slug] of screenEntries) {\n screenSlugToId.set(slug, syntheticId(`screen:${slug}`));\n }\n\n // 4. Build raw screens\n const rawScreens: RawScreen[] = screenEntries.map(([slug, screen]) => ({\n id: screenSlugToId.get(slug)!,\n definition_id: definitionId,\n name: screen.name,\n slug,\n component_tree: screen.component_tree,\n }));\n\n // 5. Build raw themes (keyed by slug for profile lookup)\n const themeSlugToRaw = new Map<string, RawTheme>();\n for (const [slug, theme] of themeEntries) {\n themeSlugToRaw.set(slug, {\n id: syntheticId(`theme:${slug}`),\n name: theme.name,\n config: theme.config,\n active: theme.active,\n });\n }\n const rawThemes = Array.from(themeSlugToRaw.values());\n\n // 6. Build navigation slug -> synthetic ID map\n const navSlugToId = new Map<string, number>();\n for (const [slug] of navEntries) {\n navSlugToId.set(slug, syntheticId(`navigation:${slug}`));\n }\n\n // Build raw navigations (keyed by slug for profile lookup)\n const rawNavigations = new Map<string, RawNavigation>();\n for (const [slug, nav] of navEntries) {\n rawNavigations.set(slug, {\n id: navSlugToId.get(slug)!,\n name: nav.name,\n definition_id: definitionId,\n navigation_items: toRawNavigationItems(\n nav.navigation_items ?? [],\n screenSlugToId,\n ),\n });\n }\n\n // 7. Find the default profile (or use the first one)\n const defaultProfile =\n profileEntries.find(([, p]) => p.default)?.[1] ??\n profileEntries[0]?.[1] ??\n null;\n\n // 8. Assemble the profile for the manifest\n let rawProfile: RawManifestProfile | undefined;\n if (defaultProfile) {\n // Resolve navigation slugs to raw navigation objects\n const navigation = defaultProfile.navigation\n ? rawNavigations.get(defaultProfile.navigation)\n : undefined;\n\n const mobileNavigation = defaultProfile.mobile_navigation\n ? rawNavigations.get(defaultProfile.mobile_navigation)\n : undefined;\n\n // Filter themes to those referenced by the profile (by slug)\n const profileThemeSet = new Set(defaultProfile.themes);\n const profileThemes =\n profileThemeSet.size > 0\n ? Array.from(profileThemeSet)\n .map((slug) => themeSlugToRaw.get(slug))\n .filter((t): t is RawTheme => t != null)\n : rawThemes; // If no themes specified in profile, include all\n\n rawProfile = {\n name: defaultProfile.name,\n definition_id: definitionId,\n themes: profileThemes,\n ...(navigation ? { navigation } : {}),\n ...(mobileNavigation ? { mobile_navigation: mobileNavigation } : {}),\n };\n } else if (rawThemes.length > 0 || rawNavigations.size > 0) {\n // No profile files but we have themes/navs — create a synthetic profile\n const firstNav =\n rawNavigations.size > 0\n ? rawNavigations.values().next().value\n : undefined;\n\n rawProfile = {\n name: \"Default\",\n definition_id: definitionId,\n themes: rawThemes,\n ...(firstNav ? { navigation: firstNav } : {}),\n };\n }\n\n // 9. Return assembled manifest\n return {\n manifest: {\n definition_id: definitionId,\n published_version: 0,\n screens: rawScreens,\n ...(rawProfile ? { profile: rawProfile } : {}),\n },\n };\n}\n","/**\n * Vite plugin for local portal content development.\n *\n * Intercepts the manifest API request (`/api/fluid_os/definitions/active`)\n * and serves content assembled from the local `portal/` directory.\n * Watches portal files for changes and triggers a full page reload.\n */\n\nimport { join, resolve, sep } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { buildManifestFromLocalFiles } from \"./build-manifest.js\";\n\n/** Options for the portal dev plugin. */\nexport interface PortalDevPluginOptions {\n portalDir?: string;\n}\n\nconst DEBOUNCE_MS = 100;\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function portalDevPlugin(options?: PortalDevPluginOptions): any {\n let portalDir: string;\n let cachedManifest: string | null = null;\n let buildInFlight: Promise<string> | null = null;\n\n return {\n name: \"fluid-portal-dev\",\n enforce: \"pre\" as const,\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configResolved(config: any) {\n const root: string = config.root ?? process.cwd();\n portalDir = options?.portalDir\n ? resolve(root, options.portalDir)\n : join(root, \"portal\");\n },\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configureServer(server: any) {\n // Idempotency: `fluid portal dev` injects this plugin, and a portal's\n // own vite.config may also wire it. Only the first instance registers\n // the watcher + middleware so we don't double-reload or double-serve.\n if (server.__fluidPortalDevWired) return;\n server.__fluidPortalDevWired = true;\n\n const watchDir = portalDir;\n\n if (existsSync(watchDir)) {\n server.watcher.add(watchDir);\n\n let reloadTimer: ReturnType<typeof setTimeout> | null = null;\n\n const onFileChange = (filePath: string) => {\n if (!filePath.startsWith(watchDir)) return;\n\n cachedManifest = null;\n buildInFlight = null;\n\n console.log(\n `[fluid-portal-dev] File changed: ${filePath.replace(watchDir + sep, \"\")}`,\n );\n\n if (reloadTimer) clearTimeout(reloadTimer);\n reloadTimer = setTimeout(() => {\n reloadTimer = null;\n server.ws.send({ type: \"full-reload\", path: \"*\" });\n }, DEBOUNCE_MS);\n };\n\n server.watcher.on(\"change\", onFileChange);\n server.watcher.on(\"add\", onFileChange);\n server.watcher.on(\"unlink\", onFileChange);\n }\n\n // ── Middleware to intercept manifest requests ───────────────────\n // Two routes return the SAME `{ manifest }` payload:\n // - /api/app/manifest — the portal-tenant BFF route the\n // current SDK runtime (useFluidApp → app_manifest_show) fetches.\n // - /api/fluid_os/definitions/active — the legacy FluidOS route.\n // We serve both so the local portal/ draft feeds the running app\n // regardless of which runtime it uses (the SDK adapter reads\n // `response.manifest`, matching buildManifestFromLocalFiles' output).\n const isManifestRequest = (url: string): boolean =>\n url.includes(\"/api/app/manifest\") ||\n url.includes(\"/api/fluid_os/definitions/active\");\n\n server.middlewares.use(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (req: any, res: any, next: () => void) => {\n const url: string | undefined = req.url;\n\n if (!url || !isManifestRequest(url)) {\n next();\n return;\n }\n\n (async () => {\n try {\n if (!existsSync(portalDir)) {\n // Fall through to the Vite proxy so requests reach the real\n // API when local portal content is unavailable (e.g. --skip-pull).\n next();\n return;\n }\n\n if (cachedManifest == null) {\n if (!buildInFlight) {\n buildInFlight = buildManifestFromLocalFiles(portalDir)\n .then((m) => {\n cachedManifest = JSON.stringify(m);\n buildInFlight = null;\n return cachedManifest;\n })\n .catch((err) => {\n buildInFlight = null; // allow retry on next request\n throw err;\n });\n }\n cachedManifest = await buildInFlight;\n }\n\n res.writeHead(200, {\n \"Content-Type\": \"application/json\",\n \"X-Portal-Dev-Mode\": \"true\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(cachedManifest);\n } catch (err) {\n console.error(\"[fluid-portal-dev] Error building manifest:\", err);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n err instanceof Error\n ? err.message\n : \"Failed to build manifest from local files\",\n }),\n );\n }\n })();\n },\n );\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAuFA,SAAS,YAAY,KAAqB;AAGxC,QAFa,WAAW,MAAM,CAAC,OAAO,IAAI,CAAC,QAAQ,CAEvC,aAAa,EAAE;;;;;AAU7B,eAAe,aAAgB,UAAqC;AAClE,KAAI;EACF,MAAM,UAAU,MAAM,SAAS,UAAU,QAAQ;AACjD,SAAO,KAAK,MAAM,QAAQ;SACpB;AACN,SAAO;;;;;;;AAQX,eAAe,YACb,SACyC;AACzC,KAAI,CAAC,WAAW,QAAQ,CAAE,QAAO,EAAE;CAEnC,MAAM,UAAU,MAAM,QAAQ,SAAS,EAAE,eAAe,MAAM,CAAC;CAC/D,MAAM,UAA8B,EAAE;AAEtC,MAAK,MAAM,SAAS,SAAS;AAC3B,MAAI,CAAC,MAAM,QAAQ,IAAI,QAAQ,MAAM,KAAK,KAAK,QAAS;EAExD,MAAM,OAAO,SAAS,MAAM,MAAM,QAAQ;EAC1C,MAAM,OAAO,MAAM,aAAgB,KAAK,SAAS,MAAM,KAAK,CAAC;AAC7D,MAAI,QAAQ,KACV,SAAQ,KAAK,CAAC,MAAM,KAAK,CAAC;;AAI9B,QAAO;;;;;;AAWT,SAAS,qBACP,OACA,gBACqB;AACrB,QAAO,MAAM,KAAK,SAAS;EACzB,MAAM,UAA6B;GACjC,IAAI,KAAK;GACT,OAAO,KAAK,SAAS;GACrB,UAAU,KAAK,YAAY;GAC3B,UAAU,qBAAqB,KAAK,YAAY,EAAE,EAAE,eAAe;GACpE;AAED,MAAI,KAAK,QAAQ,KAAM,SAAQ,OAAO,KAAK;AAC3C,MAAI,KAAK,QAAQ,KAAM,SAAQ,OAAO,KAAK;AAC3C,MAAI,KAAK,UAAU,KAAM,SAAQ,SAAS,KAAK;AAC/C,MAAI,KAAK,aAAa,KAAM,SAAQ,YAAY,KAAK;AAGrD,MAAI,KAAK,UAAU,MAAM;GACvB,MAAM,WAAW,eAAe,IAAI,KAAK,OAAO;AAChD,OAAI,YAAY,KACd,SAAQ,YAAY;;AAIxB,SAAO;GACP;;;;;;;;AAaJ,eAAsB,4BACpB,WAC8B;CAM9B,MAAM,eAAe,YAAY,eAJd,MAAM,aACvB,KAAK,WAAW,kBAAkB,CACnC,GACkC,QAAQ,cACqB;CAGhE,MAAM,CAAC,eAAe,cAAc,YAAY,kBAC9C,MAAM,QAAQ,IAAI;EAChB,YAAyB,KAAK,WAAW,UAAU,CAAC;EACpD,YAAwB,KAAK,WAAW,SAAS,CAAC;EAClD,YAA6B,KAAK,WAAW,cAAc,CAAC;EAC5D,YAA0B,KAAK,WAAW,WAAW,CAAC;EACvD,CAAC;CAGJ,MAAM,iCAAiB,IAAI,KAAqB;AAChD,MAAK,MAAM,CAAC,SAAS,cACnB,gBAAe,IAAI,MAAM,YAAY,UAAU,OAAO,CAAC;CAIzD,MAAM,aAA0B,cAAc,KAAK,CAAC,MAAM,aAAa;EACrE,IAAI,eAAe,IAAI,KAAK;EAC5B,eAAe;EACf,MAAM,OAAO;EACb;EACA,gBAAgB,OAAO;EACxB,EAAE;CAGH,MAAM,iCAAiB,IAAI,KAAuB;AAClD,MAAK,MAAM,CAAC,MAAM,UAAU,aAC1B,gBAAe,IAAI,MAAM;EACvB,IAAI,YAAY,SAAS,OAAO;EAChC,MAAM,MAAM;EACZ,QAAQ,MAAM;EACd,QAAQ,MAAM;EACf,CAAC;CAEJ,MAAM,YAAY,MAAM,KAAK,eAAe,QAAQ,CAAC;CAGrD,MAAM,8BAAc,IAAI,KAAqB;AAC7C,MAAK,MAAM,CAAC,SAAS,WACnB,aAAY,IAAI,MAAM,YAAY,cAAc,OAAO,CAAC;CAI1D,MAAM,iCAAiB,IAAI,KAA4B;AACvD,MAAK,MAAM,CAAC,MAAM,QAAQ,WACxB,gBAAe,IAAI,MAAM;EACvB,IAAI,YAAY,IAAI,KAAK;EACzB,MAAM,IAAI;EACV,eAAe;EACf,kBAAkB,qBAChB,IAAI,oBAAoB,EAAE,EAC1B,eACD;EACF,CAAC;CAIJ,MAAM,iBACJ,eAAe,MAAM,GAAG,OAAO,EAAE,QAAQ,GAAG,MAC5C,eAAe,KAAK,MACpB;CAGF,IAAI;AACJ,KAAI,gBAAgB;EAElB,MAAM,aAAa,eAAe,aAC9B,eAAe,IAAI,eAAe,WAAW,GAC7C,KAAA;EAEJ,MAAM,mBAAmB,eAAe,oBACpC,eAAe,IAAI,eAAe,kBAAkB,GACpD,KAAA;EAGJ,MAAM,kBAAkB,IAAI,IAAI,eAAe,OAAO;EACtD,MAAM,gBACJ,gBAAgB,OAAO,IACnB,MAAM,KAAK,gBAAgB,CACxB,KAAK,SAAS,eAAe,IAAI,KAAK,CAAC,CACvC,QAAQ,MAAqB,KAAK,KAAK,GAC1C;AAEN,eAAa;GACX,MAAM,eAAe;GACrB,eAAe;GACf,QAAQ;GACR,GAAI,aAAa,EAAE,YAAY,GAAG,EAAE;GACpC,GAAI,mBAAmB,EAAE,mBAAmB,kBAAkB,GAAG,EAAE;GACpE;YACQ,UAAU,SAAS,KAAK,eAAe,OAAO,GAAG;EAE1D,MAAM,WACJ,eAAe,OAAO,IAClB,eAAe,QAAQ,CAAC,MAAM,CAAC,QAC/B,KAAA;AAEN,eAAa;GACX,MAAM;GACN,eAAe;GACf,QAAQ;GACR,GAAI,WAAW,EAAE,YAAY,UAAU,GAAG,EAAE;GAC7C;;AAIH,QAAO,EACL,UAAU;EACR,eAAe;EACf,mBAAmB;EACnB,SAAS;EACT,GAAI,aAAa,EAAE,SAAS,YAAY,GAAG,EAAE;EAC9C,EACF;;;;;;;;;;;;AC/RH,MAAM,cAAc;AAGpB,SAAgB,gBAAgB,SAAuC;CACrE,IAAI;CACJ,IAAI,iBAAgC;CACpC,IAAI,gBAAwC;AAE5C,QAAO;EACL,MAAM;EACN,SAAS;EAGT,eAAe,QAAa;GAC1B,MAAM,OAAe,OAAO,QAAQ,QAAQ,KAAK;AACjD,eAAY,SAAS,YACjB,QAAQ,MAAM,QAAQ,UAAU,GAChC,KAAK,MAAM,SAAS;;EAI1B,gBAAgB,QAAa;AAI3B,OAAI,OAAO,sBAAuB;AAClC,UAAO,wBAAwB;GAE/B,MAAM,WAAW;AAEjB,OAAI,WAAW,SAAS,EAAE;AACxB,WAAO,QAAQ,IAAI,SAAS;IAE5B,IAAI,cAAoD;IAExD,MAAM,gBAAgB,aAAqB;AACzC,SAAI,CAAC,SAAS,WAAW,SAAS,CAAE;AAEpC,sBAAiB;AACjB,qBAAgB;AAEhB,aAAQ,IACN,oCAAoC,SAAS,QAAQ,WAAW,KAAK,GAAG,GACzE;AAED,SAAI,YAAa,cAAa,YAAY;AAC1C,mBAAc,iBAAiB;AAC7B,oBAAc;AACd,aAAO,GAAG,KAAK;OAAE,MAAM;OAAe,MAAM;OAAK,CAAC;QACjD,YAAY;;AAGjB,WAAO,QAAQ,GAAG,UAAU,aAAa;AACzC,WAAO,QAAQ,GAAG,OAAO,aAAa;AACtC,WAAO,QAAQ,GAAG,UAAU,aAAa;;GAW3C,MAAM,qBAAqB,QACzB,IAAI,SAAS,oBAAoB,IACjC,IAAI,SAAS,mCAAmC;AAElD,UAAO,YAAY,KAEhB,KAAU,KAAU,SAAqB;IACxC,MAAM,MAA0B,IAAI;AAEpC,QAAI,CAAC,OAAO,CAAC,kBAAkB,IAAI,EAAE;AACnC,WAAM;AACN;;AAGF,KAAC,YAAY;AACX,SAAI;AACF,UAAI,CAAC,WAAW,UAAU,EAAE;AAG1B,aAAM;AACN;;AAGF,UAAI,kBAAkB,MAAM;AAC1B,WAAI,CAAC,cACH,iBAAgB,4BAA4B,UAAU,CACnD,MAAM,MAAM;AACX,yBAAiB,KAAK,UAAU,EAAE;AAClC,wBAAgB;AAChB,eAAO;SACP,CACD,OAAO,QAAQ;AACd,wBAAgB;AAChB,cAAM;SACN;AAEN,wBAAiB,MAAM;;AAGzB,UAAI,UAAU,KAAK;OACjB,gBAAgB;OAChB,qBAAqB;OACrB,iBAAiB;OAClB,CAAC;AACF,UAAI,IAAI,eAAe;cAChB,KAAK;AACZ,cAAQ,MAAM,+CAA+C,IAAI;AACjE,UAAI,UAAU,KAAK,EAAE,gBAAgB,oBAAoB,CAAC;AAC1D,UAAI,IACF,KAAK,UAAU,EACb,OACE,eAAe,QACX,IAAI,UACJ,6CACP,CAAC,CACH;;QAED;KAEP;;EAEJ"}