@olonjs/cli 3.0.127 → 3.0.129

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.
@@ -1597,6 +1597,37 @@ If user says "use dark theme" or "change my theme":
1597
1597
  4. Write the updated JSON back to the file
1598
1598
  5. Inform the user the setting has been changed and whether a reload is needed
1599
1599
 
1600
+ END_OF_FILE_CONTENT
1601
+ echo "Creating components.json..."
1602
+ cat << 'END_OF_FILE_CONTENT' > "components.json"
1603
+ {
1604
+ "$schema": "https://ui.shadcn.com/schema.json",
1605
+ "style": "radix-nova",
1606
+ "rsc": false,
1607
+ "tsx": true,
1608
+ "tailwind": {
1609
+ "config": "",
1610
+ "css": "src/index.css",
1611
+ "baseColor": "zinc",
1612
+ "cssVariables": true,
1613
+ "prefix": ""
1614
+ },
1615
+ "iconLibrary": "lucide",
1616
+ "rtl": false,
1617
+ "aliases": {
1618
+ "components": "@/components",
1619
+ "utils": "@/lib/utils",
1620
+ "ui": "@/components/ui",
1621
+ "lib": "@/lib",
1622
+ "hooks": "@/hooks"
1623
+ },
1624
+ "menuColor": "default",
1625
+ "menuAccent": "bold",
1626
+ "registries": {}
1627
+ }
1628
+
1629
+
1630
+
1600
1631
  END_OF_FILE_CONTENT
1601
1632
  echo "Creating index.html..."
1602
1633
  cat << 'END_OF_FILE_CONTENT' > "index.html"
@@ -1632,7 +1663,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1632
1663
  "verify:webmcp": "node scripts/webmcp-feature-check.mjs",
1633
1664
  "prebuild": "node scripts/sync-pages-to-public.mjs && node scripts/generate-llms-txt.mjs",
1634
1665
  "build": "tsc && vite build",
1635
- "dist": "bash ./src2Code.sh --template alpha src .cursor vercel.json index.html tsconfig.json tsconfig.node.json vite.config.ts scripts ../../specs package.json public/assets/images/plug-graded-square.jpg",
1666
+ "dist": "bash ./src2Code.sh --template alpha src .cursor vercel.json components.json index.html tsconfig.json package.json tsconfig.node.json vite.config.ts scripts ",
1636
1667
  "preview": "vite preview",
1637
1668
  "bake:email": "tsx scripts/bake-email.tsx",
1638
1669
  "bakemail": "npm run bake:email --",
@@ -1643,7 +1674,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1643
1674
  "@tiptap/extension-link": "^2.11.5",
1644
1675
  "@tiptap/react": "^2.11.5",
1645
1676
  "@tiptap/starter-kit": "^2.11.5",
1646
- "@olonjs/core": "^1.0.116",
1677
+ "@olonjs/core": "^1.0.120",
1647
1678
  "class-variance-authority": "^0.7.1",
1648
1679
  "clsx": "^2.1.1",
1649
1680
  "lucide-react": "^0.474.0",
@@ -1672,7 +1703,6 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1672
1703
  }
1673
1704
 
1674
1705
  END_OF_FILE_CONTENT
1675
- # SKIP: public/assets/images/plug-graded-square.jpg is binary and cannot be embedded as text.
1676
1706
  mkdir -p "scripts"
1677
1707
  echo "Creating scripts/bake-email.tsx..."
1678
1708
  cat << 'END_OF_FILE_CONTENT' > "scripts/bake-email.tsx"
