diff --git a/packages/void/package.json b/packages/void/package.json index b69637f34..e7f51bc84 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -20,6 +20,7 @@ "docs", "schema.json", "skills", + "templates", "AGENTS_TEMPLATE.md", "README.md", "sandbox.Dockerfile" diff --git a/packages/void/src/cli/starter-ui.css b/packages/void/src/cli/starter-ui.css new file mode 100644 index 000000000..f1fe77276 --- /dev/null +++ b/packages/void/src/cli/starter-ui.css @@ -0,0 +1,402 @@ +:root { + --text: rgb(22 23 29 / 70%); + --text-h: #16171d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f2f1f4; + --accent: #6c3bff; + --error: #b42318; + --social-bg: #f2f1f4; + --radius-inner: 4px; + --radius-element: 6px; + --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@supports (corner-shape: squircle) { + :root { + /* Squircle curves need about twice the radius to read as rounded. */ + --radius-inner: 8px; + --radius-element: 12px; + } + + :where(.starter, .starter *), + .starter *::before, + .starter *::after { + corner-shape: squircle; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: rgb(255 255 255 / 70%); + --text-h: #fff; + --bg: #16171d; + --border: #3b3440; + --code-bg: #3b3440; + --accent: #b39aff; + --error: #fda29b; + --social-bg: #3b3440; + --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } +} + +.starter { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code { + font-family: var(--mono); + display: inline-flex; + border-radius: var(--radius-inner); + color: var(--text-h); + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} + +.hero { + position: relative; + + .base, + .framework, + .void { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .void { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4); + } + + .void { + z-index: 0; + top: 107px; + height: 22px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + padding-bottom: 24px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +.starter-intro { + width: min(720px, 100%); + padding-inline: 20px; + box-sizing: border-box; + + a { + color: var(--accent); + } + + @media (max-width: 1024px) { + padding-inline: 0; + } +} + +.starter-description { + display: grid; + gap: 12px; + + > p:nth-child(2) { + text-wrap: balance; + } +} + +.starter-demo { + border-top: 1px solid var(--border); + padding: 32px; + text-align: left; + + @media (max-width: 1024px) { + padding: 24px 20px; + } +} + +.demo-content { + max-width: 640px; + margin: 0 auto; + display: grid; + gap: 24px; + + h2 { + margin: 0; + } +} + +.messages { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 12px; + + li { + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-element); + overflow-wrap: anywhere; + } + + strong { + display: block; + color: var(--text-h); + font-weight: 500; + } + + small { + font-size: 14px; + } +} + +.message-form { + display: grid; + gap: 12px; + + label { + color: var(--text-h); + } + + input, + button { + font: inherit; + color: var(--text-h); + background: var(--social-bg); + border: 1px solid var(--border); + border-radius: var(--radius-element); + padding: 10px 12px; + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + } + + input { + flex: 1; + min-width: 0; + } + + button { + cursor: pointer; + + &:hover { + border-color: var(--accent); + } + + &:disabled { + cursor: wait; + opacity: 0.6; + } + } +} + +.message-fields { + display: flex; + gap: 8px; + + @media (max-width: 480px) { + flex-direction: column; + } +} + +.message-form [data-void-form-error] { + color: var(--error); + font-size: 14px; +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + color: var(--accent); + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + a { + color: var(--text-h); + font-size: 16px; + border-radius: var(--radius-element); + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index 013aa8ac8..0a8e5546a 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -1,3 +1,8 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'pathslash'; +import { getPackageDir } from './agents.ts'; +import starterStyles from './starter-ui.css?raw'; + export const SCAFFOLD_GITIGNORE_LINES = [ 'node_modules', 'dist', @@ -59,6 +64,7 @@ export const SCAFFOLD_FRAMEWORK_OPTIONS = [ type ScaffoldFrameworkConfig = { label: string; + docsUrl: string; adapterPackage: string; adapterImport: string; adapterPlugin: string; @@ -71,6 +77,7 @@ type ScaffoldFrameworkConfig = { export const SCAFFOLD_FRAMEWORKS: Record = { react: { label: 'React', + docsUrl: 'https://react.dev/', adapterPackage: '@void/react', adapterImport: 'voidReact', adapterPlugin: '@void/react/plugin', @@ -90,6 +97,7 @@ export const SCAFFOLD_FRAMEWORKS: Record = { + const assets = join(getPackageDir(), 'templates', 'assets'); + const files: PagesStarterTemplate['files'] = { 'vite.config.ts': getViteConfig(framework, config), [`pages/index.${config.pageExtension}`]: getIndexPage(framework, starterMode), + 'pages/style.css': starterStyles, + 'pages/vite-env.d.ts': '/// \n', + 'public/stack.svg': readFileSync(join(assets, 'stack.svg'), 'utf8'), + 'public/void.svg': readFileSync(join(assets, 'void.svg'), 'utf8'), + [`public/${framework}.svg`]: readFileSync(join(assets, `${framework}.svg`), 'utf8'), + 'public/icons.svg': readFileSync(join(assets, 'icons.svg'), 'utf8'), }; if (starterMode !== 'static') { @@ -322,218 +339,233 @@ export default defineSeed(async ({ db, schema }) => { } function getIndexPage(framework: ScaffoldFramework, starterMode: ScaffoldStarterMode): string { - if (starterMode === 'static') { - switch (framework) { - case 'react': - return `export default function HomePage() { - return ( -
-

Welcome to Void

-

This starter is running in Pages mode with no database or server code yet.

-

- Add pages/*.server.ts, routes/, or void/db when - you want to grow into full-stack features. -

-
- ); -} -`; - case 'vue': - return ` -`; - case 'svelte': - return `
-

Welcome to Void

-

This starter is running in Pages mode with no database or server code yet.

-

- Add pages/*.server.ts, routes/, or void/db when you - want to grow into full-stack features. -

-
-`; - case 'solid': - return `export default function HomePage() { - return ( -
-

Welcome to Void

-

This starter is running in Pages mode with no database or server code yet.

-

- Add pages/*.server.ts, routes/, or void/db when - you want to grow into full-stack features. -

-
- ); -} -`; - } - } + const { label, docsUrl } = SCAFFOLD_FRAMEWORKS[framework]; + const hasDatabase = starterMode !== 'static'; + const description = + starterMode === 'static' + ? `

This starter is running in Pages mode with no database or server code yet.

+

+ Add pages/*.server.ts, routes/, or void/db when + you want to grow into full-stack features. +

` + : `

+ This starter is running in Pages mode. The list below is loaded from your database in pages/index.server.ts. +

+

API example: /api/hello

`; + const body = `
+
+
+ + ${label} logo + Void logo +
+
+

Welcome to Void

+
+${indent(description, 8)} +
+
+
${hasDatabase ? `\n \n${indent(getMessagesDemo(framework), 2)}` : ''} + +
+
+ +

Documentation

+

Your questions, answered

+ +
+
+ +

Connect with us

+

Join the Void community

+ +
+
+ + +
`; switch (framework) { case 'react': - return `import { Form, useForm } from "${SCAFFOLD_FRAMEWORKS[framework].adapterPackage}"; + case 'solid': { + const jsx = framework === 'react' ? body.replaceAll('class=', 'className=') : body; + const imports = hasDatabase + ? `${framework === 'solid' ? 'import { For, Show } from "solid-js";\n' : ''}import { Form, useForm } from "${SCAFFOLD_FRAMEWORKS[framework].adapterPackage}"; import type { Props } from "./index.server"; - -export default function HomePage({ messages }: Props) { - const form = useForm("/", { text: "" }); - return ( -
-

Welcome to Void

-

- This starter is running in Pages mode. The list below is loaded from your database in - pages/index.server.ts. -

-

- API example: /api/hello -

- {messages.length === 0 ? ( -

- No messages yet. Run void db seed to load the sample row from - db/seed.ts. -

- ) : ( -
    - {messages.map((message) => ( -
  • - {message.text} -
    - {String(message.createdAt)} -
  • - ))} -
- )} -
- - -
-
+` + : ''; + const props = hasDatabase + ? framework === 'react' + ? '{ messages }: Props' + : 'props: Props' + : ''; + return `${imports}import "./style.css"; + +export default function HomePage(${props}) { +${hasDatabase ? ' const form = useForm("/", { text: "" });\n\n' : ''} return ( +${indent(jsx, 4)} ); } `; + } case 'vue': return ` +${hasDatabase ? 'import { Form, useForm } from "@void/vue";\nimport type { Props } from "./index.server";\n' : ''}import "./style.css"; +${hasDatabase ? '\ndefineProps();\nconst form = useForm("/", { text: "" });\n' : ''} `; case 'svelte': return ` - -
-

Welcome to Void

-

- This starter is running in Pages mode. The list below is loaded from your database in - pages/index.server.ts. -

-

- API example: /api/hello -

- {#if messages.length === 0} -

- No messages yet. Run void db seed to load the sample row from - db/seed.ts. -

- {:else} -
    - {#each messages as message (message.id)} -
  • - {message.text} -
    - {String(message.createdAt)} -
  • - {/each} -
- {/if} -
- - -
-
-`; - case 'solid': - return `import { Form, useForm } from "${SCAFFOLD_FRAMEWORKS[framework].adapterPackage}"; -import type { Props } from "./index.server"; +${hasDatabase ? ' import { Form, useForm } from "@void/svelte";\n import type { Props } from "./index.server";\n' : ''} import "./style.css"; +${hasDatabase ? '\n let { messages }: Props = $props();\n const form = useForm("/", { text: "" });\n' : ''} -export default function HomePage(props: Props) { - const form = useForm("/", { text: "" }); - return ( -
-

Welcome to Void

-

- This starter is running in Pages mode. The list below is loaded from your database in - pages/index.server.ts. -

-

- API example: /api/hello -

- {props.messages.length === 0 ? ( -

- No messages yet. Run void db seed to load the sample row from - db/seed.ts. -

- ) : ( -
    - {props.messages.map((message) => ( -
  • - {message.text} -
    - {String(message.createdAt)} -
  • - ))} -
- )} -
- - -
-
- ); -} +${body} `; } } + +function getMessagesDemo(framework: ScaffoldFramework): string { + const empty = `

+ No messages yet. Run void db seed to load the sample row from db/seed.ts. +

`; + const content = { + react: `{messages.length === 0 ? ( +${indent(empty, 2)} +) : ( +
    + {messages.map((message) => ( +
  • + {message.text} + {String(message.createdAt)} +
  • + ))} +
+)} +
+ +
+ form.setData("text", event.target.value)} required maxLength={1000} /> + +
+
`, + vue: ` +
    +
  • + {{ message.text }} + {{ String(message.createdAt) }} +
  • +
+
+ +
+ + +
+
`, + svelte: `{#if messages.length === 0} +${indent(empty, 2)} +{:else} +
    + {#each messages as message (message.id)} +
  • + {message.text} + {String(message.createdAt)} +
  • + {/each} +
+{/if} +
+ +
+ + +
+
`, + solid: ` 0} fallback={ +${indent(empty, 2)} +}> +
    + {(message) => ( +
  • + {message.text} + {String(message.createdAt)} +
  • + )}
    +
+
+
+ +
+ form.setData("text", event.currentTarget.value)} required maxLength={1000} /> + +
+
`, + }[framework]; + return `
+
+

Messages

+${indent(content, 4)} +
+
`; +} + +function indent(source: string, spaces: number): string { + return source + .split('\n') + .map((line) => ' '.repeat(spaces) + line) + .join('\n'); +} diff --git a/packages/void/src/css.d.ts b/packages/void/src/css.d.ts new file mode 100644 index 000000000..4f8dea640 --- /dev/null +++ b/packages/void/src/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.css?raw' { + const content: string; + export default content; +} diff --git a/packages/void/templates/assets/icons.svg b/packages/void/templates/assets/icons.svg new file mode 100644 index 000000000..c54550d94 --- /dev/null +++ b/packages/void/templates/assets/icons.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/void/templates/assets/react.svg b/packages/void/templates/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/void/templates/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/templates/assets/solid.svg b/packages/void/templates/assets/solid.svg new file mode 100644 index 000000000..025aa303c --- /dev/null +++ b/packages/void/templates/assets/solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/templates/assets/stack.svg b/packages/void/templates/assets/stack.svg new file mode 100644 index 000000000..cc09ed4b7 --- /dev/null +++ b/packages/void/templates/assets/stack.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/void/templates/assets/svelte.svg b/packages/void/templates/assets/svelte.svg new file mode 100644 index 000000000..c5e08481f --- /dev/null +++ b/packages/void/templates/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/templates/assets/void.svg b/packages/void/templates/assets/void.svg new file mode 100644 index 000000000..7da47181c --- /dev/null +++ b/packages/void/templates/assets/void.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/void/templates/assets/vue.svg b/packages/void/templates/assets/vue.svg new file mode 100644 index 000000000..770e9d333 --- /dev/null +++ b/packages/void/templates/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index b35c3e13d..c6e12f5f3 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1327,7 +1327,32 @@ describe('isScaffoldableEmptyProject', () => { const reactViteConfig = readFileSync(join(root, 'vite.config.ts'), 'utf-8'); expect(reactViteConfig).toContain('from "@void/react/plugin"'); expect(reactViteConfig).toContain('voidReact({ react: { compiler: true } })'); - expect(readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8')).toContain('Welcome to Void'); + const page = readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8'); + expect(page).toContain('Welcome to Void'); + expect(page).toContain('The list below is loaded from your database in'); + expect(page).toContain('href="/api/hello"'); + expect(page).toContain('void db seed'); + expect(page).not.toContain('no database or server code yet'); + expect(page).not.toContain('Void +'); + expect(page).toContain('src="/react.svg"'); + expect(page).toContain('import "./style.css"'); + expect(page).toContain('messages.map'); + expect(page).toContain('useForm'); + expect(page).toContain('
{ const vueViteConfig = readFileSync(join(root, 'vite.config.ts'), 'utf-8'); expect(vueViteConfig).toContain('from "@void/vue/plugin"'); expect(vueViteConfig).toContain('voidVue()'); - expect(readFileSync(join(root, 'pages', 'index.vue'), 'utf-8')).toContain('Welcome to Void'); + const page = readFileSync(join(root, 'pages', 'index.vue'), 'utf-8'); + expect(page).toContain('Welcome to Void'); + expect(page).not.toContain('Void +'); + expect(page).toContain('src="/vue.svg"'); + expect(page).toContain('v-for="message in messages"'); + expect(page).toContain(' { expect(installCalls()[0][0]).toBe('vp'); expect(pkg.scripts.dev).toBe('vp dev'); - expect(readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8')).toContain( - 'no database or server code yet', + const page = readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8'); + expect(page).toContain('Welcome to Void'); + expect(page).toContain( + 'This starter is running in Pages mode with no database or server code yet.', ); + expect(page).toContain('you want to grow into full-stack features.'); + expect(page).not.toContain('href="/api/hello"'); + expect(page).not.toContain('useForm'); + expect(page).not.toContain(' readFileSync(join(root, 'pages', 'index.server.ts'), 'utf-8')).toThrow(); expect(() => readFileSync(join(root, 'routes', 'api', 'hello.ts'), 'utf-8')).toThrow(); @@ -1666,7 +1702,7 @@ describe('isScaffoldableEmptyProject', () => { expect(pkg.devDependencies['vite-plus']).toBe('^1.3.0'); expect(pkg.scripts.dev).toBe('vp dev'); expect(readFileSync(join(appRoot, 'pages', 'index.tsx'), 'utf-8')).toContain( - 'no database or server code yet', + 'Welcome to Void', ); const locationPrompt = mockSelect.mock.calls[0]?.[0]; diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index cc7ea04cf..90217727b 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { defineConfig } from 'vite-plus'; const WORKERS_TYPES_REFERENCE = '/// '; @@ -144,7 +145,20 @@ export default defineConfig({ format: 'esm', dts: true, clean: true, - plugins: [injectWorkersTypesReferencePlugin()], + plugins: [ + { + name: 'void:raw-css', + load: { + filter: { id: /\.css\?raw$/ }, + handler(id) { + const file = id.slice(0, -'?raw'.length); + this.addWatchFile(file); + return `export default ${JSON.stringify(readFileSync(file, 'utf8'))};`; + }, + }, + }, + injectWorkersTypesReferencePlugin(), + ], deps: { resolveDepSubpath: true, neverBundle: [ diff --git a/vite.config.ts b/vite.config.ts index df369179f..25c585a9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -432,6 +432,7 @@ export default defineConfig({ test: { name: 'cli-unit', fsModuleCache: true, + css: { include: [/starter-ui\.css(?:\?|$)/] }, include: cliUnitTests, pool: 'threads', maxWorkers, @@ -443,6 +444,7 @@ export default defineConfig({ test: { name: 'unit', fsModuleCache: true, + css: { include: [/starter-ui\.css(?:\?|$)/] }, include: ['packages/*/test/unit/**/*.test.ts'], exclude: [...cliUnitTests, ...sdkPgliteUnitTests], sequence: { groupOrder: 2 },