@parche/core 0.3.0-alpha.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parche/core",
3
- "version": "0.3.0-alpha.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/integration/index.ts",
@@ -1,5 +1,6 @@
1
1
  ---
2
- import { widgetMap } from 'parche:registry/widgets';
2
+ import { loadWidgets } from 'parche:registry/widgets';
3
+ import { fullBleedWidgets } from 'parche:config/layout';
3
4
  import SectionWrapper from './SectionWrapper.astro';
4
5
 
5
6
  interface WrapperConfig {
@@ -25,43 +26,116 @@ interface Props {
25
26
 
26
27
  const { sections, Wrapper } = Astro.props;
27
28
 
28
- // ---- Image resolution: ~/assets/images/foo.png → optimized import ----
29
- const imageModules = import.meta.glob<{ default: ImageMetadata }>(
29
+ // ---- Image resolution: @/assets/images/foo.png → optimized import ----
30
+ // Lazy glob: a loader exists for every image, but a module (and its build-time
31
+ // optimization) is only produced for the ones actually referenced. We collect
32
+ // just the paths these sections use, resolve them once, then substitute
33
+ // synchronously during render.
34
+ const imageLoaders = import.meta.glob<{ default: ImageMetadata }>(
30
35
  '/src/assets/images/**/*.{png,jpg,jpeg,gif,svg,webp,avif}',
31
- { eager: true }
36
+ );
37
+
38
+ /** Plain objects/arrays are the only things we recurse into — Dates, class
39
+ * instances, etc. are left untouched (and never hold image paths). */
40
+ function isPlainObject(v: any): boolean {
41
+ if (v === null || typeof v !== 'object') return false;
42
+ const proto = Object.getPrototypeOf(v);
43
+ return proto === Object.prototype || proto === null;
44
+ }
45
+
46
+ /** Gather every '@/assets/…' string used anywhere in the section props. */
47
+ function collectAssetPaths(value: any, acc: Set<string>): void {
48
+ if (typeof value === 'string') {
49
+ if (value.startsWith('@/assets/')) acc.add(value);
50
+ } else if (Array.isArray(value)) {
51
+ for (const v of value) collectAssetPaths(v, acc);
52
+ } else if (isPlainObject(value)) {
53
+ for (const v of Object.values(value)) collectAssetPaths(v, acc);
54
+ }
55
+ }
56
+
57
+ // Load only the widget components these sections reference (lazy catalog).
58
+ const widgets = await loadWidgets(sections.map((s) => s.widget));
59
+
60
+ const assetPaths = new Set<string>();
61
+ for (const section of sections) collectAssetPaths(section.props ?? {}, assetPaths);
62
+
63
+ const resolvedImages = new Map<string, string>();
64
+ await Promise.all(
65
+ [...assetPaths].map(async (src) => {
66
+ const loader = imageLoaders[src.replace('@/', '/src/')];
67
+ if (loader) {
68
+ const mod = await loader();
69
+ resolvedImages.set(src, mod.default.src);
70
+ }
71
+ }),
32
72
  );
33
73
 
34
74
  function resolveImage(src: string): string {
35
- if (!src || !src.startsWith('@/assets/')) return src;
36
- const fsPath = src.replace('@/', '/src/');
37
- const meta = imageModules[fsPath]?.default;
38
- return meta?.src ?? src;
75
+ return resolvedImages.get(src) ?? src;
39
76
  }
40
77
 
41
- /** Walk props and resolve any ~/assets/ image paths */
42
- function resolveProps(props: Record<string, any>): Record<string, any> {
43
- const resolved = { ...props };
44
- for (const [key, value] of Object.entries(resolved)) {
45
- if (typeof value === 'string' && value.startsWith('@/assets/')) {
46
- resolved[key] = resolveImage(value);
47
- } else if (value && typeof value === 'object' && !Array.isArray(value) && 'src' in value && typeof value.src === 'string' && value.src.startsWith('@/assets/')) {
48
- resolved[key] = { ...value, src: resolveImage(value.src) };
78
+ /** Substitute resolved image paths at any depth, reusing every subtree that
79
+ * didn't change so props with no images are passed through, not deep-cloned. */
80
+ function resolveDeep(value: any): any {
81
+ if (typeof value === 'string') return resolveImage(value);
82
+ if (Array.isArray(value)) {
83
+ let changed = false;
84
+ const out = value.map((v) => {
85
+ const r = resolveDeep(v);
86
+ if (r !== v) changed = true;
87
+ return r;
88
+ });
89
+ return changed ? out : value;
90
+ }
91
+ if (isPlainObject(value)) {
92
+ let changed = false;
93
+ const out: Record<string, any> = {};
94
+ for (const [k, v] of Object.entries(value)) {
95
+ const r = resolveDeep(v);
96
+ if (r !== v) changed = true;
97
+ out[k] = r;
49
98
  }
99
+ return changed ? out : value;
50
100
  }
51
- return resolved;
101
+ return value;
102
+ }
103
+
104
+ function resolveProps(props: Record<string, any>): Record<string, any> {
105
+ return resolveDeep(props);
52
106
  }
53
107
 
54
- // Widgets that render full-bleed and manage their own padding — no wrapper by default
55
- const noWrapperWidgets = new Set(['Hero', 'Hero2', 'HeroText', 'Announcement', 'Note']);
108
+ // Widgets that render full-bleed and manage their own padding — no wrapper by
109
+ // default. The list is contributed by the parches (manifest `fullBleed`), not
110
+ // hardcoded here; core knows no widget names. Match the key as registered, or
111
+ // its short form (a parche may register `hero/Hero` while declaring `Hero`).
112
+ const noWrapperWidgets = new Set(fullBleedWidgets);
56
113
  function widgetSkipsWrapper(name: string): boolean {
57
- const key = name.includes('/') ? name.split('/').pop()! : name;
58
- return noWrapperWidgets.has(key);
114
+ if (noWrapperWidgets.has(name)) return true;
115
+ const short = name.includes('/') ? name.split('/').pop()! : name;
116
+ return noWrapperWidgets.has(short);
59
117
  }
60
118
  ---
61
119
 
62
120
  {sections.map((section, index) => {
63
- const Widget = widgetMap[section.widget];
64
- if (!Widget) return null;
121
+ const Widget = widgets[section.widget];
122
+ if (!Widget) {
123
+ if (import.meta.env.DEV) {
124
+ console.warn(
125
+ `[parche] Unknown widget "${section.widget}" — not registered by any parche. ` +
126
+ `Check the widget name or that a parche provides it.`,
127
+ );
128
+ return (
129
+ <div
130
+ data-parche-missing-widget={section.widget}
131
+ style="margin:.5rem;padding:.75rem 1rem;border:2px dashed #dc2626;border-radius:.5rem;background:#fef2f2;color:#991b1b;font:14px/1.5 ui-monospace,SFMono-Regular,monospace"
132
+ >
133
+ ⚠ Parche: unknown widget <strong>{section.widget}</strong> — not registered by any parche.
134
+ </div>
135
+ );
136
+ }
137
+ return null;
138
+ }
65
139
 
66
140
  const props = resolveProps(section.props ?? {});
67
141
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { widgetMap } from 'parche:registry/widgets';
2
+ import { loadWidgets } from 'parche:registry/widgets';
3
3
  import DynamicRenderer from 'parche:DynamicRenderer';
4
4
 
5
5
  interface WrapperConfig {
@@ -37,6 +37,10 @@ const {
37
37
  SectionWrapper: PreviewWrapper,
38
38
  mainId,
39
39
  } = Astro.props;
40
+
41
+ // Load only the layout widgets this layout references ('layout/Main' is a
42
+ // synthetic marker with no loader — skipped). Lazy catalog.
43
+ const widgets = await loadWidgets(layoutSections.map((s) => s.widget));
40
44
  ---
41
45
 
42
46
  {layoutSections.map((section) => {
@@ -58,8 +62,24 @@ const {
58
62
  );
59
63
  }
60
64
 
61
- const Widget = widgetMap[section.widget];
62
- if (!Widget) return null;
65
+ const Widget = widgets[section.widget];
66
+ if (!Widget) {
67
+ if (import.meta.env.DEV) {
68
+ console.warn(
69
+ `[parche] Unknown layout widget "${section.widget}" — not registered by any parche. ` +
70
+ `Check the layout definition or the parche providing this widget.`,
71
+ );
72
+ return (
73
+ <div
74
+ data-parche-missing-widget={section.widget}
75
+ style="margin:.5rem;padding:.75rem 1rem;border:2px dashed #dc2626;border-radius:.5rem;background:#fef2f2;color:#991b1b;font:14px/1.5 ui-monospace,SFMono-Regular,monospace"
76
+ >
77
+ ⚠ Parche: unknown layout widget <strong>{section.widget}</strong> — not registered by any parche.
78
+ </div>
79
+ );
80
+ }
81
+ return null;
82
+ }
63
83
 
64
84
  return <Widget {...(section.props ?? {})} />;
65
85
  })}
@@ -1,8 +1,11 @@
1
1
  ---
2
2
  /**
3
3
  * Default section wrapper used by DynamicRenderer.
4
- * Provides: configurable HTML tag, background layer, isDark per-widget,
5
- * responsive container with padding, and class overrides via twMerge.
4
+ * Provides: configurable HTML tag, a raw-HTML background layer, isDark per-widget,
5
+ * a responsive container, and class overrides via twMerge.
6
+ *
7
+ * Section rhythm/backgrounds are authored as raw HTML via `bg` in page content —
8
+ * a gradient div, an inline SVG (dots/grid), a glow, whatever the section needs.
6
9
  */
7
10
  import type { HTMLTag } from 'astro/types';
8
11
  import { twMerge } from 'tailwind-merge';
@@ -10,6 +13,7 @@ import { twMerge } from 'tailwind-merge';
10
13
  interface Props {
11
14
  id?: string;
12
15
  isDark?: boolean;
16
+ /** Raw HTML background layer (a div, gradient, inline SVG, pattern…). */
13
17
  bg?: string;
14
18
  classes?: Record<string, unknown>;
15
19
  as?: string;
@@ -23,21 +27,18 @@ const containerClass = (classes.container as string) ?? '';
23
27
 
24
28
  <Tag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}>
25
29
  {bg && (
26
- <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
30
+ <div class="absolute inset-0 pointer-events-none -z-[1] overflow-hidden" aria-hidden="true">
27
31
  <Fragment set:html={bg} />
28
32
  </div>
29
33
  )}
30
34
  {isDark && !bg && (
31
35
  <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
32
- <div class="absolute inset-0 bg-neutral-900 dark:bg-transparent" />
36
+ <div class="absolute inset-0 bg-neutral-900 dark:bg-transparent"></div>
33
37
  </div>
34
38
  )}
35
39
  <div
36
40
  class:list={[
37
- twMerge(
38
- 'relative mx-auto max-w-7xl px-4 md:px-6 py-12 md:py-16 lg:py-20',
39
- containerClass,
40
- ),
41
+ twMerge('relative mx-auto max-w-7xl px-4 md:px-6 py-12 md:py-16 lg:py-20', containerClass),
41
42
  { dark: isDark },
42
43
  ]}
43
44
  >
@@ -2,19 +2,176 @@ import type { AstroIntegration } from 'astro';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs';
5
+ import { z } from 'zod';
5
6
  import { vitePluginParche } from './vite-plugin-parche.js';
6
7
  import { createRegistry } from './registry.js';
7
- import type { ParcheUserConfig, UIRegistry, ParcheApp, ParcheManifest, ParcheRequires } from './types.js';
8
+ import { siteConfigSchema, type SiteConfig } from '../types/config.js';
9
+ import type { ParcheUserConfig, ParchePreset, ParcheSeoConfig, UIRegistry, ParcheApp, ParcheManifest, ParcheRequires } from './types.js';
8
10
 
9
- export default function parche(userConfig: ParcheUserConfig = {}): AstroIntegration {
11
+ // ---------------------------------------------------------------------------
12
+ // Preset composition (`extends`)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
16
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
17
+ }
18
+
19
+ /**
20
+ * Deep-merge two config fragments. Objects merge recursively; primitives and
21
+ * arrays are replaced by the override (last wins) — except `parches`, which the
22
+ * caller concatenates. `undefined` on the override never clobbers the base.
23
+ */
24
+ function deepMerge<T>(base: T, over: T): T {
25
+ if (over === undefined) return base;
26
+ if (isPlainObject(base) && isPlainObject(over)) {
27
+ const out: Record<string, unknown> = { ...base };
28
+ for (const key of Object.keys(over)) {
29
+ out[key] = deepMerge((base as Record<string, unknown>)[key], (over as Record<string, unknown>)[key]);
30
+ }
31
+ return out as T;
32
+ }
33
+ return over;
34
+ }
35
+
36
+ /** Merge an override config onto a base, concatenating `parches` (base first). */
37
+ function mergeConfig(base: ParchePreset, over: ParchePreset): ParchePreset {
38
+ const merged = deepMerge(base, over);
39
+ const baseParches = base.parches ?? [];
40
+ const overParches = over.parches ?? [];
41
+ if (baseParches.length || overParches.length) {
42
+ merged.parches = [...baseParches, ...overParches];
43
+ }
44
+ return merged;
45
+ }
46
+
47
+ /**
48
+ * Identity helper that types and freezes a reusable config fragment for
49
+ * `extends`. Authoring a preset through it gets you inference and a clear
50
+ * boundary; it does no work beyond returning the object.
51
+ */
52
+ export function parchePreset(preset: ParchePreset): ParchePreset {
53
+ return preset;
54
+ }
55
+
56
+ /** Fold a config's `extends` chain into a single flat config (presets first). */
57
+ export function resolveExtends(userConfig: ParcheUserConfig): ParcheUserConfig {
58
+ if (!userConfig.extends) return userConfig;
59
+ const presets = Array.isArray(userConfig.extends) ? userConfig.extends : [userConfig.extends];
60
+ let base: ParchePreset = {};
61
+ for (const preset of presets) base = mergeConfig(base, preset);
62
+ const { extends: _drop, ...rest } = userConfig;
63
+ return mergeConfig(base, rest) as ParcheUserConfig;
64
+ }
65
+
66
+ // Shape validation for the `parche({ ... })` options. `.strict()` turns a typo'd
67
+ // option name (e.g. `parchez`) or a malformed value into a friendly error at
68
+ // config time, instead of a deep, cryptic Vite failure later. The `parches`
69
+ // array holds manifests (validated structurally by the registry), so it's `any`.
70
+ const userConfigSchema = z
71
+ .object({
72
+ overrides: z.record(z.string(), z.string()).optional(),
73
+ config: z.string().optional(),
74
+ parches: z.array(z.any()).optional(),
75
+ routes: z
76
+ .object({
77
+ pages: z.boolean().optional(),
78
+ templates: z.record(z.string(), z.string()).optional(),
79
+ layouts: z.record(z.string(), z.string()).optional(),
80
+ catchAllRoute: z.string().optional(),
81
+ notFoundRoute: z.string().optional(),
82
+ middleware: z.string().optional(),
83
+ })
84
+ .strict()
85
+ .optional(),
86
+ themes: z
87
+ .object({
88
+ available: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
89
+ showPanel: z.boolean().optional(),
90
+ })
91
+ .strict()
92
+ .optional(),
93
+ styles: z.object({ entry: z.string().optional() }).strict().optional(),
94
+ seo: z.object({ allowAICrawlers: z.boolean().optional() }).strict().optional(),
95
+ })
96
+ .strict();
97
+
98
+ function validateUserConfig(userConfig: ParcheUserConfig): void {
99
+ const result = userConfigSchema.safeParse(userConfig);
100
+ if (!result.success) {
101
+ const issues = result.error.issues
102
+ .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
103
+ .join('\n');
104
+ throw new Error(`[parche] Invalid parche() options:\n${issues}`);
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Config resolution
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** Runtime context passed to the function form of `parche()`. */
113
+ export interface ParcheConfigContext {
114
+ /** Astro command driving this run. */
115
+ command: 'dev' | 'build' | 'preview' | 'sync';
116
+ /** Convenience alias: 'development' for dev, 'production' otherwise. */
117
+ mode: 'development' | 'production';
118
+ /** Environment variables, for env-based / white-label branching. */
119
+ env: Record<string, string | undefined>;
120
+ /** Tenant id from PARCHE_TENANT (multi-tenant / white-label), if set. */
121
+ tenant: string | undefined;
122
+ }
123
+
124
+ type SiteConfigInput = Parameters<typeof siteConfigSchema.parse>[0];
125
+
126
+ /**
127
+ * The Parche config object: integration options (parches, routes, themes,
128
+ * styles, overrides, extends) plus, optionally, the site identity inline. Omit
129
+ * the site fields and point `config` at a separate file instead — both styles
130
+ * are equally supported. `seo` carries the site SEO fields and the build-time
131
+ * `allowAICrawlers`.
132
+ */
133
+ export type ParcheConfig = Omit<ParcheUserConfig, 'seo'> &
134
+ Partial<Omit<SiteConfigInput, 'seo'>> & {
135
+ seo?: (SiteConfigInput extends { seo?: infer S } ? S : never) & ParcheSeoConfig;
136
+ };
137
+
138
+ /** `parche()` accepts a config object or a function of the runtime context. */
139
+ export type ParcheConfigInput = ParcheConfig | ((ctx: ParcheConfigContext) => ParcheConfig);
140
+
141
+ interface PreparedConfig {
142
+ /** Integration options only (site data stripped), extends already folded. */
143
+ userConfig: ParcheUserConfig;
144
+ /** Inline site config, when the site identity was given inline. */
145
+ inlineSiteConfig?: SiteConfig;
146
+ /** robots.txt AI-crawler policy. */
147
+ allowAICrawlers: boolean;
148
+ }
149
+
150
+ /**
151
+ * Shared integration body. `prepare` turns the runtime context into the resolved
152
+ * options; everything downstream (registry, route injection, robots.txt) is
153
+ * identical regardless of how the config was authored.
154
+ */
155
+ function createIntegration(prepare: (ctx: ParcheConfigContext) => PreparedConfig): AstroIntegration {
10
156
  let resolvedSiteUrl = '';
157
+ let allowAICrawlers = true;
11
158
  return {
12
159
  name: 'parche',
13
160
  hooks: {
14
- 'astro:config:setup': ({ updateConfig, config, injectRoute, addMiddleware }) => {
161
+ 'astro:config:setup': ({ command, updateConfig, config, injectRoute, addMiddleware }) => {
162
+ const ctx: ParcheConfigContext = {
163
+ command,
164
+ mode: command === 'dev' ? 'development' : 'production',
165
+ env: process.env,
166
+ tenant: process.env.PARCHE_TENANT,
167
+ };
168
+ const prepared = prepare(ctx);
169
+ const resolved = prepared.userConfig;
170
+ allowAICrawlers = prepared.allowAICrawlers;
171
+ validateUserConfig(resolved);
15
172
  resolvedSiteUrl = config.site ?? '';
16
173
  const rootDir = fileURLToPath(config.root);
17
- const resolvedRegistry = createRegistry(userConfig, rootDir, config.i18n);
174
+ const resolvedRegistry = createRegistry(resolved, rootDir, config.i18n, prepared.inlineSiteConfig);
18
175
 
19
176
  // Resolve @core/* alias for backward compatibility with widget internal imports
20
177
  const coreDir = path.resolve(
@@ -25,24 +182,24 @@ export default function parche(userConfig: ParcheUserConfig = {}): AstroIntegrat
25
182
  const routesDir = path.resolve(coreDir, 'routes');
26
183
 
27
184
  // Inject routes only when explicitly enabled via routes.pages: true
28
- if (userConfig.routes?.pages) {
185
+ if (resolved.routes?.pages) {
29
186
  // Catch-all page route
30
187
  injectRoute({
31
188
  pattern: '[...slug]',
32
- entrypoint: userConfig.routes?.catchAllRoute
189
+ entrypoint: resolved.routes?.catchAllRoute
33
190
  ?? path.resolve(routesDir, '[...slug].astro'),
34
191
  });
35
192
 
36
193
  // 404 page
37
194
  injectRoute({
38
195
  pattern: '404',
39
- entrypoint: userConfig.routes?.notFoundRoute
196
+ entrypoint: resolved.routes?.notFoundRoute
40
197
  ?? path.resolve(routesDir, '404.astro'),
41
198
  });
42
199
 
43
200
  // Middleware for i18n locale resolution
44
201
  addMiddleware({
45
- entrypoint: userConfig.routes?.middleware
202
+ entrypoint: resolved.routes?.middleware
46
203
  ?? path.resolve(routesDir, 'middleware.ts'),
47
204
  order: 'pre',
48
205
  });
@@ -78,13 +235,71 @@ export default function parche(userConfig: ParcheUserConfig = {}): AstroIntegrat
78
235
  },
79
236
 
80
237
  'astro:build:done': ({ dir }) => {
81
- const allowAICrawlers = userConfig.seo?.allowAICrawlers ?? true;
82
238
  processRobotsTxt(dir, resolvedSiteUrl, allowAICrawlers);
83
239
  },
84
240
  },
85
241
  };
86
242
  }
87
243
 
244
+ /**
245
+ * The Parche Astro integration. One entry, two equally supported styles for the
246
+ * site identity:
247
+ *
248
+ * • Inline — pass `site` (and optionally metadata/seo/organization) right here.
249
+ * It's validated and served as `parche:config`; no separate file needed.
250
+ * • Separate file — omit `site` and point `config` at a file (default
251
+ * `./parche.config.ts` at the project root). The parches stay in
252
+ * astro.config; everything else lives in that file (authored with
253
+ * `defineConfig` from `@parche/core/config`).
254
+ *
255
+ * The argument may also be a function of the runtime context
256
+ * (`(ctx) => config`) for env-based / conditional / multi-tenant setups, and any
257
+ * config may `extends` a `parchePreset(...)`.
258
+ *
259
+ * @example
260
+ * // astro.config.mjs
261
+ * import parche from '@parche/core';
262
+ * export default defineConfig({
263
+ * integrations: [parche({ parches: [createUI()], config: './parche.config.ts', routes: { pages: true } })],
264
+ * });
265
+ */
266
+ export default function parche(input: ParcheConfigInput = {}): AstroIntegration {
267
+ return createIntegration((ctx) => prepareParcheConfig(input, ctx));
268
+ }
269
+
270
+ /**
271
+ * Resolve a config input (object or function) into the pieces the integration
272
+ * needs: the integration options, an optional inline site config, and the
273
+ * robots policy. Pure — exported for testing. Folds `extends`, splits the site
274
+ * identity out of the options, and picks inline vs separate-file mode.
275
+ */
276
+ export function prepareParcheConfig(
277
+ input: ParcheConfigInput,
278
+ ctx: ParcheConfigContext,
279
+ ): PreparedConfig {
280
+ const cfg = typeof input === 'function' ? input(ctx) : input;
281
+ // Fold `extends` first (a preset may seed site data or parches), then split
282
+ // the site identity out of the integration options.
283
+ const merged = resolveExtends(cfg as unknown as ParcheUserConfig) as unknown as ParcheConfig;
284
+ const { site, metadata, seo, organization, config: configPath, ...rest } =
285
+ merged as ParcheConfig & { config?: string };
286
+ const userOpts = rest as ParcheUserConfig;
287
+ const { allowAICrawlers = true, ...siteSeo } = (seo ?? {}) as Record<string, unknown>;
288
+
289
+ if (site) {
290
+ // Inline mode: validate + serve the site identity as parche:config.
291
+ const inlineSiteConfig = siteConfigSchema.parse({ site, metadata, seo: siteSeo, organization });
292
+ return { userConfig: userOpts, inlineSiteConfig, allowAICrawlers: allowAICrawlers as boolean };
293
+ }
294
+
295
+ // Separate-file mode: site identity comes from `config` (or the default
296
+ // ./parche.config.ts). Only the robots policy is read from seo here.
297
+ return {
298
+ userConfig: { ...userOpts, config: configPath },
299
+ allowAICrawlers: allowAICrawlers as boolean,
300
+ };
301
+ }
302
+
88
303
  const ROBOTS_MARKER = '# === PARCHE:AUTO-GENERATED';
89
304
 
90
305
  function processRobotsTxt(outDir: URL, siteUrl: string, allowAICrawlers: boolean) {
@@ -153,4 +368,4 @@ function buildAutoGeneratedRobots(siteUrl: string, allowAICrawlers: boolean): st
153
368
  return lines.join('\n');
154
369
  }
155
370
 
156
- export type { ParcheUserConfig, UIRegistry, ParcheApp, ParcheManifest, ParcheRequires };
371
+ export type { ParcheUserConfig, ParchePreset, UIRegistry, ParcheApp, ParcheManifest, ParcheRequires };