@lucaismyname/create-l1-stack 0.0.18 → 0.0.20

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +457 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaismyname/create-l1-stack",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/index.js CHANGED
@@ -77,6 +77,106 @@ const THEME_PRESETS = {
77
77
  },
78
78
  }
79
79
 
80
+ const INTEGRATIONS = {
81
+ contentful: {
82
+ label: 'Contentful',
83
+ env: {
84
+ VITE_CONTENTFUL_SPACE_ID: '',
85
+ VITE_CONTENTFUL_ENVIRONMENT: 'master',
86
+ VITE_CONTENTFUL_ACCESS_TOKEN: '',
87
+ },
88
+ clientOnlyWarning: false,
89
+ },
90
+ notion: {
91
+ label: 'Notion (frontend-only template; do not ship secret token to browser)',
92
+ env: {
93
+ VITE_NOTION_TOKEN: '',
94
+ },
95
+ clientOnlyWarning: true,
96
+ },
97
+ strapi: {
98
+ label: 'Strapi',
99
+ env: {
100
+ VITE_STRAPI_URL: 'https://example.com',
101
+ VITE_STRAPI_TOKEN: '',
102
+ },
103
+ clientOnlyWarning: false,
104
+ },
105
+ sanity: {
106
+ label: 'Sanity',
107
+ env: {
108
+ VITE_SANITY_PROJECT_ID: '',
109
+ VITE_SANITY_DATASET: 'production',
110
+ VITE_SANITY_API_VERSION: '2024-01-01',
111
+ VITE_SANITY_TOKEN: '',
112
+ VITE_SANITY_USE_CDN: 'true',
113
+ },
114
+ clientOnlyWarning: false,
115
+ },
116
+ mysql: {
117
+ label: 'MySQL (backend-only; template will only include docs)',
118
+ env: {
119
+ VITE_MYSQL_HOST: 'localhost',
120
+ VITE_MYSQL_USER: '',
121
+ VITE_MYSQL_PASSWORD: '',
122
+ VITE_MYSQL_DATABASE: '',
123
+ },
124
+ clientOnlyWarning: true,
125
+ },
126
+ }
127
+
128
+ const LAYOUTS = {
129
+ topNav: { label: 'Top nav (default)' },
130
+ sidebar: { label: 'Sidebar (responsive + collapsible)' },
131
+ blank: { label: 'Blank' },
132
+ }
133
+
134
+ const CONTAINER_PRESETS = {
135
+ narrow: { label: 'Narrow', maxW: 'max-w-3xl' },
136
+ default: { label: 'Default', maxW: 'max-w-5xl' },
137
+ wide: { label: 'Wide', maxW: 'max-w-7xl' },
138
+ }
139
+
140
+ const DEPLOY_TARGETS = {
141
+ static: { label: 'Static (Apache/Nginx)' },
142
+ netlify: { label: 'Netlify' },
143
+ vercel: { label: 'Vercel' },
144
+ cloudflare: { label: 'Cloudflare Pages' },
145
+ }
146
+
147
+ const ICON_SETS = {
148
+ lucide: {
149
+ dependency: { name: 'lucide-react', version: '^0.563.0' },
150
+ tsx: `import * as React from "react"\nimport {\n Check,\n ChevronDown,\n ChevronUp,\n Database,\n NotebookPen,\n Rocket,\n Server,\n} from "lucide-react"\n\ntype IconProps = React.SVGProps<SVGSVGElement>\n\nexport const Icons = {\n Check: (props: IconProps) => <Check {...props} />,\n ChevronDown: (props: IconProps) => <ChevronDown {...props} />,\n ChevronUp: (props: IconProps) => <ChevronUp {...props} />,\n Database: (props: IconProps) => <Database {...props} />,\n NotebookPen: (props: IconProps) => <NotebookPen {...props} />,\n Rocket: (props: IconProps) => <Rocket {...props} />,\n Server: (props: IconProps) => <Server {...props} />,\n}\n`,
151
+ jsx: `import {\n Check,\n ChevronDown,\n ChevronUp,\n Database,\n NotebookPen,\n Rocket,\n Server,\n} from "lucide-react"\n\nexport const Icons = {\n Check: (props) => <Check {...props} />,\n ChevronDown: (props) => <ChevronDown {...props} />,\n ChevronUp: (props) => <ChevronUp {...props} />,\n Database: (props) => <Database {...props} />,\n NotebookPen: (props) => <NotebookPen {...props} />,\n Rocket: (props) => <Rocket {...props} />,\n Server: (props) => <Server {...props} />,\n}\n`,
152
+ },
153
+ heroicons: {
154
+ dependency: { name: '@heroicons/react', version: '^2.2.0' },
155
+ tsx: `import * as React from "react"\nimport {\n CheckIcon,\n ChevronDownIcon,\n ChevronUpIcon,\n CircleStackIcon,\n PencilSquareIcon,\n RocketLaunchIcon,\n ServerIcon,\n} from "@heroicons/react/24/outline"\n\ntype IconProps = React.SVGProps<SVGSVGElement>\n\nexport const Icons = {\n Check: (props: IconProps) => <CheckIcon {...props} />,\n ChevronDown: (props: IconProps) => <ChevronDownIcon {...props} />,\n ChevronUp: (props: IconProps) => <ChevronUpIcon {...props} />,\n Database: (props: IconProps) => <CircleStackIcon {...props} />,\n NotebookPen: (props: IconProps) => <PencilSquareIcon {...props} />,\n Rocket: (props: IconProps) => <RocketLaunchIcon {...props} />,\n Server: (props: IconProps) => <ServerIcon {...props} />,\n}\n`,
156
+ jsx: `import {\n CheckIcon,\n ChevronDownIcon,\n ChevronUpIcon,\n CircleStackIcon,\n PencilSquareIcon,\n RocketLaunchIcon,\n ServerIcon,\n} from "@heroicons/react/24/outline"\n\nexport const Icons = {\n Check: (props) => <CheckIcon {...props} />,\n ChevronDown: (props) => <ChevronDownIcon {...props} />,\n ChevronUp: (props) => <ChevronUpIcon {...props} />,\n Database: (props) => <CircleStackIcon {...props} />,\n NotebookPen: (props) => <PencilSquareIcon {...props} />,\n Rocket: (props) => <RocketLaunchIcon {...props} />,\n Server: (props) => <ServerIcon {...props} />,\n}\n`,
157
+ },
158
+ tabler: {
159
+ dependency: { name: '@tabler/icons-react', version: '^3.31.0' },
160
+ tsx: `import * as React from "react"\nimport {\n IconCheck,\n IconChevronDown,\n IconChevronUp,\n IconDatabase,\n IconNotebook,\n IconRocket,\n IconServer,\n} from "@tabler/icons-react"\n\ntype IconProps = React.SVGProps<SVGSVGElement>\n\nexport const Icons = {\n Check: (props: IconProps) => <IconCheck {...props} />,\n ChevronDown: (props: IconProps) => <IconChevronDown {...props} />,\n ChevronUp: (props: IconProps) => <IconChevronUp {...props} />,\n Database: (props: IconProps) => <IconDatabase {...props} />,\n NotebookPen: (props: IconProps) => <IconNotebook {...props} />,\n Rocket: (props: IconProps) => <IconRocket {...props} />,\n Server: (props: IconProps) => <IconServer {...props} />,\n}\n`,
161
+ jsx: `import {\n IconCheck,\n IconChevronDown,\n IconChevronUp,\n IconDatabase,\n IconNotebook,\n IconRocket,\n IconServer,\n} from "@tabler/icons-react"\n\nexport const Icons = {\n Check: (props) => <IconCheck {...props} />,\n ChevronDown: (props) => <IconChevronDown {...props} />,\n ChevronUp: (props) => <IconChevronUp {...props} />,\n Database: (props) => <IconDatabase {...props} />,\n NotebookPen: (props) => <IconNotebook {...props} />,\n Rocket: (props) => <IconRocket {...props} />,\n Server: (props) => <IconServer {...props} />,\n}\n`,
162
+ },
163
+ radix: {
164
+ dependency: { name: '@radix-ui/react-icons', version: '^1.3.2' },
165
+ tsx: `import * as React from "react"\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"\n\ntype IconProps = React.SVGProps<SVGSVGElement>\n\nconst FallbackIcon = (props: IconProps) => (\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>\n <path d="M12 2l3 7h7l-5.5 4 2 7L12 16l-6.5 4 2-7L2 9h7l3-7z" />\n </svg>\n)\n\nexport const Icons = {\n Check: (props: IconProps) => <CheckIcon {...props} />,\n ChevronDown: (props: IconProps) => <ChevronDownIcon {...props} />,\n ChevronUp: (props: IconProps) => <ChevronUpIcon {...props} />,\n Database: (props: IconProps) => <FallbackIcon {...props} />,\n NotebookPen: (props: IconProps) => <FallbackIcon {...props} />,\n Rocket: (props: IconProps) => <FallbackIcon {...props} />,\n Server: (props: IconProps) => <FallbackIcon {...props} />,\n}\n`,
166
+ jsx: `import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"\n\nconst FallbackIcon = (props) => (\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>\n <path d="M12 2l3 7h7l-5.5 4 2 7L12 16l-6.5 4 2-7L2 9h7l3-7z" />\n </svg>\n)\n\nexport const Icons = {\n Check: (props) => <CheckIcon {...props} />,\n ChevronDown: (props) => <ChevronDownIcon {...props} />,\n ChevronUp: (props) => <ChevronUpIcon {...props} />,\n Database: (props) => <FallbackIcon {...props} />,\n NotebookPen: (props) => <FallbackIcon {...props} />,\n Rocket: (props) => <FallbackIcon {...props} />,\n Server: (props) => <FallbackIcon {...props} />,\n}\n`,
167
+ },
168
+ feather: {
169
+ dependency: { name: 'react-feather', version: '^2.0.10' },
170
+ tsx: `import * as React from "react"\nimport { Check, ChevronDown, ChevronUp, Database, Edit3, Send, Server } from "react-feather"\n\ntype IconProps = React.SVGProps<SVGSVGElement>\n\nexport const Icons = {\n Check: (props: IconProps) => <Check {...props} />,\n ChevronDown: (props: IconProps) => <ChevronDown {...props} />,\n ChevronUp: (props: IconProps) => <ChevronUp {...props} />,\n Database: (props: IconProps) => <Database {...props} />,\n NotebookPen: (props: IconProps) => <Edit3 {...props} />,\n Rocket: (props: IconProps) => <Send {...props} />,\n Server: (props: IconProps) => <Server {...props} />,\n}\n`,
171
+ jsx: `import { Check, ChevronDown, ChevronUp, Database, Edit3, Send, Server } from "react-feather"\n\nexport const Icons = {\n Check: (props) => <Check {...props} />,\n ChevronDown: (props) => <ChevronDown {...props} />,\n ChevronUp: (props) => <ChevronUp {...props} />,\n Database: (props) => <Database {...props} />,\n NotebookPen: (props) => <Edit3 {...props} />,\n Rocket: (props) => <Send {...props} />,\n Server: (props) => <Server {...props} />,\n}\n`,
172
+ },
173
+ materialSymbols: {
174
+ dependency: { name: 'react-material-symbols', version: '^4.1.0' },
175
+ tsx: `import * as React from "react"\nimport { MaterialSymbol } from "react-material-symbols"\n\ntype IconProps = React.SVGProps<SVGSVGElement>\n\ntype MaterialProps = IconProps & { icon: string }\n\nconst MS = ({ icon, className }: MaterialProps) => (\n <MaterialSymbol icon={icon} className={className} size={24} fill={0} grade={0} opticalSize={24} />\n)\n\nexport const Icons = {\n Check: (props: IconProps) => <MS icon="check" {...props} />,\n ChevronDown: (props: IconProps) => <MS icon="keyboard_arrow_down" {...props} />,\n ChevronUp: (props: IconProps) => <MS icon="keyboard_arrow_up" {...props} />,\n Database: (props: IconProps) => <MS icon="database" {...props} />,\n NotebookPen: (props: IconProps) => <MS icon="edit_note" {...props} />,\n Rocket: (props: IconProps) => <MS icon="rocket_launch" {...props} />,\n Server: (props: IconProps) => <MS icon="dns" {...props} />,\n}\n`,
176
+ jsx: `import { MaterialSymbol } from "react-material-symbols"\n\nconst MS = ({ icon, className }) => (\n <MaterialSymbol icon={icon} className={className} size={24} fill={0} grade={0} opticalSize={24} />\n)\n\nexport const Icons = {\n Check: (props) => <MS icon="check" {...props} />,\n ChevronDown: (props) => <MS icon="keyboard_arrow_down" {...props} />,\n ChevronUp: (props) => <MS icon="keyboard_arrow_up" {...props} />,\n Database: (props) => <MS icon="database" {...props} />,\n NotebookPen: (props) => <MS icon="edit_note" {...props} />,\n Rocket: (props) => <MS icon="rocket_launch" {...props} />,\n Server: (props) => <MS icon="dns" {...props} />,\n}\n`,
177
+ },
178
+ }
179
+
80
180
  function updateCssVarInBlock(css, blockSelector, varName, value) {
81
181
  const blockRe = new RegExp(`(${blockSelector}\\s*\\{)([\\s\\S]*?)(\\})`, 'm')
82
182
  const match = css.match(blockRe)
@@ -112,6 +212,280 @@ async function applyPrimaryTheme(targetDir, preset) {
112
212
  }
113
213
  }
