Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6c5d255
fix(blocks): renderJson lê o site block com a chave que o decofile re…
JonasJesus42 Aug 21, 2026
0e8d079
fix(blocks): resolver o stub de requestContextStorage no React Native
JonasJesus42 Aug 22, 2026
c627d90
refactor(blocks): mergeSections vira parte pública de @decocms/blocks…
JonasJesus42 Aug 22, 2026
381b565
feat(native): @decocms/native — binding de app para React Native / Expo
JonasJesus42 Aug 22, 2026
ae91779
feat(native): cookie jar + invoke — o que destrava carrinho e login
JonasJesus42 Aug 22, 2026
023473c
feat(cli,native): --platform native + tabela de rotas do CMS gerada
JonasJesus42 Aug 22, 2026
bd64f72
feat(cli): --platform native gera o mapa tipado de invoke para o app
JonasJesus42 Aug 22, 2026
6ba6b35
fix(blocks): schema.ts compila sob `strict` de consumidor
JonasJesus42 Aug 22, 2026
7310eaa
feat(native): deco-native init — encoda os achados da migração como s…
JonasJesus42 Aug 22, 2026
ad68df5
feat(native): campanhas de push como conteúdo do CMS
JonasJesus42 Aug 22, 2026
f788d49
fix(native): push sai do barrel raiz — ele quebrava todo bundle nativo
JonasJesus42 Aug 22, 2026
d5528f0
fix(blocks): NestedFromFlat nunca funcionou — o invoke tipado dava `n…
JonasJesus42 Aug 22, 2026
00b68fc
feat(native): tradutores DaisyUI + ponte de cookie com o WebView
JonasJesus42 Aug 22, 2026
61360dd
feat(native): SiteView no pacote e init que entrega um app que abre
JonasJesus42 Aug 22, 2026
280267f
fix(native): a plataforma já compartilha a sessão — o jar é que quebrava
JonasJesus42 Aug 22, 2026
b39ce02
fix(native): força singleton no Metro e faz os tipos do daisy viajarem
JonasJesus42 Aug 22, 2026
9fea0db
fix(native): o pacote passa a declarar as próprias fontes de classe
JonasJesus42 Aug 22, 2026
05254ce
feat(native): segura a página anterior enquanto a próxima carrega
JonasJesus42 Aug 22, 2026
7a191f0
feat(native): DecoImage — imagem do CMS redimensionada, cacheada e ad…
JonasJesus42 Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
879 changes: 871 additions & 8 deletions bun.lock

Large diffs are not rendered by default.

210 changes: 210 additions & 0 deletions packages/blocks-cli/scripts/generate-invoke-native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { collectHandlers, renderModule } from "./generate-invoke-native";

const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});

/** Builds a site-shaped tree: src/loaders, src/actions, and an out dir. */
function site(files: Record<string, string>) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "deco-invoke-native-"));
dirs.push(root);
for (const [rel, contents] of Object.entries(files)) {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, contents);
}
fs.mkdirSync(path.join(root, ".deco"), { recursive: true });
return {
root,
collect: () =>
collectHandlers({
loadersDir: path.join(root, "src/loaders"),
actionsDir: path.join(root, "src/actions"),
outDir: path.join(root, ".deco"),
}),
};
}

describe("collectHandlers — keys", () => {
it("derives invoke keys from the file path", () => {
const { collect } = site({
"src/actions/newsletter/subscribe.ts":
"export default async function a(p: { email: string }): Promise<void> {}",
"src/loaders/user.ts": "export default async function l(): Promise<void> {}",
});
expect(
collect()
.entries.map((e) => e.key)
.sort(),
).toEqual(["site/actions/newsletter/subscribe", "site/loaders/user"]);
});

it("ignores `_`-prefixed helper modules", () => {
// Same convention generate-loaders uses — those are not handlers.
const { collect } = site({
"src/loaders/_cookie.ts": "export const read = () => null;",
"src/loaders/user.ts": "export default async function l(): Promise<void> {}",
});
expect(collect().entries.map((e) => e.key)).toEqual(["site/loaders/user"]);
});
});

describe("collectHandlers — types", () => {
it("reads the input type and unwraps Promise from the output", () => {
const { collect } = site({
"src/actions/x.ts": `
export interface In { a: string }
export interface Out { b: number }
export default async function action(props: In): Promise<Out> { return { b: 1 }; }
`,
});
const [entry] = collect().entries;
expect(entry.input).toBe("In");
expect(entry.output).toBe("Out");
});

it("imports a type from where it is ACTUALLY declared, not the handler file", () => {
// The bug this exists for: assuming the handler exports every type it
// mentions emits `import type { Person } from "../src/loaders/user"` and
// TS2614s. Person really lives in a package.
const { collect } = site({
"src/loaders/user.ts": `
import type { Person } from "@decocms/apps-commerce/types";
export default async function l(): Promise<Person | null> { return null; }
`,
});
expect(collect().entries[0].imports).toEqual([
{ name: "Person", source: "@decocms/apps-commerce/types" },
]);
});

it("rewrites a relative origin to be relative to the output file", () => {
const { collect } = site({
"src/platform/types.ts": "export interface State { ok: boolean }",
"src/loaders/wishlist.ts": `
import type { State } from "../platform/types";
export default async function l(): Promise<State> { return { ok: true }; }
`,
});
expect(collect().entries[0].imports[0].source).toBe("../src/platform/types");
});

it("degrades a local non-exported type to unknown, and says so", () => {
// It cannot be named from outside its file at all — emitting an import
// for it would not compile.
const { collect } = site({
"src/actions/y.ts": `
interface Props { a: string }
export default async function action(props: Props): Promise<void> {}
`,
});
const { entries, skipped } = collect();
expect(entries[0].input).toBe("unknown");
expect(skipped.join()).toContain("degraded to unknown");
});

it("falls back to unknown when the handler has no type annotations", () => {
const { collect } = site({ "src/actions/z.ts": "export default async function a(p) {}" });
expect(collect().entries[0]).toMatchObject({ input: "unknown", output: "unknown" });
});

it("refuses to type a privileged action even if the site defines one", () => {
// Mirrors generate-invoke's deny-list: these take a caller-supplied
// `entity` against admin-credentialed MasterData.
const { collect } = site({
"src/actions/searchDocuments.ts":
"export default async function a(p: { entity: string }): Promise<unknown> { return null; }",
});
const { entries, skipped } = collect();
expect(entries).toHaveLength(0);
expect(skipped.join()).toContain("privileged action");
});
});

describe("renderModule", () => {
it("aliases a name declared by two different modules", () => {
// Without this, two `Props` produce a duplicate identifier.
const entries = [
{
key: "site/actions/a/one",
input: "Props",
output: "void",
imports: [{ name: "Props", source: "../src/actions/a/one" }],
},
{
key: "site/actions/b/two",
input: "Props",
output: "void",
imports: [{ name: "Props", source: "../src/actions/b/two" }],
},
];
const source = renderModule(entries);
expect(source).toContain("Props as Props_a_one");
expect(source).toContain("Props as Props_b_two");
expect(source).toContain("props: Props_a_one");
});

it("does not alias the same name coming from one module", () => {
const entries = [
{
key: "site/loaders/one",
input: "unknown",
output: "State",
imports: [{ name: "State", source: "../src/types" }],
},
{
key: "site/loaders/two",
input: "unknown",
output: "State",
imports: [{ name: "State", source: "../src/types" }],
},
];
const source = renderModule(entries);
expect(source).not.toContain(" as State_");
expect(source.match(/from "\.\.\/src\/types"/g)).toHaveLength(1);
});

it("emits types only — nothing that could reach a bundle", () => {
// The opt-in property: a site that upgrades and never imports this file is
// completely unaffected. Every import must be `import type`, which TS
// erases, and nothing may reference createServerFn.
const source = renderModule([
{
key: "site/actions/newsletter/subscribe",
input: "SubscribeProps",
output: "void",
imports: [{ name: "SubscribeProps", source: "../src/actions/newsletter/subscribe" }],
},
]);
const importStatements = source.match(/^import .*/gm) ?? [];
expect(importStatements.length).toBeGreaterThan(0);
expect(importStatements.every((line) => line.startsWith("import type "))).toBe(true);
expect(source).not.toContain("createServerFn");
expect(source).toContain("TYPES ONLY");
});

it("emits no import at all when nothing needs one", () => {
const source = renderModule([
{ key: "site/loaders/x", input: "unknown", output: "void", imports: [] },
]);
expect(source.match(/^import .*/gm)).toBeNull();
expect(source).toContain('"site/loaders/x"');
});

it("is deterministic, so the incremental digest stays stable", () => {
const entries = [
{
key: "site/loaders/x",
input: "unknown",
output: "A",
imports: [{ name: "A", source: "../a" }],
},
];
expect(renderModule(entries)).toBe(renderModule(entries));
});
});
Loading
Loading