@@ -2064,7 +2094,7 @@ if (targets.length === 0) {
2064
2094
  console.log(`[bake] Targets: ${targets.map((t) => t.slug).join(', ')}`);
2065
2095
 
2066
2096
  const ssrEntryUrl = pathToFileURL(path.resolve(root, 'dist-ssr/entry-ssg.js')).href;
2067
- const { render, getCss, getPageMeta, getWebMcpBuildState } = await import(ssrEntryUrl);
2097
+ const { render, getCss, getRemoteStylesheets, getPageMeta, getWebMcpBuildState } = await import(ssrEntryUrl);
2068
2098
 
2069
2099
  const template = await fs.readFile(path.resolve(root, 'dist/index.html'), 'utf-8');
2070
2100
  const hasCommentMarker = template.includes('<!--app-html-->');
@@ -2075,6 +2105,9 @@ if (!hasCommentMarker && !hasRootDivMarker) {
2075
2105
 
2076
2106
  const inlinedCss = getCss();
2077
2107
  const styleTag = `<style data-bake="inline">${inlinedCss}</style>`;
2108
+ const remoteStylesheetTags = getRemoteStylesheets()
2109
+ .map((href) => `<link rel="stylesheet" href="${escapeHtmlAttribute(href)}">`)
2110
+ .join('\n ');
2078
2111
  const webMcpBuildState = getWebMcpBuildState();
2079
2112
 
2080
2113
  for (const { slug } of targets) {
@@ -2148,7 +2181,7 @@ for (const { slug, out, depth } of targets) {
2148
2181
  ].join('\n ');
2149
2182
 
2150
2183
  let bakedHtml = fixedTemplate
2151
- .replace('</head>', ` ${styleTag}\n ${contractLinks}\n</head>`)
2184
+ .replace('</head>', ` ${remoteStylesheetTags ? `${remoteStylesheetTags}\n ` : ''}${styleTag}\n ${contractLinks}\n</head>`)
2152
2185
  .replace(/<title>.*?<\/title>/, `<title>${safeTitle}</title>\n ${metaTags}`);
2153
2186
 
2154
2187
  if (hasCommentMarker) {
@@ -2569,6 +2602,9 @@ import { DopaDrawer } from '@/components/save-drawer/DopaDrawer';
2569
2602
  import { EmptyTenantView } from '@/components/empty-tenant';
2570
2603
  import { Skeleton } from '@/components/ui/skeleton';
2571
2604
  import { ThemeProvider } from '@/components/ThemeProvider';
2605
+ import { useOlonForms } from '@/lib/useOlonForms';
2606
+ import { OlonFormsContext } from '@/lib/OlonFormsContext';
2607
+ import { iconMap } from '@/lib/IconResolver';
2572
2608
 
2573
2609
  import tenantCss from './index.css?inline';
2574
2610
 
@@ -2917,6 +2953,42 @@ function buildThemeFontVarsCss(input: unknown): string {
2917
2953
  return `:root{--theme-font-primary:${primary};--theme-font-serif:${serif};--theme-font-mono:${mono};}`;
2918
2954
  }
2919
2955
 
2956
+ const REMOTE_CSS_LINK_ATTR = 'data-jp-tenant-remote-css';
2957
+
2958
+ function isRemoteStylesheetHref(value: string): boolean {
2959
+ return /^https?:\/\//i.test(value.trim());
2960
+ }
2961
+
2962
+ function extractLeadingRemoteCssImports(cssText: string): { hrefs: string[]; rest: string } {
2963
+ const hrefs = new Set<string>();
2964
+ const leadingTriviaPattern = /^(?:\s+|\/\*[\s\S]*?\*\/)*/;
2965
+ const importPattern =
2966
+ /^@import\s+url\(\s*(?:'([^']+)'|"([^"]+)"|([^'")\s][^)]*))\s*\)\s*([^;]*);/i;
2967
+ let rest = cssText;
2968
+
2969
+ for (;;) {
2970
+ const trivia = rest.match(leadingTriviaPattern);
2971
+ if (trivia && trivia[0]) {
2972
+ rest = rest.slice(trivia[0].length);
2973
+ }
2974
+
2975
+ const match = rest.match(importPattern);
2976
+ if (!match) break;
2977
+
2978
+ const href = (match[1] ?? match[2] ?? match[3] ?? '').trim();
2979
+ const trailingDirectives = (match[4] ?? '').trim();
2980
+
2981
+ if (!isRemoteStylesheetHref(href) || trailingDirectives.length > 0) {
2982
+ break;
2983
+ }
2984
+
2985
+ hrefs.add(href);
2986
+ rest = rest.slice(match[0].length);
2987
+ }
2988
+
2989
+ return { hrefs: Array.from(hrefs), rest };
2990
+ }
2991
+
2920
2992
  function setTenantPreviewReady(ready: boolean): void {
2921
2993
  if (typeof window !== 'undefined') {
2922
2994
  (window as Window & { __TENANT_PREVIEW_READY__?: boolean }).__TENANT_PREVIEW_READY__ = ready;
@@ -2927,6 +2999,7 @@ function setTenantPreviewReady(ready: boolean): void {
2927
2999
  }
2928
3000
 
2929
3001
  function App() {
3002
+ const { states: formStates } = useOlonForms();
2930
3003
  const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
2931
3004
  const isSave2RepoMode = isCloudMode && SAVE2REPO_ENABLED;
2932
3005
  const isHotSaveMode = isCloudMode && !isSave2RepoMode;
@@ -3331,6 +3404,39 @@ function App() {
3331
3404
  void runCloudSave(pendingCloudSave.current, false);
3332
3405
  }, [runCloudSave]);
3333
3406
 
3407
+ const tenantCssParts = useMemo(() => extractLeadingRemoteCssImports(tenantCss), [tenantCss]);
3408
+ const resolvedTenantCss = useMemo(
3409
+ () => [buildThemeFontVarsCss(themeConfig), tenantCssParts.rest].filter(Boolean).join('\n'),
3410
+ [tenantCssParts],
3411
+ );
3412
+
3413
+ useEffect(() => {
3414
+ if (typeof document === 'undefined') return undefined;
3415
+
3416
+ const createdLinks: HTMLLinkElement[] = [];
3417
+
3418
+ tenantCssParts.hrefs.forEach((href) => {
3419
+ const existing = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find(
3420
+ (link) => (link as HTMLLinkElement).href === href,
3421
+ ) as HTMLLinkElement | undefined;
3422
+ if (existing) return;
3423
+
3424
+ const link = document.createElement('link');
3425
+ link.rel = 'stylesheet';
3426
+ link.href = href;
3427
+ link.setAttribute(REMOTE_CSS_LINK_ATTR, href);
3428
+ document.head.appendChild(link);
3429
+ createdLinks.push(link);
3430
+ });
3431
+
3432
+ return () => {
3433
+ createdLinks.forEach((link) => {
3434
+ if (link.getAttribute(REMOTE_CSS_LINK_ATTR) !== link.href) return;
3435
+ if (link.parentNode) link.parentNode.removeChild(link);
3436
+ });
3437
+ };
3438
+ }, [tenantCssParts]);
3439
+
3334
3440
  const config: JsonPagesConfig = {
3335
3441
  tenantId: TENANT_ID,
3336
3442
  basePath: APP_BASE_PATH,
@@ -3341,7 +3447,8 @@ function App() {
3341
3447
  themeConfig,
3342
3448
  menuConfig,
3343
3449
  refDocuments,
3344
- themeCss: { tenant: `${buildThemeFontVarsCss(themeConfig)}\n${tenantCss}` },
3450
+ iconRegistry: iconMap,
3451
+ themeCss: { tenant: resolvedTenantCss },
3345
3452
  addSection: addSectionConfig,
3346
3453
  webmcp: {
3347
3454
  enabled: true,
@@ -3503,6 +3610,7 @@ function App() {
3503
3610
 
3504
3611
  return (
3505
3612
  <ThemeProvider>
3613
+ <OlonFormsContext.Provider value={formStates}>
3506
3614
  <>
3507
3615
  {isCloudMode && showTopProgress ? (
3508
3616
  <>
@@ -3624,6 +3732,7 @@ function App() {
3624
3732
  onRetry={retryCloudSave}
3625
3733
  />
3626
3734
  </>
3735
+ </OlonFormsContext.Provider>
3627
3736
  </ThemeProvider>
3628
3737
  );
3629
3738
  }
@@ -3634,91 +3743,90 @@ END_OF_FILE_CONTENT
3634
3743
  mkdir -p "src/components"
3635
3744
  echo "Creating src/components/ThemeProvider.tsx..."
3636
3745
  cat << 'END_OF_FILE_CONTENT' > "src/components/ThemeProvider.tsx"
3637
- import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
3638
-
3639
- type Theme = 'dark' | 'light'
3640
-
3641
- interface ThemeContextValue {
3642
- theme: Theme
3643
- toggleTheme: () => void
3644
- setTheme: (t: Theme) => void
3645
- }
3646
-
3647
- const ThemeContext = createContext<ThemeContextValue>({
3648
- theme: 'dark',
3649
- toggleTheme: () => {},
3650
- setTheme: () => {},
3651
- })
3652
-
3653
- const STORAGE_KEY = 'olon:theme'
3654
-
3655
- function isTheme(value: unknown): value is Theme {
3656
- return value === 'dark' || value === 'light'
3657
- }
3658
-
3659
- function resolveInitialTheme(): Theme {
3660
- if (typeof window === 'undefined') return 'dark'
3661
-
3662
- const fromDom = document.documentElement.getAttribute('data-theme')
3663
- if (isTheme(fromDom)) return fromDom
3664
-
3665
- const fromStorage = window.localStorage.getItem(STORAGE_KEY)
3666
- if (isTheme(fromStorage)) return fromStorage
3667
-
3668
- const prefersLight = window.matchMedia?.('(prefers-color-scheme: light)').matches
3669
- return prefersLight ? 'light' : 'dark'
3670
- }
3671
-
3672
- export function ThemeProvider({ children }: { children: ReactNode }) {
3673
- const [theme, setThemeState] = useState<Theme>(resolveInitialTheme)
3674
-
3675
- useEffect(() => {
3676
- document.documentElement.setAttribute('data-theme', theme)
3677
- window.localStorage.setItem(STORAGE_KEY, theme)
3678
- }, [theme])
3679
-
3680
- function setTheme(t: Theme) {
3681
- setThemeState(t)
3682
- }
3683
-
3684
- function toggleTheme() {
3685
- setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'))
3686
- }
3687
-
3688
- const value = useMemo(() => ({ theme, toggleTheme, setTheme }), [theme])
3689
-
3690
- return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
3691
- }
3692
-
3693
- export function useTheme() {
3694
- return useContext(ThemeContext)
3695
- }
3696
-
3697
- END_OF_FILE_CONTENT
3698
- mkdir -p "src/components/empty-tenant"
3699
- echo "Creating src/components/empty-tenant/View.tsx..."
3700
- cat << 'END_OF_FILE_CONTENT' > "src/components/empty-tenant/View.tsx"
3701
- import type { EmptyTenantData } from './types';
3746
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
3702
3747
 
3703
- type EmptyTenantViewProps = {
3704
- data?: EmptyTenantData;
3705
- };
3748
+ type Theme = 'dark' | 'light'
3706
3749
 
3707
- export function EmptyTenantView({ data }: EmptyTenantViewProps) {
3708
- const title = data?.title?.trim() || 'Your tenant is empty.';
3709
- const description = data?.description?.trim() || 'Create your first page to start building your site.';
3750
+ interface ThemeContextValue {
3751
+ theme: Theme
3752
+ toggleTheme: () => void
3753
+ setTheme: (t: Theme) => void
3754
+ }
3710
3755
 
3756
+ const ThemeContext = createContext<ThemeContextValue>({
3757
+ theme: 'dark',
3758
+ toggleTheme: () => {},
3759
+ setTheme: () => {},
3760
+ })
3711
3761
 
3712
- return (
3713
- <main className="min-h-screen flex items-center justify-center bg-background text-foreground px-6">
3714
- <section className="w-full max-w-xl rounded-xl border border-border bg-card p-8 shadow-sm">
3715
- <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
3716
- <p className="mt-3 text-sm text-muted-foreground">{description}</p>
3717
- </section>
3718
- </main>
3719
- );
3762
+ const STORAGE_KEY = 'olon:theme'
3763
+
3764
+ function isTheme(value: unknown): value is Theme {
3765
+ return value === 'dark' || value === 'light'
3766
+ }
3767
+
3768
+ function resolveInitialTheme(): Theme {
3769
+ if (typeof window === 'undefined') return 'dark'
3770
+
3771
+ const fromDom = document.documentElement.getAttribute('data-theme')
3772
+ if (isTheme(fromDom)) return fromDom
3773
+
3774
+ const fromStorage = window.localStorage.getItem(STORAGE_KEY)
3775
+ if (isTheme(fromStorage)) return fromStorage
3776
+
3777
+ const prefersLight = window.matchMedia?.('(prefers-color-scheme: light)').matches
3778
+ return prefersLight ? 'light' : 'dark'
3779
+ }
3780
+
3781
+ export function ThemeProvider({ children }: { children: ReactNode }) {
3782
+ const [theme, setThemeState] = useState<Theme>(resolveInitialTheme)
3783
+
3784
+ useEffect(() => {
3785
+ document.documentElement.setAttribute('data-theme', theme)
3786
+ window.localStorage.setItem(STORAGE_KEY, theme)
3787
+ }, [theme])
3788
+
3789
+ function setTheme(t: Theme) {
3790
+ setThemeState(t)
3791
+ }
3792
+
3793
+ function toggleTheme() {
3794
+ setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'))
3795
+ }
3796
+
3797
+ const value = useMemo(() => ({ theme, toggleTheme, setTheme }), [theme])
3798
+
3799
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
3800
+ }
3801
+
3802
+ export function useTheme() {
3803
+ return useContext(ThemeContext)
3720
3804
  }
3721
3805
 
3806
+ END_OF_FILE_CONTENT
3807
+ mkdir -p "src/components/empty-tenant"
3808
+ echo "Creating src/components/empty-tenant/View.tsx..."
3809
+ cat << 'END_OF_FILE_CONTENT' > "src/components/empty-tenant/View.tsx"
3810
+ import type { EmptyTenantData } from './types';
3811
+
3812
+ type EmptyTenantViewProps = {
3813
+ data?: EmptyTenantData;
3814
+ };
3815
+
3816
+ export function EmptyTenantView({ data }: EmptyTenantViewProps) {
3817
+ const title = data?.title?.trim() || 'Your tenant is empty.';
3818
+ const description = data?.description?.trim() || 'Create your first page to start building your site.';
3819
+
3820
+ return (
3821
+ <main className="min-h-screen flex items-center justify-center bg-background text-foreground px-6">
3822
+ <section className="w-full max-w-xl rounded-xl border border-border bg-card p-8 shadow-sm">
3823
+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
3824
+ <p className="mt-3 text-sm text-muted-foreground">{description}</p>
3825
+ </section>
3826
+ </main>
3827
+ );
3828
+ }
3829
+
3722
3830
  END_OF_FILE_CONTENT
3723
3831
  echo "Creating src/components/empty-tenant/index.ts..."
3724
3832
  cat << 'END_OF_FILE_CONTENT' > "src/components/empty-tenant/index.ts"
@@ -3735,7 +3843,6 @@ import { BaseSectionData } from '@/lib/base-schemas';
3735
3843
  export const EmptyTenantSchema = BaseSectionData.extend({
3736
3844
  title: z.string().optional().describe('ui:text'),
3737
3845
  description: z.string().optional().describe('ui:textarea'),
3738
-
3739
3846
  });
3740
3847
 
3741
3848
  export const EmptyTenantSettingsSchema = z.object({});
@@ -3749,6 +3856,196 @@ import { EmptyTenantSchema, EmptyTenantSettingsSchema } from './schema';
3749
3856
  export type EmptyTenantData = z.infer<typeof EmptyTenantSchema>;
3750
3857
  export type EmptyTenantSettings = z.infer<typeof EmptyTenantSettingsSchema>;
3751
3858
 
3859
+ END_OF_FILE_CONTENT
3860
+ mkdir -p "src/components/form-demo"
3861
+ echo "Creating src/components/form-demo/View.tsx..."
3862
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/View.tsx"
3863
+ import { Icon } from '@/lib/IconResolver';
3864
+ import { useFormState } from '@/lib/OlonFormsContext';
3865
+ import type { FormDemoData } from './types';
3866
+
3867
+ type FormDemoViewProps = {
3868
+ data: FormDemoData;
3869
+ };
3870
+
3871
+ const missingEnv =
3872
+ !import.meta.env.VITE_JSONPAGES_CLOUD_URL &&
3873
+ !import.meta.env.VITE_OLONJS_CLOUD_URL;
3874
+
3875
+ function SetupGuide({ recipientEmail }: { recipientEmail?: string }) {
3876
+ const steps = [
3877
+ {
3878
+ done: !!recipientEmail,
3879
+ label: 'recipientEmail nel JSON della sezione',
3880
+ code: '"recipientEmail": "tu@esempio.it"',
3881
+ },
3882
+ {
3883
+ done: !missingEnv,
3884
+ label: 'VITE_JSONPAGES_CLOUD_URL nel file .env',
3885
+ code: 'VITE_JSONPAGES_CLOUD_URL=https://cloud.olonjs.io',
3886
+ },
3887
+ {
3888
+ done: !!import.meta.env.VITE_JSONPAGES_API_KEY || !!import.meta.env.VITE_OLONJS_API_KEY,
3889
+ label: 'VITE_JSONPAGES_API_KEY nel file .env',
3890
+ code: 'VITE_JSONPAGES_API_KEY=sk-...',
3891
+ },
3892
+ ];
3893
+
3894
+ const allDone = steps.every((s) => s.done);
3895
+ if (allDone) return null;
3896
+
3897
+ return (
3898
+ <div className="rounded-lg border border-border bg-muted/40 p-4 space-y-3 text-sm">
3899
+ <p className="font-medium text-foreground">Quasi pronto — completa questi passaggi</p>
3900
+ <ol className="space-y-2">
3901
+ {steps.map((step, i) => (
3902
+ <li key={i} className="flex items-start gap-2">
3903
+ <span className={step.done ? 'text-green-500' : 'text-muted-foreground'}>
3904
+ {step.done ? '✓' : `${i + 1}.`}
3905
+ </span>
3906
+ <span className={step.done ? 'text-muted-foreground line-through' : 'text-foreground'}>
3907
+ {step.label}
3908
+ {!step.done && (
3909
+ <code className="block mt-0.5 text-xs bg-background rounded px-1.5 py-0.5 font-mono text-muted-foreground border border-border">
3910
+ {step.code}
3911
+ </code>
3912
+ )}
3913
+ </span>
3914
+ </li>
3915
+ ))}
3916
+ </ol>
3917
+ </div>
3918
+ );
3919
+ }
3920
+
3921
+ export function FormDemoView({ data }: FormDemoViewProps) {
3922
+ const formId = data.anchorId?.trim() || 'form-demo';
3923
+ const { status, message } = useFormState(formId);
3924
+
3925
+ return (
3926
+ <main className="min-h-screen flex items-center justify-center bg-background text-foreground px-6">
3927
+ <section className="w-full max-w-xl rounded-xl border border-border bg-card p-8 shadow-sm space-y-6">
3928
+ {data.icon && (
3929
+ <div data-jp-field="icon" className="mb-2">
3930
+ <Icon name={data.icon} size={24} />
3931
+ </div>
3932
+ )}
3933
+ {data.title && (
3934
+ <div>
3935
+ <h1
3936
+ data-jp-field="title"
3937
+ className="text-2xl font-semibold tracking-tight"
3938
+ >
3939
+ {data.title}
3940
+ </h1>
3941
+ {data.description && (
3942
+ <p
3943
+ data-jp-field="description"
3944
+ className="mt-3 text-sm text-muted-foreground"
3945
+ >
3946
+ {data.description}
3947
+ </p>
3948
+ )}
3949
+ </div>
3950
+ )}
3951
+
3952
+ <SetupGuide recipientEmail={data.recipientEmail} />
3953
+
3954
+ <form
3955
+ id={formId}
3956
+ data-olon-recipient={data.recipientEmail ?? ''}
3957
+ className="space-y-4"
3958
+ >
3959
+ <div>
3960
+ <label className="block text-xs font-medium text-muted-foreground mb-1">
3961
+ Nome
3962
+ </label>
3963
+ <input
3964
+ name="name"
3965
+ type="text"
3966
+ required
3967
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
3968
+ />
3969
+ </div>
3970
+
3971
+ <div>
3972
+ <label className="block text-xs font-medium text-muted-foreground mb-1">
3973
+ Email
3974
+ </label>
3975
+ <input
3976
+ name="email"
3977
+ type="email"
3978
+ required
3979
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
3980
+ />
3981
+ </div>
3982
+
3983
+ <div>
3984
+ <label className="block text-xs font-medium text-muted-foreground mb-1">
3985
+ Messaggio
3986
+ </label>
3987
+ <textarea
3988
+ name="message"
3989
+ required
3990
+ rows={4}
3991
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-primary"
3992
+ />
3993
+ </div>
3994
+
3995
+ {status === 'error' && (
3996
+ <p className="text-xs text-destructive">{message}</p>
3997
+ )}
3998
+ {status === 'success' && (
3999
+ <p className="text-xs text-green-600">
4000
+ {data.successMessage || message}
4001
+ </p>
4002
+ )}
4003
+
4004
+ <button
4005
+ type="submit"
4006
+ disabled={status === 'submitting'}
4007
+ className="w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed transition-opacity"
4008
+ >
4009
+ {status === 'submitting' ? 'Invio...' : (data.submitLabel || 'Invia')}
4010
+ </button>
4011
+ </form>
4012
+ </section>
4013
+ </main>
4014
+ );
4015
+ }
4016
+
4017
+ END_OF_FILE_CONTENT
4018
+ echo "Creating src/components/form-demo/index.ts..."
4019
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/index.ts"
4020
+ export * from './View';
4021
+ export * from './schema';
4022
+ export * from './types';
4023
+
4024
+ END_OF_FILE_CONTENT
4025
+ echo "Creating src/components/form-demo/schema.ts..."
4026
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/schema.ts"
4027
+ import { z } from 'zod';
4028
+ import { BaseSectionData, WithFormRecipient } from '@/lib/base-schemas';
4029
+
4030
+ export const FormDemoSchema = BaseSectionData.merge(WithFormRecipient).extend({
4031
+ icon: z.string().optional().describe('ui:icon-picker'),
4032
+ title: z.string().optional().describe('ui:text'),
4033
+ description: z.string().optional().describe('ui:textarea'),
4034
+ submitLabel: z.string().default('Invia').describe('ui:text'),
4035
+ successMessage: z.string().default('Richiesta inviata con successo.').describe('ui:text'),
4036
+ });
4037
+
4038
+ export const FormDemoSettingsSchema = z.object({});
4039
+
4040
+ END_OF_FILE_CONTENT
4041
+ echo "Creating src/components/form-demo/types.ts..."
4042
+ cat << 'END_OF_FILE_CONTENT' > "src/components/form-demo/types.ts"
4043
+ import { z } from 'zod';
4044
+ import { FormDemoSchema, FormDemoSettingsSchema } from './schema';
4045
+
4046
+ export type FormDemoData = z.infer<typeof FormDemoSchema>;
4047
+ export type FormDemoSettings = z.infer<typeof FormDemoSettingsSchema>;
4048
+
3752
4049
  END_OF_FILE_CONTENT
3753
4050
  mkdir -p "src/components/save-drawer"
3754
4051
  echo "Creating src/components/save-drawer/DeployConnector.tsx..."
@@ -7662,6 +7959,33 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/config/theme.json"
7662
7959
  }
7663
7960
  END_OF_FILE_CONTENT
7664
7961
  mkdir -p "src/data/pages"
7962
+ echo "Creating src/data/pages/home.json..."
7963
+ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/home.json"
7964
+ {
7965
+ "id": "home-page",
7966
+ "slug": "home",
7967
+ "meta": {
7968
+ "title": "Home",
7969
+ "description": "OlonJS tenant alpha — form smoke test"
7970
+ },
7971
+ "sections": [
7972
+ {
7973
+ "id": "form-demo-1",
7974
+ "type": "form-demo",
7975
+ "data": {
7976
+ "anchorId": "form-demo",
7977
+ "title": "Contattaci",
7978
+ "description": "Compila il modulo e ti risponderemo al più presto.",
7979
+ "recipientEmail": "test@olonjs.io",
7980
+ "submitLabel": "Invia",
7981
+ "successMessage": "Richiesta inviata con successo.",
7982
+ "icon": "mail"
7983
+ }
7984
+ }
7985
+ ],
7986
+ "global-header": false
7987
+ }
7988
+ END_OF_FILE_CONTENT
7665
7989
  mkdir -p "src/emails"
7666
7990
  echo "Creating src/emails/LeadNotificationEmail.tsx..."
7667
7991
  cat << 'END_OF_FILE_CONTENT' > "src/emails/LeadNotificationEmail.tsx"
@@ -8044,6 +8368,39 @@ function buildThemeCssFromSot(theme: ThemeConfig): string {
8044
8368
  return `:root{${serialized}}`;
8045
8369
  }
8046
8370
 
8371
+ function isRemoteStylesheetHref(value: string): boolean {
8372
+ return /^https?:\/\//i.test(value.trim());
8373
+ }
8374
+
8375
+ function extractLeadingRemoteCssImports(cssText: string): { hrefs: string[]; rest: string } {
8376
+ const hrefs = new Set<string>();
8377
+ const leadingTriviaPattern = /^(?:\s+|\/\*[\s\S]*?\*\/)*/;
8378
+ const importPattern =
8379
+ /^@import(?:\s+url\(\s*(?:'([^']+)'|"([^"]+)"|([^'")\s][^)]*))\s*\)|\s*(['"])([^'"]+)\4)\s*([^;]*);/i;
8380
+ let rest = cssText;
8381
+
8382
+ for (;;) {
8383
+ const trivia = rest.match(leadingTriviaPattern);
8384
+ if (trivia && trivia[0]) {
8385
+ rest = rest.slice(trivia[0].length);
8386
+ }
8387
+
8388
+ const match = rest.match(importPattern);
8389
+ if (!match) break;
8390
+
8391
+ const href = (match[1] ?? match[2] ?? match[3] ?? match[5] ?? '').trim();
8392
+ const trailingDirectives = (match[6] ?? '').trim();
8393
+ if (!isRemoteStylesheetHref(href) || trailingDirectives.length > 0) {
8394
+ break;
8395
+ }
8396
+
8397
+ hrefs.add(href);
8398
+ rest = rest.slice(match[0].length);
8399
+ }
8400
+
8401
+ return { hrefs: Array.from(hrefs), rest };
8402
+ }
8403
+
8047
8404
  function resolveTenantId(): string {
8048
8405
  const site: Record<string, unknown> = isRecord(siteConfig) ? siteConfig : {};
8049
8406
  const identityRaw = site['identity'];
@@ -8099,8 +8456,13 @@ export function render(slug: string): string {
8099
8456
 
8100
8457
  export function getCss(): string {
8101
8458
  const themeCss = buildThemeCssFromSot(themeConfig);
8102
- if (!themeCss) return tenantCss;
8103
- return `${themeCss}\n${tenantCss}`;
8459
+ const { rest } = extractLeadingRemoteCssImports(tenantCss);
8460
+ if (!themeCss) return rest;
8461
+ return `${themeCss}\n${rest}`;
8462
+ }
8463
+
8464
+ export function getRemoteStylesheets(): string[] {
8465
+ return extractLeadingRemoteCssImports(tenantCss).hrefs;
8104
8466
  }
8105
8467
 
8106
8468
  export function getPageMeta(slug: string): { title: string; description: string } {
@@ -8127,6 +8489,7 @@ export function getWebMcpBuildState(): {
8127
8489
  }
8128
8490
 
8129
8491
  END_OF_FILE_CONTENT
8492
+ # SKIP: src/entry-ssg.tsx:Zone.Identifier is binary and cannot be embedded as text.
8130
8493
  echo "Creating src/fonts.css..."
8131
8494
  cat << 'END_OF_FILE_CONTENT' > "src/fonts.css"
8132
8495
  @import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@@ -9074,11 +9437,13 @@ cat << 'END_OF_FILE_CONTENT' > "src/lib/ComponentRegistry.tsx"
9074
9437
  import type { SectionType } from '@/types';
9075
9438
  import type { SectionComponentPropsMap } from '@/types';
9076
9439
  import { EmptyTenantView } from '@/components/empty-tenant';
9440
+ import { FormDemoView } from '@/components/form-demo';
9077
9441
 
9078
9442
  export const ComponentRegistry: {
9079
9443
  [K in SectionType]: React.FC<SectionComponentPropsMap[K]>;
9080
9444
  } = {
9081
9445
  'empty-tenant': EmptyTenantView as React.FC<SectionComponentPropsMap['empty-tenant']>,
9446
+ 'form-demo': FormDemoView as React.FC<SectionComponentPropsMap['form-demo']>,
9082
9447
  };
9083
9448
 
9084
9449
  END_OF_FILE_CONTENT
@@ -9096,10 +9461,11 @@ import {
9096
9461
  X,
9097
9462
  Sparkles,
9098
9463
  Zap,
9464
+ Mail,
9099
9465
  type LucideIcon
9100
9466
  } from 'lucide-react';
9101
9467
 
9102
- const iconMap = {
9468
+ export const iconMap = {
9103
9469
  'layers': Layers,
9104
9470
  'github': Github,
9105
9471
  'arrow-right': ArrowRight,
@@ -9110,6 +9476,7 @@ const iconMap = {
9110
9476
  'x': X,
9111
9477
  'sparkles': Sparkles,
9112
9478
  'zap': Zap,
9479
+ 'mail': Mail,
9113
9480
  } as const satisfies Record<string, LucideIcon>;
9114
9481
 
9115
9482
  export type IconName = keyof typeof iconMap;
@@ -9139,15 +9506,46 @@ export const Icon: React.FC<IconProps> = ({ name, size = 20, className }) => {
9139
9506
 
9140
9507
 
9141
9508
 
9509
+ END_OF_FILE_CONTENT
9510
+ echo "Creating src/lib/OlonFormsContext.ts..."
9511
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/OlonFormsContext.ts"
9512
+ import { createContext, useContext } from 'react';
9513
+
9514
+ export type FormStatus = 'idle' | 'submitting' | 'success' | 'error';
9515
+
9516
+ export interface FormState {
9517
+ status: FormStatus;
9518
+ message: string;
9519
+ }
9520
+
9521
+ const DEFAULT_FORM_STATE: FormState = { status: 'idle', message: '' };
9522
+
9523
+ /**
9524
+ * Context holding the submission state of every olon-managed form,
9525
+ * keyed by the form's id attribute (or anchorId).
9526
+ * Provided by App.tsx via useOlonForms().
9527
+ */
9528
+ export const OlonFormsContext = createContext<Record<string, FormState>>({});
9529
+
9530
+ /**
9531
+ * Read the submission state for a specific form.
9532
+ * @param formId - must match the id attribute on the <form> element.
9533
+ */
9534
+ export function useFormState(formId: string): FormState {
9535
+ const states = useContext(OlonFormsContext);
9536
+ return states[formId] ?? DEFAULT_FORM_STATE;
9537
+ }
9538
+
9142
9539
  END_OF_FILE_CONTENT
9143
9540
  echo "Creating src/lib/addSectionConfig.ts..."
9144
9541
  cat << 'END_OF_FILE_CONTENT' > "src/lib/addSectionConfig.ts"
9145
9542
  import type { AddSectionConfig } from '@olonjs/core';
9146
9543
 
9147
- const addableSectionTypes = ['empty-tenant'] as const;
9544
+ const addableSectionTypes = ['empty-tenant', 'form-demo'] as const;
9148
9545
 
9149
9546
  const sectionTypeLabels: Record<string, string> = {
9150
9547
  'empty-tenant': 'Empty Tenant',
9548
+ 'form-demo': 'Form Demo',
9151
9549
  };
9152
9550
 
9153
9551
  function getDefaultSectionData(type: string): Record<string, unknown> {
@@ -9156,9 +9554,17 @@ function getDefaultSectionData(type: string): Record<string, unknown> {
9156
9554
  return {
9157
9555
  title: 'Your tenant is empty.',
9158
9556
  description: 'Create your first page to start building your site.',
9159
- ctaLabel: 'Open Studio',
9160
9557
  };
9161
- default: return {};
9558
+ case 'form-demo':
9559
+ return {
9560
+ title: 'Contattaci',
9561
+ description: 'Compila il modulo e ti risponderemo al più presto.',
9562
+ recipientEmail: '',
9563
+ submitLabel: 'Invia',
9564
+ successMessage: 'Richiesta inviata con successo.',
9565
+ };
9566
+ default:
9567
+ return {};
9162
9568
  }
9163
9569
  }
9164
9570
 
@@ -9211,6 +9617,16 @@ export const CtaSchema = z.object({
9211
9617
  variant: z.enum(['primary', 'secondary', 'accent']).default('primary').describe('ui:select'),
9212
9618
  });
9213
9619
 
9620
+ /**
9621
+ * Mixin for any section capsule that includes a contact form.
9622
+ * Merge into the section data schema to expose recipientEmail
9623
+ * as an editable field in the Studio inspector.
9624
+ * The View must set data-olon-recipient={data.recipientEmail} on the <form>.
9625
+ */
9626
+ export const WithFormRecipient = z.object({
9627
+ recipientEmail: z.string().optional().describe('ui:text'),
9628
+ });
9629
+
9214
9630
  END_OF_FILE_CONTENT
9215
9631
  echo "Creating src/lib/cloudSaveStream.ts..."
9216
9632
  cat << 'END_OF_FILE_CONTENT' > "src/lib/cloudSaveStream.ts"
@@ -9461,9 +9877,11 @@ END_OF_FILE_CONTENT
9461
9877
  echo "Creating src/lib/schemas.ts..."
9462
9878
  cat << 'END_OF_FILE_CONTENT' > "src/lib/schemas.ts"
9463
9879
  import { EmptyTenantSchema } from '@/components/empty-tenant';
9880
+ import { FormDemoSchema } from '@/components/form-demo';
9464
9881
 
9465
9882
  export const SECTION_SCHEMAS = {
9466
9883
  'empty-tenant': EmptyTenantSchema,
9884
+ 'form-demo': FormDemoSchema,
9467
9885
  } as const;
9468
9886
 
9469
9887
  export type SectionType = keyof typeof SECTION_SCHEMAS;
@@ -9565,6 +9983,137 @@ export function useFormSubmit({ source, tenantId }: UseFormSubmitOptions) {
9565
9983
 
9566
9984
  return { submit, status, message, reset };
9567
9985
  }
9986
+ END_OF_FILE_CONTENT
9987
+ echo "Creating src/lib/useOlonForms.ts..."
9988
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/useOlonForms.ts"
9989
+ import { useCallback, useEffect, useState } from 'react';
9990
+ import type { FormState } from './OlonFormsContext';
9991
+
9992
+ const API_BASE =
9993
+ (import.meta.env.VITE_OLONJS_CLOUD_URL as string | undefined) ??
9994
+ (import.meta.env.VITE_JSONPAGES_CLOUD_URL as string | undefined);
9995
+
9996
+ const API_KEY =
9997
+ (import.meta.env.VITE_OLONJS_API_KEY as string | undefined) ??
9998
+ (import.meta.env.VITE_JSONPAGES_API_KEY as string | undefined);
9999
+
10000
+ interface UseOlonFormsOptions {
10001
+ /** Override the submit endpoint. Defaults to VITE_OLONJS_CLOUD_URL/forms/submit */
10002
+ endpoint?: string;
10003
+ }
10004
+
10005
+ /**
10006
+ * Mount once in App.tsx. Scans the DOM for all <form data-olon-recipient="...">
10007
+ * elements and attaches submit handlers. Returns per-form states to be provided
10008
+ * via OlonFormsContext.Provider.
10009
+ *
10010
+ * Views consume state via useFormState(formId) — no direct coupling to this hook.
10011
+ */
10012
+ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<string, FormState> } {
10013
+ const [states, setStates] = useState<Record<string, FormState>>({});
10014
+
10015
+ const setFormState = useCallback((formId: string, state: FormState) => {
10016
+ setStates((prev) => ({ ...prev, [formId]: state }));
10017
+ }, []);
10018
+
10019
+ useEffect(() => {
10020
+ const resolvedBase = options?.endpoint
10021
+ ? options.endpoint.replace(/\/$/, '')
10022
+ : API_BASE
10023
+ ? API_BASE.replace(/\/$/, '')
10024
+ : null;
10025
+
10026
+ if (!resolvedBase || !API_KEY) {
10027
+ console.warn('[useOlonForms] Missing API endpoint or key — forms will not submit.');
10028
+ return;
10029
+ }
10030
+
10031
+ const endpoint = resolvedBase.endsWith('/forms/submit')
10032
+ ? resolvedBase
10033
+ : `${resolvedBase}/forms/submit`;
10034
+
10035
+ const forms = Array.from(
10036
+ document.querySelectorAll<HTMLFormElement>('form[data-olon-recipient]')
10037
+ );
10038
+
10039
+ const controllers: AbortController[] = [];
10040
+
10041
+ async function handleSubmit(form: HTMLFormElement, event: SubmitEvent) {
10042
+ event.preventDefault();
10043
+
10044
+ const formId = form.id || form.dataset.olonRecipient || 'olon-form';
10045
+ const recipientEmail = form.dataset.olonRecipient ?? '';
10046
+
10047
+ setFormState(formId, { status: 'submitting', message: 'Invio in corso...' });
10048
+
10049
+ const raw: Record<string, string> = {};
10050
+ new FormData(form).forEach((value, key) => {
10051
+ raw[key] = String(value).trim();
10052
+ });
10053
+
10054
+ const payload = {
10055
+ ...raw,
10056
+ recipientEmail,
10057
+ page: window.location.pathname,
10058
+ source: 'olon-form',
10059
+ submittedAt: new Date().toISOString(),
10060
+ };
10061
+
10062
+ const controller = new AbortController();
10063
+ controllers.push(controller);
10064
+
10065
+ try {
10066
+ const response = await fetch(endpoint, {
10067
+ method: 'POST',
10068
+ headers: {
10069
+ Authorization: `Bearer ${API_KEY}`,
10070
+ 'Content-Type': 'application/json',
10071
+ 'Idempotency-Key': `form-${formId}-${Date.now()}`,
10072
+ },
10073
+ body: JSON.stringify(payload),
10074
+ signal: controller.signal,
10075
+ });
10076
+
10077
+ const body = (await response.json().catch(() => ({}))) as {
10078
+ error?: string;
10079
+ code?: string;
10080
+ };
10081
+
10082
+ if (!response.ok) {
10083
+ throw new Error(body.error ?? body.code ?? `Submit failed (${response.status})`);
10084
+ }
10085
+
10086
+ setFormState(formId, {
10087
+ status: 'success',
10088
+ message: 'Richiesta inviata con successo.',
10089
+ });
10090
+ form.reset();
10091
+ } catch (error: unknown) {
10092
+ if (error instanceof Error && error.name === 'AbortError') return;
10093
+ const message =
10094
+ error instanceof Error ? error.message : 'Invio non riuscito. Riprova.';
10095
+ setFormState(formId, { status: 'error', message });
10096
+ }
10097
+ }
10098
+
10099
+ type Listener = { form: HTMLFormElement; handler: (e: Event) => void };
10100
+ const listeners: Listener[] = [];
10101
+
10102
+ forms.forEach((form) => {
10103
+ const handler = (e: Event) => void handleSubmit(form, e as SubmitEvent);
10104
+ form.addEventListener('submit', handler);
10105
+ listeners.push({ form, handler });
10106
+ });
10107
+
10108
+ return () => {
10109
+ controllers.forEach((c) => c.abort());
10110
+ listeners.forEach(({ form, handler }) => form.removeEventListener('submit', handler));
10111
+ };
10112
+ }, [options?.endpoint, setFormState]);
10113
+
10114
+ return { states };
10115
+ }
10116
+
9568
10117
  END_OF_FILE_CONTENT
9569
10118
  echo "Creating src/lib/utils.ts..."
9570
10119
  cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
@@ -9630,17 +10179,21 @@ mkdir -p "src/types"
9630
10179
  echo "Creating src/types.ts..."
9631
10180
  cat << 'END_OF_FILE_CONTENT' > "src/types.ts"
9632
10181
  import type { EmptyTenantData, EmptyTenantSettings } from '@/components/empty-tenant';
10182
+ import type { FormDemoData, FormDemoSettings } from '@/components/form-demo';
9633
10183
 
9634
10184
  export type SectionComponentPropsMap = {
9635
10185
  'empty-tenant': { data: EmptyTenantData; settings?: EmptyTenantSettings };
10186
+ 'form-demo': { data: FormDemoData; settings?: FormDemoSettings };
9636
10187
  };
9637
10188
 
9638
10189
  declare module '@olonjs/core' {
9639
10190
  export interface SectionDataRegistry {
9640
10191
  'empty-tenant': EmptyTenantData;
10192
+ 'form-demo': FormDemoData;
9641
10193
  }
9642
10194
  export interface SectionSettingsRegistry {
9643
10195
  'empty-tenant': EmptyTenantSettings;
10196
+ 'form-demo': FormDemoSettings;
9644
10197
  }
9645
10198
  }
9646
10199