create-fluixi 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/index.js +308 -0
- package/package.json +32 -0
- package/template-spa/_gitignore +4 -0
- package/template-spa/index.html +12 -0
- package/template-spa/package.json +22 -0
- package/template-spa/src/app.tsx +13 -0
- package/template-spa/src/main.tsx +5 -0
- package/template-spa/tsconfig.json +15 -0
- package/template-spa/vite.config.ts +13 -0
- package/template-spa-html/_gitignore +4 -0
- package/template-spa-html/index.html +12 -0
- package/template-spa-html/package.json +21 -0
- package/template-spa-html/src/app.ts +13 -0
- package/template-spa-html/src/main.ts +5 -0
- package/template-spa-html/tsconfig.json +13 -0
- package/template-spa-html/vite.config.ts +13 -0
- package/template-ssr/README.md +15 -0
- package/template-ssr/_gitignore +4 -0
- package/template-ssr/fluixi.config.ts +14 -0
- package/template-ssr/index.html +12 -0
- package/template-ssr/package.json +25 -0
- package/template-ssr/src/app.tsx +36 -0
- package/template-ssr/src/entry-client.tsx +10 -0
- package/template-ssr/src/entry-server.ts +17 -0
- package/template-ssr/src/routes/about.tsx +8 -0
- package/template-ssr/src/routes/index.tsx +25 -0
- package/template-ssr/tsconfig.json +15 -0
- package/template-ssr-html/README.md +19 -0
- package/template-ssr-html/_gitignore +4 -0
- package/template-ssr-html/fluixi.config.ts +14 -0
- package/template-ssr-html/index.html +12 -0
- package/template-ssr-html/package.json +24 -0
- package/template-ssr-html/src/app.ts +34 -0
- package/template-ssr-html/src/entry-client.ts +10 -0
- package/template-ssr-html/src/entry-server.ts +17 -0
- package/template-ssr-html/src/routes/about.ts +10 -0
- package/template-ssr-html/src/routes/index.ts +25 -0
- package/template-ssr-html/tsconfig.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fluixi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/fluixi/assets/main/logos/128x128.png" alt="Fluixi" width="120" height="120" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# create-fluixi
|
|
6
|
+
|
|
7
|
+
**Scaffold a new Fluixi app — `npm create fluixi <dir>`.**
|
|
8
|
+
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## ✨ Overview
|
|
16
|
+
|
|
17
|
+
Run via your package manager's `create` shortcut — no install needed:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm create fluixi my-app
|
|
21
|
+
# or
|
|
22
|
+
pnpm create fluixi my-app
|
|
23
|
+
# or
|
|
24
|
+
yarn create fluixi my-app
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It prompts for a project name, a **mode** and an **authoring format**, or takes them on the
|
|
28
|
+
command line:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
create-fluixi my-app --ssr # SSR app (@fluixi/start: file routing + SSR + hydration)
|
|
32
|
+
create-fluixi my-app --spa # SPA (vite + @fluixi/vite-plugin)
|
|
33
|
+
|
|
34
|
+
create-fluixi my-app --spa --html # same app, html`` templates instead of JSX
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--jsx` (default) or `--html` picks how you write components. The html`` variant is
|
|
38
|
+
JSX-free — plain `.ts` files, no `jsxImportSource`, and [`@fluixi/ts-plugin`](../ts-plugin)
|
|
39
|
+
wired up so the editor still understands the templates. Both compile to the same thing.
|
|
40
|
+
|
|
41
|
+
Then:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd my-app
|
|
45
|
+
pnpm install
|
|
46
|
+
pnpm dev
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 🗂️ Templates
|
|
50
|
+
|
|
51
|
+
| Mode | Stack | Entry |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| **SSR** | [`@fluixi/start`](../start) + [`@fluixi/cli`](../cli) | `src/entry-{server,client}`, `src/routes/`, `fluixi.config.ts` |
|
|
54
|
+
| **SPA** | `vite` + [`@fluixi/vite-plugin`](../vite-plugin) | `src/main.tsx` → `render(() => <App/>)` |
|
|
55
|
+
|
|
56
|
+
Each comes in a JSX and an html`` variant (`template-ssr`, `template-ssr-html`, and the same
|
|
57
|
+
two for SPA).
|
|
58
|
+
|
|
59
|
+
Both pin `@fluixi/*` to the `next` dist-tag and set `jsxImportSource: "@fluixi/jsx"`.
|
|
60
|
+
|
|
61
|
+
## 🧱 Scaffolding pieces
|
|
62
|
+
|
|
63
|
+
To add components, routes, or middleware **inside** an existing app, use `fluixi generate`
|
|
64
|
+
from [`@fluixi/cli`](../cli):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
fluixi g component Button
|
|
68
|
+
fluixi g route about
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 📄 License
|
|
72
|
+
|
|
73
|
+
[MIT](./LICENSE) © [Fluixi](https://fluixi.dev)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npm create fluixi <dir>`: scaffold a new Fluixi app. Prompts for a name, a mode
|
|
4
|
+
* (SSR via @fluixi/start, or SPA via vite + @fluixi/vite-plugin), and a template syntax
|
|
5
|
+
* (JSX or html``), unless given on the command line: `create-fluixi my-app --spa --html`
|
|
6
|
+
* (`--ssr`/`--spa`, `--jsx`/`--html`). The html`` variant is JSX-free, plain `.ts`
|
|
7
|
+
* sources, no `jsx` in tsconfig. Zero runtime deps, the styled prompts (gradient
|
|
8
|
+
* wordmark, gutter, arrow-key select) are hand-rolled ANSI.
|
|
9
|
+
*/
|
|
10
|
+
import { cpSync, renameSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { resolve, basename, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createInterface } from 'node:readline/promises';
|
|
14
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
15
|
+
import { stdin as input, stdout as output, argv, exit, env } from 'node:process';
|
|
16
|
+
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
|
17
|
+
// ── tiny zero-dep ANSI styling ──────────────────────────────────────────────
|
|
18
|
+
const COLOR = env.NO_COLOR || env.FORCE_COLOR === '0'
|
|
19
|
+
? false
|
|
20
|
+
: (!!output.isTTY || !!env.FORCE_COLOR) && env.TERM !== 'dumb';
|
|
21
|
+
const sgr = (o, x) => (s) => (COLOR ? `\x1b[${o}m${s}\x1b[${x}m` : `${s}`);
|
|
22
|
+
const c = {
|
|
23
|
+
bold: sgr(1, 22), dim: sgr(2, 22), green: sgr(32, 39), yellow: sgr(33, 39),
|
|
24
|
+
cyan: sgr(36, 39), gray: sgr(90, 39), red: sgr(31, 39),
|
|
25
|
+
};
|
|
26
|
+
const rgb = (r, g, b) => (s) => (COLOR ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m` : s);
|
|
27
|
+
const BRAND = [
|
|
28
|
+
[34, 211, 238], [56, 189, 248], [129, 140, 248], [167, 139, 250], [192, 132, 252],
|
|
29
|
+
];
|
|
30
|
+
const gradient = (text) => {
|
|
31
|
+
if (!COLOR)
|
|
32
|
+
return text;
|
|
33
|
+
const ch = [...text];
|
|
34
|
+
return ch.map((x, i) => {
|
|
35
|
+
const [r, g, b] = BRAND[Math.min(BRAND.length - 1, Math.floor((i / Math.max(1, ch.length - 1)) * (BRAND.length - 1)))];
|
|
36
|
+
return rgb(r, g, b)(x);
|
|
37
|
+
}).join('');
|
|
38
|
+
};
|
|
39
|
+
const BAR = c.gray('│');
|
|
40
|
+
// ── interactive prompt primitives (clack-style gutter) ──────────────────────
|
|
41
|
+
async function askText(label, fallback) {
|
|
42
|
+
output.write(`${c.green('◇')} ${c.bold(label)} ${c.gray(`(${fallback})`)}\n`);
|
|
43
|
+
const rl = createInterface({ input, output });
|
|
44
|
+
const answer = (await rl.question(`${BAR} `)).trim();
|
|
45
|
+
rl.close();
|
|
46
|
+
output.write(`${BAR}\n`);
|
|
47
|
+
return answer || fallback;
|
|
48
|
+
}
|
|
49
|
+
function askSelect(label, choices) {
|
|
50
|
+
return new Promise((done) => {
|
|
51
|
+
let idx = 0;
|
|
52
|
+
output.write(`${c.green('◆')} ${c.bold(label)}\n`);
|
|
53
|
+
const draw = (first) => {
|
|
54
|
+
if (!first)
|
|
55
|
+
output.write(`\x1b[${choices.length}A`);
|
|
56
|
+
for (let i = 0; i < choices.length; i++) {
|
|
57
|
+
const on = i === idx;
|
|
58
|
+
const dot = on ? c.green('●') : c.gray('○');
|
|
59
|
+
const name = on ? c.cyan(c.bold(choices[i].label)) : choices[i].label;
|
|
60
|
+
output.write(`\x1b[2K${BAR} ${dot} ${name} ${c.gray(choices[i].hint)}\n`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
draw(true);
|
|
64
|
+
emitKeypressEvents(input);
|
|
65
|
+
if (input.isTTY)
|
|
66
|
+
input.setRawMode(true);
|
|
67
|
+
const onKey = (_s, k) => {
|
|
68
|
+
if (k.name === 'up' || k.name === 'k') {
|
|
69
|
+
idx = (idx - 1 + choices.length) % choices.length;
|
|
70
|
+
draw(false);
|
|
71
|
+
}
|
|
72
|
+
else if (k.name === 'down' || k.name === 'j') {
|
|
73
|
+
idx = (idx + 1) % choices.length;
|
|
74
|
+
draw(false);
|
|
75
|
+
}
|
|
76
|
+
// Both names. A terminal sends `\r` and readline calls that `return`; a pty or a
|
|
77
|
+
// piped answer sends `\n`, which it calls `enter`. Accepting only the first left
|
|
78
|
+
// the prompt waiting for a key that never came.
|
|
79
|
+
else if (k.name === 'return' || k.name === 'enter') {
|
|
80
|
+
finish();
|
|
81
|
+
done(choices[idx].value);
|
|
82
|
+
}
|
|
83
|
+
else if (k.name === 'c' && k.ctrl) {
|
|
84
|
+
finish();
|
|
85
|
+
output.write('\n');
|
|
86
|
+
exit(1);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
// Enter on a closed stream still means "take what is highlighted". Without this a
|
|
90
|
+
// piped answer (`printf 'app\n\n\n' | create-fluixi`) drew the list and stopped.
|
|
91
|
+
const onEnd = () => { finish(); done(choices[idx].value); };
|
|
92
|
+
const finish = () => {
|
|
93
|
+
input.off('keypress', onKey);
|
|
94
|
+
input.off('end', onEnd);
|
|
95
|
+
input.off('close', onEnd);
|
|
96
|
+
if (input.isTTY)
|
|
97
|
+
input.setRawMode(false);
|
|
98
|
+
// Pause it again or the live stdin handle keeps the event loop alive and the CLI
|
|
99
|
+
// never exits.
|
|
100
|
+
input.pause();
|
|
101
|
+
output.write(`${BAR}\n`);
|
|
102
|
+
};
|
|
103
|
+
// Already at EOF, so no event is coming: a fully buffered answer
|
|
104
|
+
// (`printf 'app\n\n\n' | create-fluixi`) is consumed by the name prompt and the
|
|
105
|
+
// stream ends before this one is set up. Take the highlighted value now.
|
|
106
|
+
if (input.readableEnded) {
|
|
107
|
+
finish();
|
|
108
|
+
done(choices[idx].value);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
input.on('keypress', onKey);
|
|
112
|
+
input.on('end', onEnd);
|
|
113
|
+
input.on('close', onEnd);
|
|
114
|
+
// The reason this prompt used to go nowhere. `askText` closes its readline
|
|
115
|
+
// interface, which leaves stdin paused, and `emitKeypressEvents` only wires the
|
|
116
|
+
// stream up the first time it is called. Attaching a listener to a paused stream
|
|
117
|
+
// reads nothing, so no key ever arrived: the list drew with the first option
|
|
118
|
+
// highlighted, the event loop drained, and the process exited 0 having created
|
|
119
|
+
// nothing.
|
|
120
|
+
input.resume();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What every @fluixi-ui app needs, and nothing more.
|
|
125
|
+
*
|
|
126
|
+
* The base layer: tokens and theme, the primitives and the behaviour they share, the
|
|
127
|
+
* layout and animation helpers. No component package is here on purpose, an app installs
|
|
128
|
+
* the ones it uses, and the resolver turns `<Button />` into an import from whichever it
|
|
129
|
+
* installed.
|
|
130
|
+
*/
|
|
131
|
+
const UI_DEPS = {
|
|
132
|
+
'@fluixi-ui/fx': 'alpha',
|
|
133
|
+
'@fluixi-ui/interactions': 'alpha',
|
|
134
|
+
'@fluixi-ui/kit': 'alpha',
|
|
135
|
+
'@fluixi-ui/layout': 'alpha',
|
|
136
|
+
'@fluixi-ui/polymorphic': 'alpha',
|
|
137
|
+
'@fluixi-ui/primitives': 'alpha',
|
|
138
|
+
'@fluixi-ui/theme': 'alpha',
|
|
139
|
+
'@fluixi-ui/tokens': 'alpha',
|
|
140
|
+
};
|
|
141
|
+
/** Build-time only: it runs in the config, not in the app. */
|
|
142
|
+
const UI_DEV_DEPS = { '@fluixi-ui/resolver': 'alpha' };
|
|
143
|
+
/** Add to a package.json section without dropping what the template already had. */
|
|
144
|
+
function addDeps(pkg, section, deps) {
|
|
145
|
+
pkg[section] = Object.fromEntries(Object.entries({ ...(pkg[section] ?? {}), ...deps }).sort(([a], [b]) => a.localeCompare(b)));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The stylesheet the app imports, assembled from what was chosen.
|
|
149
|
+
*
|
|
150
|
+
* Tokens first: the kit's components read those custom properties, so a framework layer
|
|
151
|
+
* loaded before them would be overridden by nothing and a component would render unstyled.
|
|
152
|
+
*/
|
|
153
|
+
function styleSheet(ui, css) {
|
|
154
|
+
const lines = [];
|
|
155
|
+
if (ui === 'fluixi-ui')
|
|
156
|
+
lines.push(`@import '@fluixi-ui/tokens/css';`);
|
|
157
|
+
if (css === 'tailwind')
|
|
158
|
+
lines.push(`@import 'tailwindcss';`);
|
|
159
|
+
if (css === 'fluixi-css')
|
|
160
|
+
lines.push(`@import '@fluixi-css/core';`);
|
|
161
|
+
return lines.join('\n') + '\n';
|
|
162
|
+
}
|
|
163
|
+
/** Where the component resolver is configured, which differs by mode. */
|
|
164
|
+
const configFor = (mode) => mode === 'ssr' ? 'fluixi.config.ts' : 'vite.config.ts';
|
|
165
|
+
/**
|
|
166
|
+
* Let a capitalised tag resolve to a @fluixi-ui package.
|
|
167
|
+
*
|
|
168
|
+
* Added alongside the rule the template ships rather than replacing it, so a local
|
|
169
|
+
* `src/components/Card.tsx` still wins and only the names it does not define fall
|
|
170
|
+
* through to the kit.
|
|
171
|
+
*/
|
|
172
|
+
function addUiResolver(target, mode) {
|
|
173
|
+
const file = join(target, configFor(mode));
|
|
174
|
+
if (!existsSync(file))
|
|
175
|
+
return;
|
|
176
|
+
let src = readFileSync(file, 'utf8');
|
|
177
|
+
src = `import { uiComponents } from '@fluixi-ui/resolver';\n` + src;
|
|
178
|
+
src = src.replace("resolve: [{ match: /^([A-Z]\\w+)$/, module: '/src/components/$1.tsx' }]", "resolve: [{ match: /^([A-Z]\\w+)$/, module: '/src/components/$1.tsx' }, uiComponents()]");
|
|
179
|
+
writeFileSync(file, src);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Put a plugin in the app's vite config.
|
|
183
|
+
*
|
|
184
|
+
* An SSR app has none by default, because @fluixi/start owns the build. It still reads
|
|
185
|
+
* one when it is there, so a css engine that ships a vite plugin gets a file created for
|
|
186
|
+
* it rather than having nowhere to go.
|
|
187
|
+
*/
|
|
188
|
+
function addVitePlugin(target, mode, importLine, call) {
|
|
189
|
+
// A start app declares extra plugins in its own config; it has no vite config, and
|
|
190
|
+
// adding one just to hold a plugin would leave the project with two build configs
|
|
191
|
+
// that have to agree.
|
|
192
|
+
const file = join(target, configFor(mode));
|
|
193
|
+
if (!existsSync(file))
|
|
194
|
+
return;
|
|
195
|
+
let src = importLine + '\n' + readFileSync(file, 'utf8');
|
|
196
|
+
src =
|
|
197
|
+
mode === 'ssr'
|
|
198
|
+
? src.replace('export default defineConfig({', `export default defineConfig({\n plugins: [${call}],`)
|
|
199
|
+
: src.replace('plugins: [', `plugins: [\n ${call},`);
|
|
200
|
+
writeFileSync(file, src);
|
|
201
|
+
}
|
|
202
|
+
/** Import the stylesheet from whichever file boots the client. */
|
|
203
|
+
function importStyles(target, mode, format) {
|
|
204
|
+
const base = mode === 'ssr' ? 'entry-client' : 'main';
|
|
205
|
+
const file = ['tsx', 'ts']
|
|
206
|
+
.map((ext) => join(target, 'src', `${base}.${ext}`))
|
|
207
|
+
.find((p) => existsSync(p));
|
|
208
|
+
if (!file)
|
|
209
|
+
return;
|
|
210
|
+
writeFileSync(file, `import './styles.css';\n` + readFileSync(file, 'utf8'));
|
|
211
|
+
}
|
|
212
|
+
async function main() {
|
|
213
|
+
const args = argv.slice(2);
|
|
214
|
+
let dir = args.find((a) => !a.startsWith('-'));
|
|
215
|
+
let mode = args.includes('--spa') ? 'spa' : args.includes('--ssr') ? 'ssr' : undefined;
|
|
216
|
+
let format = args.includes('--html') ? 'html' : args.includes('--jsx') ? 'jsx' : undefined;
|
|
217
|
+
let ui = args.includes('--ui') ? 'fluixi-ui' : args.includes('--no-ui') ? 'none' : undefined;
|
|
218
|
+
let css = args.includes('--tailwind') ? 'tailwind'
|
|
219
|
+
: args.includes('--fluixi-css') ? 'fluixi-css'
|
|
220
|
+
: args.includes('--no-css') ? 'none'
|
|
221
|
+
: undefined;
|
|
222
|
+
const interactive = input.isTTY && (!dir || !mode || !format || !ui || !css);
|
|
223
|
+
if (interactive) {
|
|
224
|
+
output.write(`\n${c.gray('┌')} ${gradient('◆')} ${c.bold(gradient('create-fluixi'))}\n${BAR}\n`);
|
|
225
|
+
if (!dir)
|
|
226
|
+
dir = await askText('Project name', 'fluixi-app');
|
|
227
|
+
if (!mode) {
|
|
228
|
+
mode = (await askSelect('App mode', [
|
|
229
|
+
{ value: 'ssr', label: 'SSR', hint: 'server-rendered · @fluixi/start' },
|
|
230
|
+
{ value: 'spa', label: 'SPA', hint: 'client-only · vite' },
|
|
231
|
+
]));
|
|
232
|
+
}
|
|
233
|
+
if (!format) {
|
|
234
|
+
format = (await askSelect('Template syntax', [
|
|
235
|
+
{ value: 'jsx', label: 'JSX', hint: 'default · .tsx · html`` works inline too' },
|
|
236
|
+
{ value: 'html', label: 'html``', hint: 'no JSX · plain .ts · @fluixi/ts-plugin' },
|
|
237
|
+
]));
|
|
238
|
+
}
|
|
239
|
+
if (!ui) {
|
|
240
|
+
ui = (await askSelect('Component library', [
|
|
241
|
+
{ value: 'none', label: 'None', hint: 'bring your own components' },
|
|
242
|
+
{ value: 'fluixi-ui', label: '@fluixi-ui', hint: 'kit + tokens · install components as you need them' },
|
|
243
|
+
]));
|
|
244
|
+
}
|
|
245
|
+
if (!css) {
|
|
246
|
+
css = (await askSelect('Styling', [
|
|
247
|
+
{ value: 'none', label: 'None', hint: 'plain css' },
|
|
248
|
+
{ value: 'tailwind', label: 'Tailwind CSS', hint: 'v4 · via @tailwindcss/vite' },
|
|
249
|
+
{ value: 'fluixi-css', label: '@fluixi-css', hint: 'the Fluixi stylesheet' },
|
|
250
|
+
]));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
dir = dir ?? 'fluixi-app';
|
|
254
|
+
mode = mode ?? 'ssr';
|
|
255
|
+
format = format ?? 'jsx';
|
|
256
|
+
ui = ui ?? 'none';
|
|
257
|
+
css = css ?? 'none';
|
|
258
|
+
const target = resolve(dir);
|
|
259
|
+
const name = basename(target);
|
|
260
|
+
if (existsSync(target)) {
|
|
261
|
+
output.write(`${c.gray('└')} ${c.red('✗')} "${c.bold(name)}" already exists — pick another name or remove it.\n\n`);
|
|
262
|
+
exit(1);
|
|
263
|
+
}
|
|
264
|
+
const template = `../template-${mode}${format === 'html' ? '-html' : ''}`;
|
|
265
|
+
cpSync(here(template), target, { recursive: true });
|
|
266
|
+
// npm strips a real .gitignore from published packages → restore it from _gitignore.
|
|
267
|
+
const gi = join(target, '_gitignore');
|
|
268
|
+
if (existsSync(gi))
|
|
269
|
+
renameSync(gi, join(target, '.gitignore'));
|
|
270
|
+
const pkgPath = join(target, 'package.json');
|
|
271
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
272
|
+
pkg.name = name;
|
|
273
|
+
if (ui === 'fluixi-ui') {
|
|
274
|
+
addDeps(pkg, 'dependencies', UI_DEPS);
|
|
275
|
+
addDeps(pkg, 'devDependencies', UI_DEV_DEPS);
|
|
276
|
+
addUiResolver(target, mode);
|
|
277
|
+
}
|
|
278
|
+
if (css === 'tailwind') {
|
|
279
|
+
addDeps(pkg, 'devDependencies', { tailwindcss: '^4', '@tailwindcss/vite': '^4' });
|
|
280
|
+
addVitePlugin(target, mode, `import tailwindcss from '@tailwindcss/vite';`, 'tailwindcss()');
|
|
281
|
+
}
|
|
282
|
+
if (css === 'fluixi-css') {
|
|
283
|
+
addDeps(pkg, 'dependencies', { '@fluixi-css/core': 'next' });
|
|
284
|
+
addDeps(pkg, 'devDependencies', { '@fluixi-css/vite': 'next', lightningcss: '^1.29.3' });
|
|
285
|
+
addVitePlugin(target, mode, `import fluixiCss from '@fluixi-css/vite';`,
|
|
286
|
+
// The bridge only matters with the component kit installed: it maps the theme
|
|
287
|
+
// scales onto the `--flx-ui-*` custom properties those components read.
|
|
288
|
+
`fluixiCss({ reset: true${ui === 'fluixi-ui' ? ', emitUiTokens: true' : ''} })`);
|
|
289
|
+
writeFileSync(join(target, 'fluixi.css.config.js'), `const config = {\n purge: true,\n content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],\n mode: { dark: '[data-theme="dark"]' },\n theme: { extend: {} },\n};\n\nexport default config;\n`);
|
|
290
|
+
}
|
|
291
|
+
if (ui === 'fluixi-ui' || css !== 'none') {
|
|
292
|
+
writeFileSync(join(target, 'src', 'styles.css'), styleSheet(ui, css));
|
|
293
|
+
importStyles(target, mode, format);
|
|
294
|
+
}
|
|
295
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
296
|
+
// Outro + next steps. The leading gutter only makes sense after the intro/prompts.
|
|
297
|
+
const tag = `${c.cyan(mode.toUpperCase())} ${c.gray('·')} ${c.cyan(format === 'html' ? 'html``' : 'JSX')}`;
|
|
298
|
+
if (interactive)
|
|
299
|
+
output.write(`${BAR}\n`);
|
|
300
|
+
else
|
|
301
|
+
output.write('\n');
|
|
302
|
+
output.write(`${c.green('◇')} ${c.green('Created')} ${c.bold(name)} ${c.gray('·')} ${tag}\n${c.gray('└')}\n\n`);
|
|
303
|
+
output.write(` ${c.bold('Next')}\n`);
|
|
304
|
+
output.write(` ${c.gray('›')} cd ${dir}\n`);
|
|
305
|
+
output.write(` ${c.gray('›')} ${c.cyan('pnpm install')} ${c.gray('# or npm / yarn')}\n`);
|
|
306
|
+
output.write(` ${c.gray('›')} ${c.cyan('pnpm dev')}\n\n`);
|
|
307
|
+
}
|
|
308
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-fluixi",
|
|
3
|
+
"version": "0.1.0-alpha.10",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "Ibrahima Touré",
|
|
6
|
+
"description": "Scaffold a new Fluixi app — `npm create fluixi <dir>`.",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"create-fluixi": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"template-ssr",
|
|
14
|
+
"template-spa",
|
|
15
|
+
"template-ssr-html",
|
|
16
|
+
"template-spa-html",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"registry": "https://registry.npmjs.org/",
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/fluixi/core.git",
|
|
27
|
+
"directory": "packages/create"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.lib.json"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Fluixi App</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fluixi-app",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@fluixi/core": "alpha",
|
|
13
|
+
"@fluixi/jsx": "alpha",
|
|
14
|
+
"@fluixi/dom": "alpha",
|
|
15
|
+
"@fluixi/reactive": "alpha"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@fluixi/ts-plugin": "alpha",
|
|
19
|
+
"@fluixi/vite-plugin": "alpha",
|
|
20
|
+
"vite": "^5.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createSignal } from '@fluixi/core';
|
|
2
|
+
|
|
3
|
+
export function App() {
|
|
4
|
+
const [count, setCount] = createSignal(0);
|
|
5
|
+
return (
|
|
6
|
+
<div class="app">
|
|
7
|
+
<h1>Fluixi</h1>
|
|
8
|
+
<button onClick={() => setCount(count() - 1)}>-</button>
|
|
9
|
+
<span>{count()}</span>
|
|
10
|
+
<button onClick={() => setCount(count() + 1)}>+</button>
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2021",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"jsx": "preserve",
|
|
7
|
+
"jsxImportSource": "@fluixi/jsx",
|
|
8
|
+
"strict": false,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"types": ["vite/client"],
|
|
12
|
+
"plugins": [{ "name": "@fluixi/ts-plugin" }]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src", "fluixi.d.ts", "vite.config.ts"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { fluixi } from '@fluixi/vite-plugin';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [
|
|
6
|
+
fluixi({
|
|
7
|
+
// Where a capitalised tag with no import in scope comes from. One pattern covers
|
|
8
|
+
// the whole folder, so a new component needs no config, `fluixi g component Card`
|
|
9
|
+
// and `<Card />` just works. Delete this if you'd rather import every component.
|
|
10
|
+
resolve: [{ match: /^([A-Z]\w+)$/, module: '/src/components/$1.tsx' }],
|
|
11
|
+
}),
|
|
12
|
+
],
|
|
13
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Fluixi App</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.ts"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fluixi-app",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@fluixi/core": "alpha",
|
|
13
|
+
"@fluixi/dom": "alpha",
|
|
14
|
+
"@fluixi/reactive": "alpha"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@fluixi/ts-plugin": "alpha",
|
|
18
|
+
"@fluixi/vite-plugin": "alpha",
|
|
19
|
+
"vite": "^5.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createSignal, html } from '@fluixi/core';
|
|
2
|
+
|
|
3
|
+
export function App() {
|
|
4
|
+
const [count, setCount] = createSignal(0);
|
|
5
|
+
return html`
|
|
6
|
+
<div class="app">
|
|
7
|
+
<h1>Fluixi</h1>
|
|
8
|
+
<button @click=${() => setCount(count() - 1)}>-</button>
|
|
9
|
+
<span>${count()}</span>
|
|
10
|
+
<button @click=${() => setCount(count() + 1)}>+</button>
|
|
11
|
+
</div>
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2021",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": false,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"types": ["vite/client"],
|
|
10
|
+
"plugins": [{ "name": "@fluixi/ts-plugin" }]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "fluixi.d.ts", "vite.config.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { fluixi } from '@fluixi/vite-plugin';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [
|
|
6
|
+
fluixi({
|
|
7
|
+
// Where a capitalised tag with no import in scope comes from. One pattern covers
|
|
8
|
+
// the whole folder, so a new component needs no config, `fluixi g component Card`
|
|
9
|
+
// and `<Card />` just works. Delete this if you'd rather import every component.
|
|
10
|
+
resolve: [{ match: /^([A-Z]\w+)$/, module: '/src/components/$1.ts' }],
|
|
11
|
+
}),
|
|
12
|
+
],
|
|
13
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Fluixi app
|
|
2
|
+
|
|
3
|
+
Scaffolded with `npm create fluixi`. SSR + file-based routing + hydration via
|
|
4
|
+
[`@fluixi/start`](https://www.npmjs.com/package/@fluixi/start).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pnpm install
|
|
8
|
+
pnpm dev # dev server (SSR + HMR)
|
|
9
|
+
pnpm build # production build
|
|
10
|
+
pnpm start # serve the production build
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- Routes live in `src/routes/` (file-based).
|
|
14
|
+
- `src/app.tsx` is the shell layout; `src/entry-{server,client}.{ts,tsx}` are the SSR/hydration entries.
|
|
15
|
+
- Config in `fluixi.config.ts`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from '@fluixi/start/config';
|
|
2
|
+
import { nodeAdapter } from '@fluixi/start/adapter';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
port: 3000,
|
|
6
|
+
// Where a capitalised tag with no import in scope comes from. One pattern covers
|
|
7
|
+
// the whole folder, so a new component needs no config, `fluixi g component Card`
|
|
8
|
+
// and `<Card />` just works. Delete this if you'd rather import every component.
|
|
9
|
+
resolve: [{ match: /^([A-Z]\w+)$/, module: '/src/components/$1.tsx' }],
|
|
10
|
+
// `pnpm start` runs a Node process from this directory, so the framework stays an
|
|
11
|
+
// import rather than being copied into dist/server. Swap in a deploy adapter ,
|
|
12
|
+
// cloudflare, netlify, vercel: to get the layout that platform expects.
|
|
13
|
+
adapter: nodeAdapter,
|
|
14
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Fluixi Start Example</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/entry-client.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fluixi-app",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "fluixi dev",
|
|
8
|
+
"build": "fluixi build",
|
|
9
|
+
"start": "fluixi start"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@fluixi/cli": "alpha",
|
|
13
|
+
"@fluixi/start": "alpha",
|
|
14
|
+
"@fluixi/core": "alpha",
|
|
15
|
+
"@fluixi/jsx": "alpha",
|
|
16
|
+
"@fluixi/server": "alpha",
|
|
17
|
+
"@fluixi/reactive": "alpha",
|
|
18
|
+
"@fluixi/dom": "alpha"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@fluixi/ts-plugin": "alpha",
|
|
22
|
+
"@types/node": "^20.0.0",
|
|
23
|
+
"vite": "^5.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Router, Outlet, createMemoryHistory } from '@fluixi/start/router';
|
|
2
|
+
import { Suspense } from '@fluixi/core';
|
|
3
|
+
import { routes } from '@fluixi/core/routes';
|
|
4
|
+
|
|
5
|
+
// Shell layout around the routed page. File-routes (src/routes/*) are lazy, so the
|
|
6
|
+
// <Outlet/> sits under <Suspense>: async SSR resolves their content server-side.
|
|
7
|
+
function RootLayout() {
|
|
8
|
+
return (
|
|
9
|
+
<div class="app">
|
|
10
|
+
<nav>
|
|
11
|
+
<a href="/">home</a>
|
|
12
|
+
{' · '}
|
|
13
|
+
<a href="/about">about</a>
|
|
14
|
+
</nav>
|
|
15
|
+
<main>
|
|
16
|
+
<Suspense fallback={<p>loading…</p>}>
|
|
17
|
+
<Outlet />
|
|
18
|
+
</Suspense>
|
|
19
|
+
</main>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Exported so entry-client can hand it to startClient, it preloads the current route's
|
|
25
|
+
// lazy chunk before hydrating, so the server-rendered DOM is adopted in place (flash-free).
|
|
26
|
+
export const appRoutes = [{ path: '/', component: RootLayout, children: routes as any }];
|
|
27
|
+
|
|
28
|
+
export default function App(props?: { url?: string }) {
|
|
29
|
+
// On the server, match the request URL (memory history); on the client, default
|
|
30
|
+
// to browser history.
|
|
31
|
+
const history =
|
|
32
|
+
typeof window === 'undefined'
|
|
33
|
+
? createMemoryHistory(props?.url ?? '/')
|
|
34
|
+
: undefined;
|
|
35
|
+
return <Router routes={appRoutes as any} history={history as any} />;
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Client entry: hydrates the server-rendered markup. The app HTML is injected into
|
|
2
|
+
// `#root` (the @fluixi/start mountId), so hydrate that container.
|
|
3
|
+
import { startClient } from '@fluixi/core/client';
|
|
4
|
+
import App, { appRoutes } from './app.js';
|
|
5
|
+
|
|
6
|
+
// Passing the route tree lets startClient preload the matched route's lazy chunk before
|
|
7
|
+
// hydrating: the lazy file-route then resolves synchronously and adopts the server DOM in
|
|
8
|
+
// place (no first-paint flash, no duplicate). It also enables auto-hydration of the
|
|
9
|
+
// server-rendered root.
|
|
10
|
+
startClient(App, { root: '#root', routes: appRoutes });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SSR entry: @fluixi/start's dev/build/start load this. It calls `renderStream`
|
|
2
|
+
// (streaming: head flushed first, body streamed) when present, else `render` (string).
|
|
3
|
+
import App from './app.js';
|
|
4
|
+
import { renderRequestAsync, renderToBodyStream } from '@fluixi/server';
|
|
5
|
+
|
|
6
|
+
export function render(url: string): Promise<string> {
|
|
7
|
+
return renderRequestAsync(App, { url });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderStream(url: string): ReadableStream<Uint8Array> {
|
|
11
|
+
return renderToBodyStream(App, { url });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Server-function RPC dispatch: re-exported so the dispatcher shares the registry that
|
|
15
|
+
// this app's `"use server"` modules populate. @fluixi/start routes RPC calls here.
|
|
16
|
+
import '@fluixi/start/server-fns'; // registers every "use server" module (incl. lazy routes)
|
|
17
|
+
export { isServerFnRequest, handleServerFn } from '@fluixi/start/server-fn';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createResource } from '@fluixi/reactive/signal';
|
|
2
|
+
import { seo } from '@fluixi/start/head';
|
|
3
|
+
|
|
4
|
+
const tick = <T,>(v: T, ms = 30) => new Promise<T>((r) => setTimeout(() => r(v), ms));
|
|
5
|
+
|
|
6
|
+
// A data-gated route, like a real page: the resource is awaited during SSR so the
|
|
7
|
+
// content (not the spinner) is server-rendered, then hydrated.
|
|
8
|
+
export default function Home() {
|
|
9
|
+
const [data] = createResource(() => tick('Hello from Fluixi Start 👋'));
|
|
10
|
+
|
|
11
|
+
// Per-route document head: rendered into <head> on the server (great for crawlers/links)
|
|
12
|
+
// and kept reactive on the client. The head engine is always on; seo() is optional, call it
|
|
13
|
+
// only where you want page metadata. Drop in og/twitter/canonical/jsonLd as your app grows.
|
|
14
|
+
seo({
|
|
15
|
+
title: 'Fluixi Start',
|
|
16
|
+
description: 'A server-rendered Fluixi app, scaffolded by the fluixi CLI.',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<section class="home">
|
|
21
|
+
<h1>{data()}</h1>
|
|
22
|
+
<p>Server-rendered through the fluixi CLI, then hydrated.</p>
|
|
23
|
+
</section>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2021",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"jsx": "preserve",
|
|
7
|
+
"jsxImportSource": "@fluixi/jsx",
|
|
8
|
+
"strict": false,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"types": ["node", "vite/client"],
|
|
12
|
+
"plugins": [{ "name": "@fluixi/ts-plugin" }]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src", "fluixi.d.ts", "fluixi.config.ts"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Fluixi app
|
|
2
|
+
|
|
3
|
+
Scaffolded with `npm create fluixi`. SSR + file-based routing + hydration via
|
|
4
|
+
[`@fluixi/start`](https://www.npmjs.com/package/@fluixi/start).
|
|
5
|
+
|
|
6
|
+
This variant is authored with `` html`` `` templates (no JSX), so the sources are plain
|
|
7
|
+
`.ts` files and `tsconfig.json` doesn't configure `jsx`. `@fluixi/ts-plugin` gives the
|
|
8
|
+
templates full TypeScript support (hover, completion, type-checking) in your editor.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm install
|
|
12
|
+
pnpm dev # dev server (SSR + HMR)
|
|
13
|
+
pnpm build # production build
|
|
14
|
+
pnpm start # serve the production build
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- Routes live in `src/routes/` (file-based).
|
|
18
|
+
- `src/app.ts` is the shell layout; `src/entry-{server,client}.ts` are the SSR/hydration entries.
|
|
19
|
+
- Config in `fluixi.config.ts`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from '@fluixi/start/config';
|
|
2
|
+
import { nodeAdapter } from '@fluixi/start/adapter';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
port: 3000,
|
|
6
|
+
// Where a capitalised tag with no import in scope comes from. One pattern covers
|
|
7
|
+
// the whole folder, so a new component needs no config, `fluixi g component Card`
|
|
8
|
+
// and `<Card />` just works. Delete this if you'd rather import every component.
|
|
9
|
+
resolve: [{ match: /^([A-Z]\w+)$/, module: '/src/components/$1.ts' }],
|
|
10
|
+
// `pnpm start` runs a Node process from this directory, so the framework stays an
|
|
11
|
+
// import rather than being copied into dist/server. Swap in a deploy adapter ,
|
|
12
|
+
// cloudflare, netlify, vercel: to get the layout that platform expects.
|
|
13
|
+
adapter: nodeAdapter,
|
|
14
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Fluixi Start Example</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/entry-client.ts"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fluixi-app",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "fluixi dev",
|
|
8
|
+
"build": "fluixi build",
|
|
9
|
+
"start": "fluixi start"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@fluixi/cli": "alpha",
|
|
13
|
+
"@fluixi/start": "alpha",
|
|
14
|
+
"@fluixi/core": "alpha",
|
|
15
|
+
"@fluixi/server": "alpha",
|
|
16
|
+
"@fluixi/reactive": "alpha",
|
|
17
|
+
"@fluixi/dom": "alpha"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@fluixi/ts-plugin": "alpha",
|
|
21
|
+
"@types/node": "^20.0.0",
|
|
22
|
+
"vite": "^5.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Router, Outlet, createMemoryHistory } from '@fluixi/start/router';
|
|
2
|
+
import { Suspense } from '@fluixi/core';
|
|
3
|
+
import { html } from '@fluixi/start';
|
|
4
|
+
import { routes } from '@fluixi/core/routes';
|
|
5
|
+
|
|
6
|
+
// Shell layout around the routed page. File-routes (src/routes/*) are lazy, so the
|
|
7
|
+
// <Outlet/> sits under <Suspense>: async SSR resolves their content server-side.
|
|
8
|
+
// Components are interpolated as `<${Comp}/>` so TypeScript sees them referenced.
|
|
9
|
+
function RootLayout() {
|
|
10
|
+
return html`
|
|
11
|
+
<div class="app">
|
|
12
|
+
<nav>
|
|
13
|
+
<a href="/">home</a> · <a href="/about">about</a>
|
|
14
|
+
</nav>
|
|
15
|
+
<main>
|
|
16
|
+
<${Suspense} fallback=${html`<p>loading…</p>`}>
|
|
17
|
+
<${Outlet} />
|
|
18
|
+
</${Suspense}>
|
|
19
|
+
</main>
|
|
20
|
+
</div>
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Exported so entry-client can hand it to startClient, it preloads the current route's
|
|
25
|
+
// lazy chunk before hydrating, so the server-rendered DOM is adopted in place (flash-free).
|
|
26
|
+
export const appRoutes = [{ path: '/', component: RootLayout, children: routes as any }];
|
|
27
|
+
|
|
28
|
+
export default function App(props?: { url?: string }) {
|
|
29
|
+
// On the server, match the request URL (memory history); on the client, default
|
|
30
|
+
// to browser history.
|
|
31
|
+
const history =
|
|
32
|
+
typeof window === 'undefined' ? createMemoryHistory(props?.url ?? '/') : undefined;
|
|
33
|
+
return html`<${Router} routes=${appRoutes as any} history=${history as any} />`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Client entry: hydrates the server-rendered markup. The app HTML is injected into
|
|
2
|
+
// `#root` (the @fluixi/start mountId), so hydrate that container.
|
|
3
|
+
import { startClient } from '@fluixi/core/client';
|
|
4
|
+
import App, { appRoutes } from './app.js';
|
|
5
|
+
|
|
6
|
+
// Passing the route tree lets startClient preload the matched route's lazy chunk before
|
|
7
|
+
// hydrating: the lazy file-route then resolves synchronously and adopts the server DOM in
|
|
8
|
+
// place (no first-paint flash, no duplicate). It also enables auto-hydration of the
|
|
9
|
+
// server-rendered root.
|
|
10
|
+
startClient(App, { root: '#root', routes: appRoutes });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SSR entry: @fluixi/start's dev/build/start load this. It calls `renderStream`
|
|
2
|
+
// (streaming: head flushed first, body streamed) when present, else `render` (string).
|
|
3
|
+
import App from './app.js';
|
|
4
|
+
import { renderRequestAsync, renderToBodyStream } from '@fluixi/server';
|
|
5
|
+
|
|
6
|
+
export function render(url: string): Promise<string> {
|
|
7
|
+
return renderRequestAsync(App, { url });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderStream(url: string): ReadableStream<Uint8Array> {
|
|
11
|
+
return renderToBodyStream(App, { url });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Server-function RPC dispatch: re-exported so the dispatcher shares the registry that
|
|
15
|
+
// this app's `"use server"` modules populate. @fluixi/start routes RPC calls here.
|
|
16
|
+
import '@fluixi/start/server-fns'; // registers every "use server" module (incl. lazy routes)
|
|
17
|
+
export { isServerFnRequest, handleServerFn } from '@fluixi/start/server-fn';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createResource } from '@fluixi/reactive/signal';
|
|
2
|
+
import { seo } from '@fluixi/start/head';
|
|
3
|
+
import { html } from '@fluixi/start';
|
|
4
|
+
|
|
5
|
+
const tick = <T,>(v: T, ms = 30) => new Promise<T>((r) => setTimeout(() => r(v), ms));
|
|
6
|
+
|
|
7
|
+
// A data-gated route, like a real page: the resource is awaited during SSR so the
|
|
8
|
+
// content (not the spinner) is server-rendered, then hydrated.
|
|
9
|
+
export default function Home() {
|
|
10
|
+
const [data] = createResource(() => tick('Hello from Fluixi Start 👋'));
|
|
11
|
+
|
|
12
|
+
// Per-route document head: rendered into <head> on the server (great for crawlers/links)
|
|
13
|
+
// and kept reactive on the client. seo() is optional; call it where you want metadata.
|
|
14
|
+
seo({
|
|
15
|
+
title: 'Fluixi Start',
|
|
16
|
+
description: 'A server-rendered Fluixi app, scaffolded by the fluixi CLI.',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return html`
|
|
20
|
+
<section class="home">
|
|
21
|
+
<h1>${data()}</h1>
|
|
22
|
+
<p>Server-rendered through the fluixi CLI, then hydrated.</p>
|
|
23
|
+
</section>
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2021",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": false,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"types": ["node", "vite/client"],
|
|
10
|
+
"plugins": [{ "name": "@fluixi/ts-plugin" }]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "fluixi.d.ts", "fluixi.config.ts"]
|
|
13
|
+
}
|