@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.
- package/package.json +1 -1
- package/src/index.js +457 -0
package/package.json
CHANGED
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')
|