114
214
 
215
+ async function applyIconSet(targetDir, language, iconSetKey) {
216
+ const iconSet = ICON_SETS[iconSetKey] ?? ICON_SETS.lucide
217
+
218
+ const pkgJsonPath = path.join(targetDir, 'package.json')
219
+ const pkg = await fse.readJson(pkgJsonPath)
220
+ pkg.dependencies ??= {}
221
+
222
+ for (const key of Object.keys(ICON_SETS)) {
223
+ const depName = ICON_SETS[key]?.dependency?.name
224
+ if (depName && pkg.dependencies[depName]) {
225
+ delete pkg.dependencies[depName]
226
+ }
227
+ }
228
+
229
+ pkg.dependencies[iconSet.dependency.name] = iconSet.dependency.version
230
+ await fse.writeJson(pkgJsonPath, pkg, { spaces: 2 })
231
+
232
+ const iconFile = path.join(
233
+ targetDir,
234
+ 'src',
235
+ 'components',
236
+ language === 'ts' ? 'icons.tsx' : 'icons.jsx'
237
+ )
238
+ await fs.mkdir(path.dirname(iconFile), { recursive: true })
239
+ await fs.writeFile(iconFile, language === 'ts' ? iconSet.tsx : iconSet.jsx, 'utf8')
240
+ }
241
+
242
+ function renderEnvFile(envMap) {
243
+ const keys = Object.keys(envMap)
244
+ keys.sort()
245
+ return keys.map((k) => `${k}=${envMap[k] ?? ''}`).join('\n') + '\n'
246
+ }
247
+
248
+ function configModuleTs() {
249
+ return `type Env = ImportMetaEnv\n\nfunction required(key: keyof Env): string {\n const value = import.meta.env[key]\n if (!value) throw new Error(\`Missing env: \${String(key)}\`)\n return value\n}\n\nexport const config = {\n contentful: {\n spaceId: import.meta.env.VITE_CONTENTFUL_SPACE_ID ?? \"\",\n environment: import.meta.env.VITE_CONTENTFUL_ENVIRONMENT,\n accessToken: import.meta.env.VITE_CONTENTFUL_ACCESS_TOKEN ?? \"\",\n },\n notion: {\n token: import.meta.env.VITE_NOTION_TOKEN,\n },\n strapi: {\n url: import.meta.env.VITE_STRAPI_URL ?? \"\",\n token: import.meta.env.VITE_STRAPI_TOKEN,\n },\n sanity: {\n projectId: import.meta.env.VITE_SANITY_PROJECT_ID ?? \"\",\n dataset: import.meta.env.VITE_SANITY_DATASET ?? \"\",\n apiVersion: import.meta.env.VITE_SANITY_API_VERSION,\n token: import.meta.env.VITE_SANITY_TOKEN,\n useCdn: import.meta.env.VITE_SANITY_USE_CDN === \"true\",\n },\n}\n\nexport const env = { required }\n`
250
+ }
251
+
252
+ function configModuleJs() {
253
+ return `function required(key) {\n const value = import.meta.env[key]\n if (!value) throw new Error(\`Missing env: \${String(key)}\`)\n return value\n}\n\nexport const config = {\n contentful: {\n spaceId: import.meta.env.VITE_CONTENTFUL_SPACE_ID ?? \"\",\n environment: import.meta.env.VITE_CONTENTFUL_ENVIRONMENT,\n accessToken: import.meta.env.VITE_CONTENTFUL_ACCESS_TOKEN ?? \"\",\n },\n notion: {\n token: import.meta.env.VITE_NOTION_TOKEN,\n },\n strapi: {\n url: import.meta.env.VITE_STRAPI_URL ?? \"\",\n token: import.meta.env.VITE_STRAPI_TOKEN,\n },\n sanity: {\n projectId: import.meta.env.VITE_SANITY_PROJECT_ID ?? \"\",\n dataset: import.meta.env.VITE_SANITY_DATASET ?? \"\",\n apiVersion: import.meta.env.VITE_SANITY_API_VERSION,\n token: import.meta.env.VITE_SANITY_TOKEN,\n useCdn: import.meta.env.VITE_SANITY_USE_CDN === \"true\",\n },\n}\n\nexport const env = { required }\n`
254
+ }
255
+
256
+ async function applyEnvAndConfig(targetDir, language, enabledIntegrations) {
257
+ const envExample = {}
258
+ const envLocal = {}
259
+
260
+ let hasClientWarning = false
261
+
262
+ for (const key of enabledIntegrations) {
263
+ const integration = INTEGRATIONS[key]
264
+ if (!integration) continue
265
+ if (integration.clientOnlyWarning) hasClientWarning = true
266
+ for (const [envKey, envValue] of Object.entries(integration.env)) {
267
+ envExample[envKey] = envValue
268
+ envLocal[envKey] = envValue
269
+ }
270
+ }
271
+
272
+ if (hasClientWarning) {
273
+ envExample.VITE_CLIENT_SIDE_SECRET_WARNING =
274
+ 'DO_NOT_PUT_SECRETS_IN_VITE_ENV__MOVE_TO_SERVER'
275
+ envLocal.VITE_CLIENT_SIDE_SECRET_WARNING =
276
+ 'DO_NOT_PUT_SECRETS_IN_VITE_ENV__MOVE_TO_SERVER'
277
+ }
278
+
279
+ const envExamplePath = path.join(targetDir, '.env.example')
280
+ const envLocalPath = path.join(targetDir, '.env.local')
281
+ await fs.writeFile(envExamplePath, renderEnvFile(envExample), 'utf8')
282
+ await fs.writeFile(envLocalPath, renderEnvFile(envLocal), 'utf8')
283
+
284
+ const configPath = path.join(
285
+ targetDir,
286
+ 'src',
287
+ language === 'ts' ? 'config.ts' : 'config.js'
288
+ )
289
+ await fs.writeFile(configPath, language === 'ts' ? configModuleTs() : configModuleJs(), 'utf8')
290
+ }
291
+
292
+ async function applyIntegrations(targetDir, language, enabledIntegrations) {
293
+ const libDir = path.join(targetDir, 'src', 'lib')
294
+ await fs.mkdir(libDir, { recursive: true })
295
+
296
+ const ext = language === 'ts' ? 'ts' : 'js'
297
+ const configImport = "import { config } from '@/config'"
298
+
299
+ if (!enabledIntegrations.includes('contentful')) {
300
+ await fse.remove(path.join(libDir, `contentful.${ext}`))
301
+ } else {
302
+ const content = `${configImport}\nimport { createContentfulClient } from '@lucaismyname/l1-utils'\n\nexport const contentful = createContentfulClient({\n spaceId: config.contentful.spaceId,\n environment: config.contentful.environment,\n accessToken: config.contentful.accessToken,\n})\n`
303
+ await fs.writeFile(path.join(libDir, `contentful.${ext}`), content, 'utf8')
304
+ }
305
+
306
+ if (!enabledIntegrations.includes('strapi')) {
307
+ await fse.remove(path.join(libDir, `strapi.${ext}`))
308
+ } else {
309
+ const content = `${configImport}\nimport { createStrapiClient } from '@lucaismyname/l1-utils'\n\nexport const strapi = createStrapiClient({\n baseUrl: config.strapi.url,\n apiToken: config.strapi.token,\n})\n`
310
+ await fs.writeFile(path.join(libDir, `strapi.${ext}`), content, 'utf8')
311
+ }
312
+
313
+ if (!enabledIntegrations.includes('sanity')) {
314
+ await fse.remove(path.join(libDir, `sanity.${ext}`))
315
+ } else {
316
+ const content = `${configImport}\nimport { createSanityClient } from '@lucaismyname/l1-utils'\n\nexport const sanity = createSanityClient({\n projectId: config.sanity.projectId,\n dataset: config.sanity.dataset,\n apiVersion: config.sanity.apiVersion,\n token: config.sanity.token,\n useCdn: config.sanity.useCdn,\n})\n`
317
+ await fs.writeFile(path.join(libDir, `sanity.${ext}`), content, 'utf8')
318
+ }
319
+
320
+ if (!enabledIntegrations.includes('notion')) {
321
+ await fse.remove(path.join(libDir, `notion.${ext}`))
322
+ } else {
323
+ const content = `${configImport}\nimport { createNotionClient } from '@lucaismyname/l1-utils'\n\nexport async function getNotion() {\n const token = config.notion.token\n if (!token) throw new Error('Missing VITE_NOTION_TOKEN')\n return createNotionClient({ token })\n}\n`
324
+ await fs.writeFile(path.join(libDir, `notion.${ext}`), content, 'utf8')
325
+ }
326
+
327
+ if (!enabledIntegrations.includes('mysql')) {
328
+ await fse.remove(path.join(libDir, `mysql.${ext}`))
329
+ } else {
330
+ const content = `// MySQL is backend-only.\n// Do not use MySQL from a Vite frontend app.\n// Use @lucaismyname/l1-utils from a Node server (Express/Fastify/Next API route, etc.).\n// Example (Node):\n// import { createMySqlPool } from '@lucaismyname/l1-utils'\n// const db = await createMySqlPool({ host, user, password, database })\n`
331
+ await fs.writeFile(path.join(libDir, `mysql.${ext}`), content, 'utf8')
332
+ }
333
+ }
334
+
335
+ async function applyContainerPreset(targetDir, language, presetKey) {
336
+ const preset = CONTAINER_PRESETS[presetKey] ?? CONTAINER_PRESETS.default
337
+ const appPath = path.join(targetDir, 'src', language === 'ts' ? 'App.tsx' : 'App.jsx')
338
+ const topNavPath = path.join(
339
+ targetDir,
340
+ 'src',
341
+ 'components',
342
+ language === 'ts' ? 'TopNav.tsx' : 'TopNav.jsx'
343
+ )
344
+
345
+ const patchMaxW = async (filePath) => {
346
+ try {
347
+ const src = await fs.readFile(filePath, 'utf8')
348
+ const next = src
349
+ .replace(/max-w-5xl/g, preset.maxW)
350
+ .replace(/max-w-7xl/g, preset.maxW)
351
+ .replace(/max-w-3xl/g, preset.maxW)
352
+ if (next !== src) await fs.writeFile(filePath, next, 'utf8')
353
+ } catch {
354
+ // ignore
355
+ }
356
+ }
357
+
358
+ await patchMaxW(appPath)
359
+ await patchMaxW(topNavPath)
360
+ }
361
+
362
+ function notFoundPageTs() {
363
+ return `import { Link } from 'react-router-dom'\n\nexport default function NotFound() {\n return (\n <div className=\"space-y-2\">\n <h1 className=\"text-2xl font-semibold tracking-tight\">Page not found</h1>\n <p className=\"text-muted-foreground\">The page you’re looking for doesn’t exist.</p>\n <Link to=\"/\" className=\"text-sm font-medium underline underline-offset-4\">\n Go home\n </Link>\n </div>\n )\n}\n`
364
+ }
365
+
366
+ function notFoundPageJs() {
367
+ return `import { Link } from 'react-router-dom'\n\nexport default function NotFound() {\n return (\n <div className=\"space-y-2\">\n <h1 className=\"text-2xl font-semibold tracking-tight\">Page not found</h1>\n <p className=\"text-muted-foreground\">The page you’re looking for doesn’t exist.</p>\n <Link to=\"/\" className=\"text-sm font-medium underline underline-offset-4\">\n Go home\n </Link>\n </div>\n )\n}\n`
368
+ }
369
+
370
+ function errorBoundaryTs() {
371
+ return `import * as React from 'react'\n\ntype Props = { children: React.ReactNode }\n\ntype State = { hasError: boolean; error?: unknown }\n\nexport class ErrorBoundary extends React.Component<Props, State> {\n state: State = { hasError: false }\n\n static getDerivedStateFromError(error: unknown) {\n return { hasError: true, error }\n }\n\n render() {\n if (this.state.hasError) {\n return (\n <div className=\"space-y-2\">\n <h1 className=\"text-2xl font-semibold tracking-tight\">Something went wrong</h1>\n <p className=\"text-muted-foreground\">An unexpected error occurred.</p>\n </div>\n )\n }\n\n return this.props.children\n }\n}\n`
372
+ }
373
+
374
+ function errorBoundaryJs() {
375
+ return `import * as React from 'react'\n\nexport class ErrorBoundary extends React.Component {\n constructor(props) {\n super(props)\n this.state = { hasError: false }\n }\n\n static getDerivedStateFromError(error) {\n return { hasError: true, error }\n }\n\n render() {\n if (this.state.hasError) {\n return (\n <div className=\"space-y-2\">\n <h1 className=\"text-2xl font-semibold tracking-tight\">Something went wrong</h1>\n <p className=\"text-muted-foreground\">An unexpected error occurred.</p>\n </div>\n )\n }\n\n return this.props.children\n }\n}\n`
376
+ }
377
+
378
+ function sidebarLayoutTs(containerMaxW) {
379
+ return `import { useMemo, useState } from 'react'\nimport { Link, NavLink, useLocation } from 'react-router-dom'\n\nconst navLinkClassName = ({ isActive }: { isActive: boolean }) =>\n [\n 'flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors',\n isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent/40',\n ].join(' ')\n\nexport default function SidebarLayout({ children }: { children: React.ReactNode }) {\n const [open, setOpen] = useState(false)\n const location = useLocation()\n\n useMemo(() => {\n setOpen(false)\n }, [location.pathname])\n\n return (\n <div className=\"min-h-dvh\">\n <header className=\"border-b md:hidden\">\n <div className=\"mx-auto flex h-14 ${containerMaxW} items-center justify-between px-4\">\n <Link to=\"/\" className=\"text-sm font-semibold\">\n L1 Stack\n </Link>\n <button\n type=\"button\"\n className=\"inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm\"\n onClick={() => setOpen((v) => !v)}\n >\n Menu\n </button>\n </div>\n </header>\n\n <div className=\"mx-auto ${containerMaxW} px-4\">\n <div className=\"grid grid-cols-1 md:grid-cols-[240px_1fr] md:gap-8\">\n <aside className=\"md:sticky md:top-6 md:h-[calc(100dvh-3rem)] md:py-6\">\n <div className={open ? 'block' : 'hidden md:block'}>\n <div className=\"py-6 md:py-0\">\n <div className=\"mb-6 hidden md:block\">\n <Link to=\"/\" className=\"text-sm font-semibold\">\n L1 Stack\n </Link>\n </div>\n <nav className=\"space-y-1\">\n <NavLink to=\"/\" end className={navLinkClassName}>\n Home\n </NavLink>\n <NavLink to=\"/integrations\" className={navLinkClassName}>\n Integrations\n </NavLink>\n </nav>\n </div>\n </div>\n </aside>\n\n <main className=\"py-10\">{children}</main>\n </div>\n </div>\n </div>\n )\n}\n`
380
+ }
381
+
382
+ function sidebarLayoutJs(containerMaxW) {
383
+ return `import { useMemo, useState } from 'react'\nimport { Link, NavLink, useLocation } from 'react-router-dom'\n\nconst navLinkClassName = ({ isActive }) =>\n [\n 'flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors',\n isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent/40',\n ].join(' ')\n\nexport default function SidebarLayout({ children }) {\n const [open, setOpen] = useState(false)\n const location = useLocation()\n\n useMemo(() => {\n setOpen(false)\n }, [location.pathname])\n\n return (\n <div className=\"min-h-dvh\">\n <header className=\"border-b md:hidden\">\n <div className=\"mx-auto flex h-14 ${containerMaxW} items-center justify-between px-4\">\n <Link to=\"/\" className=\"text-sm font-semibold\">\n L1 Stack\n </Link>\n <button\n type=\"button\"\n className=\"inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm\"\n onClick={() => setOpen((v) => !v)}\n >\n Menu\n </button>\n </div>\n </header>\n\n <div className=\"mx-auto ${containerMaxW} px-4\">\n <div className=\"grid grid-cols-1 md:grid-cols-[240px_1fr] md:gap-8\">\n <aside className=\"md:sticky md:top-6 md:h-[calc(100dvh-3rem)] md:py-6\">\n <div className={open ? 'block' : 'hidden md:block'}>\n <div className=\"py-6 md:py-0\">\n <div className=\"mb-6 hidden md:block\">\n <Link to=\"/\" className=\"text-sm font-semibold\">\n L1 Stack\n </Link>\n </div>\n <nav className=\"space-y-1\">\n <NavLink to=\"/\" end className={navLinkClassName}>\n Home\n </NavLink>\n <NavLink to=\"/integrations\" className={navLinkClassName}>\n Integrations\n </NavLink>\n </nav>\n </div>\n </div>\n </aside>\n\n <main className=\"py-10\">{children}</main>\n </div>\n </div>\n </div>\n )\n}\n`
384
+ }
385
+
386
+ async function applyLayout(targetDir, language, layoutKey, containerPresetKey) {
387
+ const appPath = path.join(targetDir, 'src', language === 'ts' ? 'App.tsx' : 'App.jsx')
388
+ const notFoundPath = path.join(
389
+ targetDir,
390
+ 'src',
391
+ 'pages',
392
+ language === 'ts' ? 'NotFound.tsx' : 'NotFound.jsx'
393
+ )
394
+ const errorBoundaryPath = path.join(
395
+ targetDir,
396
+ 'src',
397
+ 'components',
398
+ language === 'ts' ? 'ErrorBoundary.tsx' : 'ErrorBoundary.jsx'
399
+ )
400
+
401
+ await fs.mkdir(path.dirname(notFoundPath), { recursive: true })
402
+ await fs.mkdir(path.dirname(errorBoundaryPath), { recursive: true })
403
+ await fs.writeFile(notFoundPath, language === 'ts' ? notFoundPageTs() : notFoundPageJs(), 'utf8')
404
+ await fs.writeFile(errorBoundaryPath, language === 'ts' ? errorBoundaryTs() : errorBoundaryJs(), 'utf8')
405
+
406
+ const preset = CONTAINER_PRESETS[containerPresetKey] ?? CONTAINER_PRESETS.default
407
+ const containerMaxW = preset.maxW
408
+
409
+ if (layoutKey === 'sidebar') {
410
+ const sidebarPath = path.join(
411
+ targetDir,
412
+ 'src',
413
+ 'components',
414
+ language === 'ts' ? 'SidebarLayout.tsx' : 'SidebarLayout.jsx'
415
+ )
416
+ await fs.mkdir(path.dirname(sidebarPath), { recursive: true })
417
+ await fs.writeFile(
418
+ sidebarPath,
419
+ language === 'ts' ? sidebarLayoutTs(containerMaxW) : sidebarLayoutJs(containerMaxW),
420
+ 'utf8'
421
+ )
422
+
423
+ const app = `import { Route, Routes } from 'react-router-dom'\nimport SidebarLayout from './components/SidebarLayout'\nimport { ErrorBoundary } from './components/ErrorBoundary'\nimport Home from './pages/Home'\nimport Integrations from './pages/Integrations'\nimport NotFound from './pages/NotFound'\n\nfunction App() {\n return (\n <SidebarLayout>\n <ErrorBoundary>\n <Routes>\n <Route path=\"/\" element={<Home />} />\n <Route path=\"/integrations\" element={<Integrations />} />\n <Route path=\"*\" element={<NotFound />} />\n </Routes>\n </ErrorBoundary>\n </SidebarLayout>\n )\n}\n\nexport default App\n`
424
+ await fs.writeFile(appPath, app, 'utf8')
425
+ return
426
+ }
427
+
428
+ if (layoutKey === 'blank') {
429
+ const app = `import { Route, Routes } from 'react-router-dom'\nimport { ErrorBoundary } from './components/ErrorBoundary'\nimport Home from './pages/Home'\nimport Integrations from './pages/Integrations'\nimport NotFound from './pages/NotFound'\n\nfunction App() {\n return (\n <div className=\"min-h-dvh\">\n <main className=\"mx-auto ${containerMaxW} px-4 py-10\">\n <ErrorBoundary>\n <Routes>\n <Route path=\"/\" element={<Home />} />\n <Route path=\"/integrations\" element={<Integrations />} />\n <Route path=\"*\" element={<NotFound />} />\n </Routes>\n </ErrorBoundary>\n </main>\n </div>\n )\n}\n\nexport default App\n`
430
+ await fs.writeFile(appPath, app, 'utf8')
431
+ return
432
+ }
433
+
434
+ // topNav: patch existing App if possible
435
+ try {
436
+ const src = await fs.readFile(appPath, 'utf8')
437
+ let next = src
438
+ next = next.replace(
439
+ /import \{ Navigate, Route, Routes \} from 'react-router-dom'/g,
440
+ "import { Route, Routes } from 'react-router-dom'"
441
+ )
442
+ if (!next.includes("./components/ErrorBoundary")) {
443
+ next = next.replace(
444
+ /import TopNav from '\.\/components\/TopNav'\n/,
445
+ "import TopNav from './components/TopNav'\nimport { ErrorBoundary } from './components/ErrorBoundary'\n"
446
+ )
447
+ }
448
+ if (!next.includes("./pages/NotFound")) {
449
+ next = next.replace(
450
+ /import Integrations from '\.\/pages\/Integrations'\n/,
451
+ "import Integrations from './pages/Integrations'\nimport NotFound from './pages/NotFound'\n"
452
+ )
453
+ }
454
+ next = next.replace(
455
+ /<Routes>[\s\S]*?<\/Routes>/m,
456
+ `<ErrorBoundary>\n <Routes>\n <Route path="/" element={<Home />} />\n <Route path="/integrations" element={<Integrations />} />\n <Route path="*" element={<NotFound />} />\n </Routes>\n </ErrorBoundary>`
457
+ )
458
+ if (next !== src) await fs.writeFile(appPath, next, 'utf8')
459
+ } catch {
460
+ // ignore
461
+ }
462
+ }
463
+
464
+ async function applyDeployTarget(targetDir, targetKey) {
465
+ const target = DEPLOY_TARGETS[targetKey] ? targetKey : 'static'
466
+
467
+ const publicDir = path.join(targetDir, 'public')
468
+ await fs.mkdir(publicDir, { recursive: true })
469
+
470
+ const redirectsPath = path.join(publicDir, '_redirects')
471
+ const redirects = `/* /index.html 200\n`
472
+ await fs.writeFile(redirectsPath, redirects, 'utf8')
473
+
474
+ const nginxSnippetPath = path.join(targetDir, 'nginx.conf.snippet')
475
+ const nginxSnippet = `location / {\n try_files $uri $uri/ /index.html;\n}\n`
476
+ await fs.writeFile(nginxSnippetPath, nginxSnippet, 'utf8')
477
+
478
+ if (target === 'netlify') {
479
+ const netlifyToml = `[build]\n command = "npm run build"\n publish = "dist"\n\n[[redirects]]\n from = "/*"\n to = "/index.html"\n status = 200\n`
480
+ await fs.writeFile(path.join(targetDir, 'netlify.toml'), netlifyToml, 'utf8')
481
+ }
482
+
483
+ if (target === 'vercel') {
484
+ const vercelJson = `{"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}`
485
+ await fs.writeFile(path.join(targetDir, 'vercel.json'), vercelJson, 'utf8')
486
+ }
487
+ }
488
+
115
489
  function toKebabCaseName(input) {
116
490
  return input
117
491
  .trim()
@@ -257,6 +631,83 @@ async function main() {
257
631
  process.exit(0)
258
632
  }
259
633
 
634
+ const iconSet = await select({
635
+ message: 'Icon set',
636
+ options: [
637
+ { value: 'lucide', label: 'Lucide (default)' },
638
+ { value: 'heroicons', label: 'HeroIcons' },
639
+ { value: 'materialSymbols', label: 'Material Symbols' },
640
+ { value: 'tabler', label: 'Tabler Icons' },
641
+ { value: 'radix', label: 'Radix Icons' },
642
+ { value: 'feather', label: 'Feather Icons' },
643
+ ],
644
+ initialValue: 'lucide',
645
+ })
646
+ if (isCancel(iconSet)) {
647
+ cancel('Cancelled')
648
+ process.exit(0)
649
+ }
650
+
651
+ const integrations = []
652
+ for (const key of Object.keys(INTEGRATIONS)) {
653
+ const enabled = await select({
654
+ message: `Enable ${INTEGRATIONS[key].label}?`,
655
+ options: [
656
+ { value: 'yes', label: 'Yes' },
657
+ { value: 'no', label: 'No' },
658
+ ],
659
+ initialValue: 'no',
660
+ })
661
+ if (isCancel(enabled)) {
662
+ cancel('Cancelled')
663
+ process.exit(0)
664
+ }
665
+ if (enabled === 'yes') integrations.push(key)
666
+ }
667
+
668
+ const layout = await select({
669
+ message: 'Layout',
670
+ options: [
671
+ { value: 'topNav', label: LAYOUTS.topNav.label },
672
+ { value: 'sidebar', label: LAYOUTS.sidebar.label },
673
+ { value: 'blank', label: LAYOUTS.blank.label },
674
+ ],
675
+ initialValue: 'topNav',
676
+ })
677
+ if (isCancel(layout)) {
678
+ cancel('Cancelled')
679
+ process.exit(0)
680
+ }
681
+
682
+ const containerPreset = await select({
683
+ message: 'Content width',
684
+ options: [
685
+ { value: 'narrow', label: CONTAINER_PRESETS.narrow.label },
686
+ { value: 'default', label: CONTAINER_PRESETS.default.label },
687
+ { value: 'wide', label: CONTAINER_PRESETS.wide.label },
688
+ ],
689
+ initialValue: 'default',
690
+ })
691
+ if (isCancel(containerPreset)) {
692
+ cancel('Cancelled')
693
+ process.exit(0)
694
+ }
695
+
696
+ const deployTarget = await select({
697
+ message: 'Deploy to',
698
+ options: [
699
+ { value: 'static', label: DEPLOY_TARGETS.static.label },
700
+ { value: 'netlify', label: DEPLOY_TARGETS.netlify.label },
701
+ { value: 'vercel', label: DEPLOY_TARGETS.vercel.label },
702
+ { value: 'cloudflare', label: DEPLOY_TARGETS.cloudflare.label },
703
+ ],
704
+ initialValue: 'static',
705
+ })
706
+ if (isCancel(deployTarget)) {
707
+ cancel('Cancelled')
708
+ process.exit(0)
709
+ }
710
+
260
711
  const targetDir = path.resolve(process.cwd(), projectName)
261
712
  // NOTE: npm downloads the CLI package before prompts run.
262
713
  // To avoid downloading both templates, we fetch only the chosen template from npm.
@@ -309,6 +760,12 @@ async function main() {
309
760
  }
310
761
 
311
762
  await applyPrimaryTheme(targetDir, THEME_PRESETS[primaryColor] ?? THEME_PRESETS.slate)
763
+ await applyIconSet(targetDir, language, iconSet)
764
+ await applyEnvAndConfig(targetDir, language, integrations)
765
+ await applyIntegrations(targetDir, language, integrations)
766
+ await applyLayout(targetDir, language, layout, containerPreset)
767
+ await applyContainerPreset(targetDir, language, containerPreset)
768
+ await applyDeployTarget(targetDir, deployTarget)
312
769
 
313
770
  // Update package name
314
771
  const pkgJsonPath = path.join(targetDir, 'package.json')