onboarding-kit 0.0.0-stage → 0.2.0
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/README.md +102 -2
- package/dist/adapters/nest.d.ts +15 -0
- package/dist/adapters/nest.js +53 -0
- package/dist/adapters/next.d.ts +23 -0
- package/dist/adapters/next.js +41 -0
- package/dist/adapters/node.d.ts +10 -0
- package/dist/adapters/node.js +33 -0
- package/dist/brand.d.ts +38 -0
- package/dist/brand.js +319 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.js +272 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +63 -0
- package/dist/load.d.ts +19 -0
- package/dist/load.js +59 -0
- package/dist/render/assets.d.ts +4 -0
- package/dist/render/assets.js +512 -0
- package/dist/render/copy.d.ts +140 -0
- package/dist/render/copy.js +343 -0
- package/dist/render/html.d.ts +12 -0
- package/dist/render/html.js +74 -0
- package/dist/render/index.d.ts +9 -0
- package/dist/render/index.js +230 -0
- package/dist/render/slides.d.ts +19 -0
- package/dist/render/slides.js +232 -0
- package/dist/template.d.ts +14 -0
- package/dist/template.js +96 -0
- package/dist/types.d.ts +163 -0
- package/dist/types.js +6 -0
- package/dist/validate.d.ts +17 -0
- package/dist/validate.js +236 -0
- package/package.json +75 -4
package/README.md
CHANGED
|
@@ -1,3 +1,103 @@
|
|
|
1
|
-
#
|
|
1
|
+
# onboarding-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Onboarding de projetos gerado pela **sua IA local** e exibido em `/onboarding`, dentro do próprio app.
|
|
4
|
+
|
|
5
|
+
1. O `onboarding-kit` cria um `onboarding.json` com todos os campos em branco e instruções para a IA.
|
|
6
|
+
2. Você pede para a IA que já usa no projeto (Claude Code, Cursor, Copilot…) ler e preencher o arquivo.
|
|
7
|
+
3. O `onboarding-kit` valida o resultado e mostra uma apresentação do projeto em `/onboarding`.
|
|
8
|
+
|
|
9
|
+
Funciona com **Next.js**, **NestJS** e **Node.js** (Express, http nativo e similares). Não tem dependências em runtime.
|
|
10
|
+
|
|
11
|
+
## Começando
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i -D onboarding-kit
|
|
15
|
+
npx onboarding-kit init
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
O `init` detecta o framework e:
|
|
19
|
+
|
|
20
|
+
- **Next.js (App Router):** cria `app/onboarding/route.ts` (ou `src/app/...`).
|
|
21
|
+
- **NestJS:** adiciona `OnboardingModule.register()` em `src/app.module.ts`.
|
|
22
|
+
- **Node.js:** mostra o trecho para registrar a rota no seu servidor.
|
|
23
|
+
|
|
24
|
+
Depois, envie isto para a sua IA:
|
|
25
|
+
|
|
26
|
+
> Leia o arquivo onboarding.json e siga as instruções em "_instructions" para preenchê-lo com base neste repositório. No final, rode "npx onboarding-kit validate" e corrija o que ele apontar.
|
|
27
|
+
|
|
28
|
+
Para ver o resultado, abra `/onboarding` com o app rodando em dev, ou rode `npx onboarding-kit serve`.
|
|
29
|
+
|
|
30
|
+
Se você abrir `/onboarding` antes da IA terminar, a página mostra o prompt pronto para copiar, acompanha as seções conforme a IA preenche o arquivo e abre a apresentação sozinha quando ela termina. Sem `onboarding.json`, o prompt já pede para a IA rodar o `init` antes.
|
|
31
|
+
|
|
32
|
+
## Comandos
|
|
33
|
+
|
|
34
|
+
| Comando | O que faz |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `init` | Cria o `onboarding.json` e liga a rota. Opções: `--framework`, `--lang`, `--no-route`, `--force` |
|
|
37
|
+
| `prompt` | Mostra o texto para enviar à IA |
|
|
38
|
+
| `validate` | Confere campos obrigatórios, itens-modelo esquecidos, se as evidências existem, se há segredos e se o JSON está atrás do commit atual. Sai com código 1 em caso de erro (útil na CI) |
|
|
39
|
+
| `serve` | Abre a apresentação em `http://localhost:4321` (`--port` para mudar) |
|
|
40
|
+
|
|
41
|
+
## A apresentação
|
|
42
|
+
|
|
43
|
+
A página `/onboarding` é um tour em slides, contado como uma história: boas-vindas → o porquê → o negócio → o produto → vocabulário (com cartões que viram e um quiz) → regras do jogo → stack → arquitetura → rodando na máquina → jeito da casa → tour pelo código → cuidados → decisões → primeira missão → final.
|
|
44
|
+
|
|
45
|
+
Os capítulos entram com uma cortina animada, os elementos aparecem em cascata, o fundo muda de cor junto com o capítulo e os cartões inclinam com o mouse. Tudo isso é desligado quando o sistema pede movimento reduzido.
|
|
46
|
+
|
|
47
|
+
Navegação por ← →, espaço, swipe no celular ou pelos segmentos coloridos no topo. `O` abre o índice. O progresso e as tarefas marcadas ficam salvos no navegador. Tem tema claro e escuro. Capítulos sem conteúdo são pulados e listas longas viram vários slides.
|
|
48
|
+
|
|
49
|
+
## Cara da marca
|
|
50
|
+
|
|
51
|
+
A apresentação usa as cores e o logo do projeto. A IA preenche `brand` (`primaryColor`, `palette`, `logo`, `favicon`) e o que ficar vazio é detectado automaticamente:
|
|
52
|
+
|
|
53
|
+
- **Cores:** `tailwind.config` (`brand`, `primary`, `accent`, inclusive `colors.indigo`), `@theme` do Tailwind v4, variáveis CSS como `--primary` e `--brand` (hex, rgb, hsl, o formato do shadcn/ui e oklch), variáveis SCSS/LESS, temas JS (`palette.primary.main`) e o `theme_color` do manifest.
|
|
54
|
+
- **Logo e favicon:** `public/`, `assets/`, `src/assets/` e `app/` (`logo.*`, `icon.*`, `favicon.*`). Os arquivos de exemplo do framework (`next.svg`, `vercel.svg`, `vite.svg`…) são ignorados.
|
|
55
|
+
|
|
56
|
+
Com uma cor, os capítulos variam em torno dela. Com várias, eles se revezam entre elas. Marcas em preto e branco viram um tema monocromático.
|
|
57
|
+
|
|
58
|
+
## O que vai no JSON
|
|
59
|
+
|
|
60
|
+
`project`, `business` (modelo de negócio, valor entregue, perfis e jornadas), `gettingStarted`, `stack`, `architecture` (com diagrama Mermaid), `conventions`, `domain` (glossário e regras de negócio), `features`, `tours` pelo código, `gotchas`, `decisions`, `firstTasks` e `brand` (cores e logo).
|
|
61
|
+
|
|
62
|
+
Toda afirmação aponta para arquivos reais (`evidence: ["src/orders/orders.service.ts:42"]`). Quando `project.repoUrl` está preenchido, essas referências viram links para o código.
|
|
63
|
+
|
|
64
|
+
As chaves que começam com `_` (como `_instructions`) são só para a IA: a página as ignora. Deixe-as no arquivo, porque a IA usa essas instruções para **atualizar** o onboarding depois (com `git diff` desde `meta.commit`).
|
|
65
|
+
|
|
66
|
+
## Uso manual dos adapters
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// Next.js — app/onboarding/route.ts
|
|
70
|
+
import { createOnboardingRoute } from "onboarding-kit/next";
|
|
71
|
+
export const runtime = "nodejs";
|
|
72
|
+
export const dynamic = "force-dynamic";
|
|
73
|
+
export const GET = createOnboardingRoute();
|
|
74
|
+
|
|
75
|
+
// Next.js Pages Router — pages/api/onboarding.ts (+ rewrite de /onboarding)
|
|
76
|
+
import { createOnboardingApiHandler } from "onboarding-kit/next";
|
|
77
|
+
export default createOnboardingApiHandler();
|
|
78
|
+
|
|
79
|
+
// NestJS — app.module.ts (com globalPrefix "api", a rota fica /api/onboarding)
|
|
80
|
+
import { OnboardingModule } from "onboarding-kit/nest";
|
|
81
|
+
@Module({ imports: [OnboardingModule.register({ path: "onboarding" })] })
|
|
82
|
+
|
|
83
|
+
// Express / Node
|
|
84
|
+
import { createOnboardingHandler } from "onboarding-kit/node";
|
|
85
|
+
app.get("/onboarding", createOnboardingHandler());
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Todos os adapters aceitam `{ file?: string; enabled?: boolean }`.
|
|
89
|
+
|
|
90
|
+
## Segurança
|
|
91
|
+
|
|
92
|
+
- A rota fica **desligada quando `NODE_ENV=production`**. Para ligar de propósito, passe `enabled: true`.
|
|
93
|
+
- A página envia `noindex` e não usa cache.
|
|
94
|
+
- O `validate` procura tokens, chaves privadas, JWTs e URLs com senha.
|
|
95
|
+
- Todo o conteúdo do JSON passa por escape antes de entrar no HTML.
|
|
96
|
+
|
|
97
|
+
## Desenvolvimento
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install
|
|
101
|
+
npm test # compila e roda os testes
|
|
102
|
+
node dist/cli/index.js serve # mostra o onboarding do próprio onboarding-kit
|
|
103
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DynamicModule } from "@nestjs/common";
|
|
2
|
+
import type { OnboardingOptions } from "../types";
|
|
3
|
+
export interface OnboardingModuleOptions extends OnboardingOptions {
|
|
4
|
+
/** Caminho da rota. Padrão: "onboarding". O globalPrefix do app, se houver, é aplicado antes. */
|
|
5
|
+
path?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Módulo NestJS que serve a apresentação do projeto.
|
|
9
|
+
*
|
|
10
|
+
* app.module.ts:
|
|
11
|
+
* imports: [OnboardingModule.register()]
|
|
12
|
+
*/
|
|
13
|
+
export declare class OnboardingModule {
|
|
14
|
+
static register(options?: OnboardingModuleOptions): DynamicModule;
|
|
15
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var OnboardingModule_1;
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.OnboardingModule = void 0;
|
|
14
|
+
const common_1 = require("@nestjs/common");
|
|
15
|
+
const index_1 = require("../index");
|
|
16
|
+
const load_1 = require("../load");
|
|
17
|
+
/**
|
|
18
|
+
* Módulo NestJS que serve a apresentação do projeto.
|
|
19
|
+
*
|
|
20
|
+
* app.module.ts:
|
|
21
|
+
* imports: [OnboardingModule.register()]
|
|
22
|
+
*/
|
|
23
|
+
let OnboardingModule = OnboardingModule_1 = class OnboardingModule {
|
|
24
|
+
static register(options = {}) {
|
|
25
|
+
const { path = "onboarding", ...rest } = options;
|
|
26
|
+
let OnboardingController = class OnboardingController {
|
|
27
|
+
async show() {
|
|
28
|
+
if (!(0, load_1.resolveEnabled)(rest))
|
|
29
|
+
throw new common_1.NotFoundException();
|
|
30
|
+
// Páginas de erro (JSON inválido etc.) também são HTML legível, então sempre devolvemos o corpo.
|
|
31
|
+
const page = await (0, index_1.renderOnboardingPage)(rest.file);
|
|
32
|
+
return page.html;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
__decorate([
|
|
36
|
+
(0, common_1.Get)(),
|
|
37
|
+
(0, common_1.Header)("Content-Type", index_1.HTML_HEADERS["Content-Type"]),
|
|
38
|
+
(0, common_1.Header)("Cache-Control", index_1.HTML_HEADERS["Cache-Control"]),
|
|
39
|
+
(0, common_1.Header)("X-Robots-Tag", index_1.HTML_HEADERS["X-Robots-Tag"]),
|
|
40
|
+
__metadata("design:type", Function),
|
|
41
|
+
__metadata("design:paramtypes", []),
|
|
42
|
+
__metadata("design:returntype", Promise)
|
|
43
|
+
], OnboardingController.prototype, "show", null);
|
|
44
|
+
OnboardingController = __decorate([
|
|
45
|
+
(0, common_1.Controller)(path)
|
|
46
|
+
], OnboardingController);
|
|
47
|
+
return { module: OnboardingModule_1, controllers: [OnboardingController] };
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
exports.OnboardingModule = OnboardingModule;
|
|
51
|
+
exports.OnboardingModule = OnboardingModule = OnboardingModule_1 = __decorate([
|
|
52
|
+
(0, common_1.Module)({})
|
|
53
|
+
], OnboardingModule);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OnboardingOptions } from "../types";
|
|
2
|
+
export type { OnboardingOptions };
|
|
3
|
+
/**
|
|
4
|
+
* Route handler para o App Router do Next.js.
|
|
5
|
+
*
|
|
6
|
+
* app/onboarding/route.ts:
|
|
7
|
+
* import { createOnboardingRoute } from "onboarding-kit/next";
|
|
8
|
+
* export const runtime = "nodejs";
|
|
9
|
+
* export const dynamic = "force-dynamic";
|
|
10
|
+
* export const GET = createOnboardingRoute();
|
|
11
|
+
*/
|
|
12
|
+
export declare function createOnboardingRoute(options?: OnboardingOptions): () => Promise<Response>;
|
|
13
|
+
/**
|
|
14
|
+
* Handler para o Pages Router (pages/api/onboarding.ts).
|
|
15
|
+
* Para servir em /onboarding, adicione no next.config:
|
|
16
|
+
* async rewrites() { return [{ source: "/onboarding", destination: "/api/onboarding" }]; }
|
|
17
|
+
*/
|
|
18
|
+
export declare function createOnboardingApiHandler(options?: OnboardingOptions): (_req: unknown, res: {
|
|
19
|
+
status(code: number): {
|
|
20
|
+
send(body: string): void;
|
|
21
|
+
};
|
|
22
|
+
setHeader(name: string, value: string): void;
|
|
23
|
+
}) => Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createOnboardingRoute = createOnboardingRoute;
|
|
4
|
+
exports.createOnboardingApiHandler = createOnboardingApiHandler;
|
|
5
|
+
const index_1 = require("../index");
|
|
6
|
+
const load_1 = require("../load");
|
|
7
|
+
/**
|
|
8
|
+
* Route handler para o App Router do Next.js.
|
|
9
|
+
*
|
|
10
|
+
* app/onboarding/route.ts:
|
|
11
|
+
* import { createOnboardingRoute } from "onboarding-kit/next";
|
|
12
|
+
* export const runtime = "nodejs";
|
|
13
|
+
* export const dynamic = "force-dynamic";
|
|
14
|
+
* export const GET = createOnboardingRoute();
|
|
15
|
+
*/
|
|
16
|
+
function createOnboardingRoute(options = {}) {
|
|
17
|
+
return async function GET() {
|
|
18
|
+
if (!(0, load_1.resolveEnabled)(options)) {
|
|
19
|
+
return new Response("Not Found", { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
const page = await (0, index_1.renderOnboardingPage)(options.file);
|
|
22
|
+
return new Response(page.html, { status: page.status, headers: index_1.HTML_HEADERS });
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Handler para o Pages Router (pages/api/onboarding.ts).
|
|
27
|
+
* Para servir em /onboarding, adicione no next.config:
|
|
28
|
+
* async rewrites() { return [{ source: "/onboarding", destination: "/api/onboarding" }]; }
|
|
29
|
+
*/
|
|
30
|
+
function createOnboardingApiHandler(options = {}) {
|
|
31
|
+
return async function handler(_req, res) {
|
|
32
|
+
if (!(0, load_1.resolveEnabled)(options)) {
|
|
33
|
+
res.status(404).send("Not Found");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const page = await (0, index_1.renderOnboardingPage)(options.file);
|
|
37
|
+
for (const [name, value] of Object.entries(index_1.HTML_HEADERS))
|
|
38
|
+
res.setHeader(name, value);
|
|
39
|
+
res.status(page.status).send(page.html);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OnboardingOptions } from "../types";
|
|
3
|
+
export type { OnboardingOptions };
|
|
4
|
+
/**
|
|
5
|
+
* Handler para Node.js puro, Express, Connect e similares.
|
|
6
|
+
*
|
|
7
|
+
* Express: app.get("/onboarding", createOnboardingHandler());
|
|
8
|
+
* Node: http.createServer((req, res) => req.url === "/onboarding" ? handler(req, res) : ...)
|
|
9
|
+
*/
|
|
10
|
+
export declare function createOnboardingHandler(options?: OnboardingOptions): (_req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createOnboardingHandler = createOnboardingHandler;
|
|
4
|
+
const index_1 = require("../index");
|
|
5
|
+
const load_1 = require("../load");
|
|
6
|
+
/**
|
|
7
|
+
* Handler para Node.js puro, Express, Connect e similares.
|
|
8
|
+
*
|
|
9
|
+
* Express: app.get("/onboarding", createOnboardingHandler());
|
|
10
|
+
* Node: http.createServer((req, res) => req.url === "/onboarding" ? handler(req, res) : ...)
|
|
11
|
+
*/
|
|
12
|
+
function createOnboardingHandler(options = {}) {
|
|
13
|
+
return async function onboardingHandler(_req, res, next) {
|
|
14
|
+
if (!(0, load_1.resolveEnabled)(options)) {
|
|
15
|
+
if (next)
|
|
16
|
+
return next();
|
|
17
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
18
|
+
res.end("Not Found");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const page = await (0, index_1.renderOnboardingPage)(options.file);
|
|
23
|
+
res.writeHead(page.status, index_1.HTML_HEADERS);
|
|
24
|
+
res.end(page.html);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (next)
|
|
28
|
+
return next(err);
|
|
29
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
30
|
+
res.end("Internal Server Error");
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { OnboardingData } from "./types";
|
|
2
|
+
/** Cor em HSL: h 0–360, s e l 0–100. */
|
|
3
|
+
export interface Hsl {
|
|
4
|
+
h: number;
|
|
5
|
+
s: number;
|
|
6
|
+
l: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ResolvedBrand {
|
|
9
|
+
/** Cores da marca, a principal primeiro. */
|
|
10
|
+
colors: Hsl[];
|
|
11
|
+
/** Logo e favicon como data URI, prontos para <img>/<link>. */
|
|
12
|
+
logo?: string;
|
|
13
|
+
favicon?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Lê uma cor CSS: #hex, rgb(), hsl(), oklch(), o trio "222 47% 11%" do shadcn/ui
|
|
17
|
+
* ou um nome da paleta do Tailwind ("indigo", "indigo-600").
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseColor(input: string): Hsl | undefined;
|
|
20
|
+
export declare function toHex({ h, s, l }: Hsl): string;
|
|
21
|
+
/** Cor sem matiz perceptível (preto, branco, cinzas). */
|
|
22
|
+
export declare function isAchromatic(c: Hsl): boolean;
|
|
23
|
+
/** Procura cores nomeadas como brand/primary/accent em CSS, SCSS, configs JS/TS e manifestos. */
|
|
24
|
+
export declare function extractColors(source: string): {
|
|
25
|
+
role: string;
|
|
26
|
+
value: string;
|
|
27
|
+
}[];
|
|
28
|
+
export declare function detectColors(root: string): Hsl[];
|
|
29
|
+
export declare function detectImage(root: string, kind: "logo" | "favicon"): string | undefined;
|
|
30
|
+
/** Lê uma imagem do repositório como data URI, só se estiver dentro da raiz e for uma imagem pequena. */
|
|
31
|
+
export declare function imageDataUri(root: string, path: string): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Junta o que a IA preencheu em `brand` com o que dá para detectar no repositório.
|
|
34
|
+
* O JSON tem prioridade; a detecção cobre o que faltar. Sem `root`, usa só o JSON.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveBrand(data: Partial<OnboardingData> | undefined, root?: string): ResolvedBrand;
|
|
37
|
+
/** Nome do package.json da raiz, para identificar o projeto antes da IA preencher o onboarding. */
|
|
38
|
+
export declare function packageName(root?: string): string | undefined;
|
package/dist/brand.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseColor = parseColor;
|
|
4
|
+
exports.toHex = toHex;
|
|
5
|
+
exports.isAchromatic = isAchromatic;
|
|
6
|
+
exports.extractColors = extractColors;
|
|
7
|
+
exports.detectColors = detectColors;
|
|
8
|
+
exports.detectImage = detectImage;
|
|
9
|
+
exports.imageDataUri = imageDataUri;
|
|
10
|
+
exports.resolveBrand = resolveBrand;
|
|
11
|
+
exports.packageName = packageName;
|
|
12
|
+
const node_fs_1 = require("node:fs");
|
|
13
|
+
const node_path_1 = require("node:path");
|
|
14
|
+
const load_1 = require("./load");
|
|
15
|
+
/* ───────────────────────── cores ───────────────────────── */
|
|
16
|
+
/** Tons 500 da paleta padrão do Tailwind, para configs como `primary: colors.indigo`. */
|
|
17
|
+
const TAILWIND_500 = {
|
|
18
|
+
slate: "#64748b", gray: "#6b7280", zinc: "#71717a", neutral: "#737373", stone: "#78716c",
|
|
19
|
+
red: "#ef4444", orange: "#f97316", amber: "#f59e0b", yellow: "#eab308", lime: "#84cc16",
|
|
20
|
+
green: "#22c55e", emerald: "#10b981", teal: "#14b8a6", cyan: "#06b6d4", sky: "#0ea5e9",
|
|
21
|
+
blue: "#3b82f6", indigo: "#6366f1", violet: "#8b5cf6", purple: "#a855f7", fuchsia: "#d946ef",
|
|
22
|
+
pink: "#ec4899", rose: "#f43f5e",
|
|
23
|
+
};
|
|
24
|
+
const clamp01 = (n) => Math.min(1, Math.max(0, n));
|
|
25
|
+
function num(value, scale = 1) {
|
|
26
|
+
return value.endsWith("%") ? (parseFloat(value) / 100) * scale : parseFloat(value);
|
|
27
|
+
}
|
|
28
|
+
function hslToRgb(h, s, l) {
|
|
29
|
+
s = clamp01(s / 100);
|
|
30
|
+
l = clamp01(l / 100);
|
|
31
|
+
const k = (n) => (n + h / 30) % 12;
|
|
32
|
+
const a = s * Math.min(l, 1 - l);
|
|
33
|
+
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
|
|
34
|
+
return [f(0) * 255, f(8) * 255, f(4) * 255];
|
|
35
|
+
}
|
|
36
|
+
function oklchToRgb(L, C, H) {
|
|
37
|
+
const a = C * Math.cos((H * Math.PI) / 180);
|
|
38
|
+
const b = C * Math.sin((H * Math.PI) / 180);
|
|
39
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
40
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
41
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
42
|
+
const linear = [
|
|
43
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
44
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
45
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
46
|
+
];
|
|
47
|
+
return linear.map((x) => {
|
|
48
|
+
const v = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.abs(x) ** (1 / 2.4) - 0.055;
|
|
49
|
+
return clamp01(v) * 255;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function rgbToHsl([r, g, b]) {
|
|
53
|
+
r /= 255;
|
|
54
|
+
g /= 255;
|
|
55
|
+
b /= 255;
|
|
56
|
+
const max = Math.max(r, g, b);
|
|
57
|
+
const min = Math.min(r, g, b);
|
|
58
|
+
const l = (max + min) / 2;
|
|
59
|
+
const d = max - min;
|
|
60
|
+
if (d === 0)
|
|
61
|
+
return { h: 0, s: 0, l: l * 100 };
|
|
62
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
63
|
+
const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
64
|
+
return { h: (h * 60 + 360) % 360, s: s * 100, l: l * 100 };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Lê uma cor CSS: #hex, rgb(), hsl(), oklch(), o trio "222 47% 11%" do shadcn/ui
|
|
68
|
+
* ou um nome da paleta do Tailwind ("indigo", "indigo-600").
|
|
69
|
+
*/
|
|
70
|
+
function parseColor(input) {
|
|
71
|
+
const value = input.trim().toLowerCase().replace(/\s*!important$/, "");
|
|
72
|
+
const hex = /^#([0-9a-f]{3,8})$/.exec(value);
|
|
73
|
+
if (hex) {
|
|
74
|
+
let digits = hex[1];
|
|
75
|
+
if (digits.length === 3 || digits.length === 4)
|
|
76
|
+
digits = [...digits.slice(0, 3)].map((d) => d + d).join("");
|
|
77
|
+
if (digits.length !== 6 && digits.length !== 8)
|
|
78
|
+
return undefined;
|
|
79
|
+
return rgbToHsl([0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16)));
|
|
80
|
+
}
|
|
81
|
+
const rgb = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/.exec(value);
|
|
82
|
+
if (rgb)
|
|
83
|
+
return rgbToHsl([num(rgb[1], 255), num(rgb[2], 255), num(rgb[3], 255)]);
|
|
84
|
+
const hsl = /^(?:hsla?\(\s*)?([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/.exec(value);
|
|
85
|
+
if (hsl)
|
|
86
|
+
return rgbToHsl(hslToRgb(parseFloat(hsl[1]), parseFloat(hsl[2]), parseFloat(hsl[3])));
|
|
87
|
+
const oklch = /^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:deg)?/.exec(value);
|
|
88
|
+
if (oklch)
|
|
89
|
+
return rgbToHsl(oklchToRgb(num(oklch[1]), num(oklch[2], 0.4), parseFloat(oklch[3])));
|
|
90
|
+
const named = /^([a-z]+)(?:-\d{2,3})?$/.exec(value);
|
|
91
|
+
if (named && TAILWIND_500[named[1]])
|
|
92
|
+
return parseColor(TAILWIND_500[named[1]]);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function toHex({ h, s, l }) {
|
|
96
|
+
return `#${hslToRgb(h, s, l)
|
|
97
|
+
.map((c) => Math.round(c).toString(16).padStart(2, "0"))
|
|
98
|
+
.join("")}`;
|
|
99
|
+
}
|
|
100
|
+
/** Cor sem matiz perceptível (preto, branco, cinzas). */
|
|
101
|
+
function isAchromatic(c) {
|
|
102
|
+
return c.s < 12 || c.l < 6 || c.l > 96;
|
|
103
|
+
}
|
|
104
|
+
/** Remove cores praticamente iguais, mantendo a ordem. */
|
|
105
|
+
function dedupe(colors) {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const c of colors) {
|
|
108
|
+
const same = out.some((o) => {
|
|
109
|
+
if (isAchromatic(o) && isAchromatic(c))
|
|
110
|
+
return true;
|
|
111
|
+
const dh = Math.min(Math.abs(o.h - c.h), 360 - Math.abs(o.h - c.h));
|
|
112
|
+
return dh < 14 && Math.abs(o.s - c.s) < 30;
|
|
113
|
+
});
|
|
114
|
+
if (!same)
|
|
115
|
+
out.push(c);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/* ───────────────────────── detecção ───────────────────────── */
|
|
120
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".output", ".svelte-kit", "coverage", ".turbo", ".vercel", ".cache", "vendor"]);
|
|
121
|
+
const STYLE_FILE = /(^tailwind\.config\.[cm]?[jt]s$)|(\.(css|scss|sass|less)$)|(^(theme|colors|tokens|palette|brand)\.[cm]?[jt]sx?$)|(^(manifest\.json|site\.webmanifest|manifest\.webmanifest)$)/i;
|
|
122
|
+
const MAX_FILES = 40;
|
|
123
|
+
const MAX_STYLE_BYTES = 300_000;
|
|
124
|
+
/** Peso do nome da variável: quanto menor, mais "cara da marca". */
|
|
125
|
+
const ROLE_WEIGHT = { brand: 0, primary: 1, theme_color: 1.5, themecolor: 1.5, accent: 2, secondary: 3 };
|
|
126
|
+
function listStyleFiles(root) {
|
|
127
|
+
const found = [];
|
|
128
|
+
const walk = (dir, depth) => {
|
|
129
|
+
if (depth > 4 || found.length >= MAX_FILES)
|
|
130
|
+
return;
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (found.length >= MAX_FILES)
|
|
140
|
+
return;
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith("."))
|
|
143
|
+
walk((0, node_path_1.join)(dir, entry.name), depth + 1);
|
|
144
|
+
}
|
|
145
|
+
else if (STYLE_FILE.test(entry.name) && !entry.name.endsWith(".min.css")) {
|
|
146
|
+
found.push((0, node_path_1.join)(dir, entry.name));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
walk(root, 0);
|
|
151
|
+
// configs do Tailwind e manifestos primeiro: são as fontes mais confiáveis
|
|
152
|
+
return found.sort((a, b) => Number(!/tailwind\.config|manifest/i.test(a)) - Number(!/tailwind\.config|manifest/i.test(b)));
|
|
153
|
+
}
|
|
154
|
+
/** Procura cores nomeadas como brand/primary/accent em CSS, SCSS, configs JS/TS e manifestos. */
|
|
155
|
+
function extractColors(source) {
|
|
156
|
+
const found = [];
|
|
157
|
+
const add = (role, value, index) => found.push({ role: role.toLowerCase(), value: value.trim(), index });
|
|
158
|
+
const ROLE = "(brand|primary|accent|secondary)";
|
|
159
|
+
const SHADE = "(?:[-_](?:color|default|main|base|500|600))?";
|
|
160
|
+
// CSS: --primary, --color-brand-500 (Tailwind v4 @theme), --brand-color
|
|
161
|
+
for (const m of source.matchAll(new RegExp(`--(?:color-)?${ROLE}${SHADE}\\s*:\\s*([^;}{]+)`, "gi")))
|
|
162
|
+
add(m[1], m[2], m.index ?? 0);
|
|
163
|
+
// SCSS / LESS: $primary, @brand-color
|
|
164
|
+
for (const m of source.matchAll(new RegExp(`[$@]${ROLE}${SHADE}\\s*:\\s*([^;\\n]+)`, "gi")))
|
|
165
|
+
add(m[1], m[2], m.index ?? 0);
|
|
166
|
+
// JS/TS/JSON: primary: "#..." / "brand": "#..." / themeColor: "#..."
|
|
167
|
+
for (const m of source.matchAll(/["']?(brand|primary|accent|secondary|theme_color|themeColor)["']?\s*:\s*(["'`])([^"'`\n]+)\2/gi))
|
|
168
|
+
add(m[1], m[3], m.index ?? 0);
|
|
169
|
+
// JS/TS: primary: { DEFAULT: "#...", 500: "#..." } e MUI primary: { main: "#..." }
|
|
170
|
+
for (const m of source.matchAll(new RegExp(`["']?${ROLE}["']?\\s*:\\s*\\{([^{}]*)\\}`, "gi"))) {
|
|
171
|
+
const inner = /["']?(?:DEFAULT|main|500|600)["']?\s*:\s*(["'`])([^"'`\n]+)\1/i.exec(m[2]);
|
|
172
|
+
if (inner)
|
|
173
|
+
add(m[1], inner[2], m.index ?? 0);
|
|
174
|
+
}
|
|
175
|
+
// Tailwind: primary: colors.indigo
|
|
176
|
+
for (const m of source.matchAll(new RegExp(`["']?${ROLE}["']?\\s*:\\s*colors\\.(\\w+)`, "gi")))
|
|
177
|
+
add(m[1], m[2], m.index ?? 0);
|
|
178
|
+
return found.sort((a, b) => a.index - b.index).map(({ role, value }) => ({ role, value }));
|
|
179
|
+
}
|
|
180
|
+
function detectColors(root) {
|
|
181
|
+
const candidates = [];
|
|
182
|
+
let order = 0;
|
|
183
|
+
for (const file of listStyleFiles(root)) {
|
|
184
|
+
let source;
|
|
185
|
+
try {
|
|
186
|
+
if ((0, node_fs_1.statSync)(file).size > MAX_STYLE_BYTES)
|
|
187
|
+
continue;
|
|
188
|
+
source = (0, node_fs_1.readFileSync)(file, "utf8");
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
for (const { role, value } of extractColors(source)) {
|
|
194
|
+
const color = parseColor(value);
|
|
195
|
+
if (!color || color.l > 96)
|
|
196
|
+
continue; // branco não serve como cor de destaque
|
|
197
|
+
const weight = (ROLE_WEIGHT[role] ?? 4) + (isAchromatic(color) ? 2.5 : 0);
|
|
198
|
+
candidates.push({ color, weight, order: order++ });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
candidates.sort((a, b) => a.weight - b.weight || a.order - b.order);
|
|
202
|
+
return dedupe(candidates.map((c) => c.color)).slice(0, 5);
|
|
203
|
+
}
|
|
204
|
+
const IMAGE_TYPES = {
|
|
205
|
+
".svg": "image/svg+xml",
|
|
206
|
+
".png": "image/png",
|
|
207
|
+
".webp": "image/webp",
|
|
208
|
+
".avif": "image/avif",
|
|
209
|
+
".jpg": "image/jpeg",
|
|
210
|
+
".jpeg": "image/jpeg",
|
|
211
|
+
".gif": "image/gif",
|
|
212
|
+
".ico": "image/x-icon",
|
|
213
|
+
};
|
|
214
|
+
const IMAGE_DIRS = ["public", "public/images", "public/img", "public/assets", "public/static", "static", "assets", "src/assets", "src/assets/images", "app", "src/app"];
|
|
215
|
+
/** Arquivos que vêm nos templates do create-next-app, Vite etc. e não são a marca do projeto. */
|
|
216
|
+
const BOILERPLATE = /^(next|vercel|vite|react|file|globe|window|nuxt|svelte|angular|placeholder)\.(svg|png)$/i;
|
|
217
|
+
const MAX_IMAGE_BYTES = 400_000;
|
|
218
|
+
function imageScore(name, kind) {
|
|
219
|
+
const base = name.toLowerCase();
|
|
220
|
+
if (BOILERPLATE.test(base))
|
|
221
|
+
return undefined;
|
|
222
|
+
const ext = (0, node_path_1.extname)(base);
|
|
223
|
+
if (!IMAGE_TYPES[ext])
|
|
224
|
+
return undefined;
|
|
225
|
+
const extRank = [".svg", ".png", ".webp", ".avif", ".ico", ".jpg", ".jpeg", ".gif"].indexOf(ext) / 10;
|
|
226
|
+
const variant = /(white|light|inverse|negative|mono)/.test(base) ? 0.5 : 0;
|
|
227
|
+
const rank = kind === "logo"
|
|
228
|
+
? /logo/.test(base) ? 0 : /brand|wordmark/.test(base) ? 0.5 : /mark|symbol/.test(base) ? 1 : /^(icon|apple-icon|apple-touch-icon)/.test(base) ? 2 : /^favicon/.test(base) ? 3 : undefined
|
|
229
|
+
: /^favicon/.test(base) ? 0 : /^icon\./.test(base) ? 0.5 : /apple-(touch-)?icon/.test(base) ? 1 : /logo/.test(base) ? 2 : undefined;
|
|
230
|
+
return rank === undefined ? undefined : rank + variant + extRank;
|
|
231
|
+
}
|
|
232
|
+
function detectImage(root, kind) {
|
|
233
|
+
let best;
|
|
234
|
+
for (const dir of IMAGE_DIRS) {
|
|
235
|
+
let names;
|
|
236
|
+
try {
|
|
237
|
+
names = (0, node_fs_1.readdirSync)((0, node_path_1.join)(root, dir));
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
for (const name of names) {
|
|
243
|
+
const score = imageScore(name, kind);
|
|
244
|
+
if (score === undefined || (best && best.score <= score))
|
|
245
|
+
continue;
|
|
246
|
+
const path = `${dir}/${name}`;
|
|
247
|
+
try {
|
|
248
|
+
if ((0, node_fs_1.statSync)((0, node_path_1.join)(root, path)).isFile())
|
|
249
|
+
best = { path, score };
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// ignora
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return best?.path;
|
|
257
|
+
}
|
|
258
|
+
/** Lê uma imagem do repositório como data URI, só se estiver dentro da raiz e for uma imagem pequena. */
|
|
259
|
+
function imageDataUri(root, path) {
|
|
260
|
+
const full = (0, node_path_1.resolve)(root, path);
|
|
261
|
+
const rel = (0, node_path_1.relative)(root, full);
|
|
262
|
+
if (!rel || rel.startsWith("..") || rel.split(node_path_1.sep).includes(".."))
|
|
263
|
+
return undefined;
|
|
264
|
+
const type = IMAGE_TYPES[(0, node_path_1.extname)(full).toLowerCase()];
|
|
265
|
+
if (!type || !(0, node_fs_1.existsSync)(full))
|
|
266
|
+
return undefined;
|
|
267
|
+
try {
|
|
268
|
+
if ((0, node_fs_1.statSync)(full).size > MAX_IMAGE_BYTES)
|
|
269
|
+
return undefined;
|
|
270
|
+
return `data:${type};base64,${(0, node_fs_1.readFileSync)(full).toString("base64")}`;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/* ───────────────────────── resolução ───────────────────────── */
|
|
277
|
+
const cache = new Map();
|
|
278
|
+
const CACHE_MS = 5_000;
|
|
279
|
+
function detect(root) {
|
|
280
|
+
const hit = cache.get(root);
|
|
281
|
+
if (hit && Date.now() - hit.at < CACHE_MS)
|
|
282
|
+
return hit;
|
|
283
|
+
const value = { at: Date.now(), colors: detectColors(root), logo: detectImage(root, "logo"), favicon: detectImage(root, "favicon") };
|
|
284
|
+
cache.set(root, value);
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Junta o que a IA preencheu em `brand` com o que dá para detectar no repositório.
|
|
289
|
+
* O JSON tem prioridade; a detecção cobre o que faltar. Sem `root`, usa só o JSON.
|
|
290
|
+
*/
|
|
291
|
+
function resolveBrand(data, root) {
|
|
292
|
+
const b = data?.brand;
|
|
293
|
+
const declared = dedupe([b?.primaryColor, ...(Array.isArray(b?.palette) ? b.palette : [])]
|
|
294
|
+
.filter((v) => typeof v === "string" && !(0, load_1.isBlank)(v))
|
|
295
|
+
.map(parseColor)
|
|
296
|
+
.filter((c) => !!c));
|
|
297
|
+
const found = root ? detect(root) : undefined;
|
|
298
|
+
const colors = declared.length ? dedupe([...declared, ...(declared.length < 2 ? found?.colors ?? [] : [])]).slice(0, 5) : found?.colors ?? [];
|
|
299
|
+
const image = (declaredPath, detected) => {
|
|
300
|
+
if (!root)
|
|
301
|
+
return undefined;
|
|
302
|
+
return (declaredPath && !(0, load_1.isBlank)(declaredPath) ? imageDataUri(root, declaredPath) : undefined) ?? (detected ? imageDataUri(root, detected) : undefined);
|
|
303
|
+
};
|
|
304
|
+
const logo = image(b?.logo, found?.logo);
|
|
305
|
+
const favicon = image(b?.favicon, found?.favicon) ?? logo;
|
|
306
|
+
return { colors, logo, favicon };
|
|
307
|
+
}
|
|
308
|
+
/** Nome do package.json da raiz, para identificar o projeto antes da IA preencher o onboarding. */
|
|
309
|
+
function packageName(root) {
|
|
310
|
+
if (!root)
|
|
311
|
+
return undefined;
|
|
312
|
+
try {
|
|
313
|
+
const name = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, "package.json"), "utf8")).name;
|
|
314
|
+
return typeof name === "string" && name.trim() ? name.trim() : undefined;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { Framework } from "../types";
|
|
3
|
+
export declare function detectFramework(pkg: Record<string, any> | undefined): Framework;
|
|
4
|
+
/** Adiciona o OnboardingModule ao app.module.ts quando dá para fazer isso com segurança. */
|
|
5
|
+
export declare function patchNestAppModule(source: string): string | undefined;
|