@palettelab/cli 0.3.37 → 0.3.39
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 +31 -1
- package/backend-sdk/palette_sdk/manifest.py +1 -1
- package/lib/app-router.js +207 -0
- package/lib/bundler.js +220 -30
- package/lib/commands/init.js +1 -1
- package/lib/commands/test.js +7 -3
- package/lib/dev-simulator.js +5 -1
- package/lib/manifest.js +2 -2
- package/package.json +1 -1
- package/template-fallback/package.json +1 -1
- package/template-fallback/palette-plugin.json +1 -1
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/database/palette-plugin.json +1 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/external-service/palette-plugin.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/next/palette-plugin.json +1 -1
- package/template-fallback/templates/palette-app/README.md +19 -0
- package/template-fallback/templates/palette-app/backend/api/main.py +9 -0
- package/template-fallback/templates/palette-app/frontend/app/layout.tsx +9 -0
- package/template-fallback/templates/palette-app/frontend/app/meetings/[meetingId]/page.tsx +16 -0
- package/template-fallback/templates/palette-app/frontend/app/not-found.tsx +10 -0
- package/template-fallback/templates/palette-app/frontend/app/page.tsx +19 -0
- package/template-fallback/templates/palette-app/package.json +13 -0
- package/template-fallback/templates/palette-app/palette-plugin.json +31 -0
- package/template-fallback/templates/palette-app/pyproject.toml +9 -0
package/README.md
CHANGED
|
@@ -390,12 +390,42 @@ Creates `data-explorer/` with a valid `palette-plugin.json`, a frontend React en
|
|
|
390
390
|
Templates:
|
|
391
391
|
|
|
392
392
|
- `dashboard`
|
|
393
|
+
- `palette-app`
|
|
393
394
|
- `next`
|
|
394
395
|
- `agent-tool`
|
|
395
396
|
- `external-service`
|
|
396
397
|
- `database`
|
|
397
398
|
- `frontend-only`
|
|
398
399
|
|
|
400
|
+
### Palette App Router
|
|
401
|
+
|
|
402
|
+
Use the `palette-app` template when you want Next-style app-directory UI
|
|
403
|
+
structure without running a Next server:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
pltt init meeting-app --template palette-app
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The manifest points `frontend.entry` at an app directory:
|
|
410
|
+
|
|
411
|
+
```json
|
|
412
|
+
{
|
|
413
|
+
"frontend": {
|
|
414
|
+
"entry": "./frontend/app",
|
|
415
|
+
"sandbox": true,
|
|
416
|
+
"framework": "palette-app"
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Supported files are `layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`, and
|
|
422
|
+
`not-found.tsx`. Routes support static segments, route groups, `[id]`,
|
|
423
|
+
`[...slug]`, and `[[...slug]]`. `next/link` and the client hooks from
|
|
424
|
+
`next/navigation` are mapped to Palette's browser router at build time.
|
|
425
|
+
|
|
426
|
+
This is UI routing only. Keep API routes, database work, secrets, permissions,
|
|
427
|
+
and jobs in the Python backend.
|
|
428
|
+
|
|
399
429
|
### Next-Compatible Frontend Config
|
|
400
430
|
|
|
401
431
|
Palette native apps publish as a single React module loaded by the OS. They do
|
|
@@ -630,7 +660,7 @@ pltt test
|
|
|
630
660
|
pltt test --json
|
|
631
661
|
```
|
|
632
662
|
|
|
633
|
-
Checks include manifest validity, SDK/platform compatibility, semver bump detection, forbidden platform imports, frontend bundling and size limits, sandbox bridge smoke, backend dependency installation/import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`.
|
|
663
|
+
Checks include manifest validity, SDK/platform compatibility, semver bump detection, forbidden platform imports, frontend bundling and size limits, sandbox bridge smoke, backend dependency installation/import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`. The default frontend bundle limit is 2 MiB; set `PALETTE_MAX_FRONTEND_BUNDLE_BYTES` for reviewed exceptions.
|
|
634
664
|
|
|
635
665
|
### `pltt package`
|
|
636
666
|
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const path = require("path")
|
|
5
|
+
|
|
6
|
+
const ROUTE_FILES = new Set(["layout.tsx", "layout.ts", "layout.jsx", "layout.js", "page.tsx", "page.ts", "page.jsx", "page.js"])
|
|
7
|
+
const OPTIONAL_FILES = new Set([
|
|
8
|
+
"loading.tsx",
|
|
9
|
+
"loading.ts",
|
|
10
|
+
"loading.jsx",
|
|
11
|
+
"loading.js",
|
|
12
|
+
"error.tsx",
|
|
13
|
+
"error.ts",
|
|
14
|
+
"error.jsx",
|
|
15
|
+
"error.js",
|
|
16
|
+
"not-found.tsx",
|
|
17
|
+
"not-found.ts",
|
|
18
|
+
"not-found.jsx",
|
|
19
|
+
"not-found.js",
|
|
20
|
+
])
|
|
21
|
+
const UNSUPPORTED_FILES = new Set([
|
|
22
|
+
"route.ts",
|
|
23
|
+
"route.tsx",
|
|
24
|
+
"route.js",
|
|
25
|
+
"route.jsx",
|
|
26
|
+
"middleware.ts",
|
|
27
|
+
"middleware.tsx",
|
|
28
|
+
"middleware.js",
|
|
29
|
+
"middleware.jsx",
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
function isRouteGroup(segment) {
|
|
33
|
+
return /^\(.+\)$/.test(segment)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function segmentToRoute(segment) {
|
|
37
|
+
if (isRouteGroup(segment)) return null
|
|
38
|
+
const optionalCatchAll = segment.match(/^\[\[\.\.\.([A-Za-z0-9_]+)\]\]$/)
|
|
39
|
+
if (optionalCatchAll) return { kind: "catchAll", name: optionalCatchAll[1], optional: true }
|
|
40
|
+
const catchAll = segment.match(/^\[\.\.\.([A-Za-z0-9_]+)\]$/)
|
|
41
|
+
if (catchAll) return { kind: "catchAll", name: catchAll[1] }
|
|
42
|
+
const dynamic = segment.match(/^\[([A-Za-z0-9_]+)\]$/)
|
|
43
|
+
if (dynamic) return { kind: "dynamic", name: dynamic[1] }
|
|
44
|
+
return { kind: "static", value: segment }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scoreSegments(segments) {
|
|
48
|
+
return segments.reduce((score, segment) => {
|
|
49
|
+
if (segment.kind === "static") return score + 10
|
|
50
|
+
if (segment.kind === "dynamic") return score + 5
|
|
51
|
+
return score + (segment.optional ? 1 : 2)
|
|
52
|
+
}, segments.length)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findFirstFile(dir, names) {
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const abs = path.join(dir, name)
|
|
58
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function routeFile(dir, base) {
|
|
64
|
+
return findFirstFile(dir, [`${base}.tsx`, `${base}.ts`, `${base}.jsx`, `${base}.js`])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walk(dir, appDir, issues, routes) {
|
|
68
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.name === "node_modules" || entry.name === ".palette" || entry.name === "dist") continue
|
|
71
|
+
const abs = path.join(dir, entry.name)
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
walk(abs, appDir, issues, routes)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (!entry.isFile()) continue
|
|
77
|
+
if (UNSUPPORTED_FILES.has(entry.name)) {
|
|
78
|
+
issues.push(`${path.relative(appDir, abs)} is not supported in palette-app mode; put API/server logic in backend/`)
|
|
79
|
+
}
|
|
80
|
+
if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
81
|
+
const src = fs.readFileSync(abs, "utf8")
|
|
82
|
+
if (/^\s*["']use server["']/m.test(src)) {
|
|
83
|
+
issues.push(`${path.relative(appDir, abs)} uses "use server", which is not supported in palette-app mode`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const page = routeFile(dir, "page")
|
|
89
|
+
if (!page) return
|
|
90
|
+
|
|
91
|
+
const relDir = path.relative(appDir, dir)
|
|
92
|
+
const dirs = relDir ? relDir.split(path.sep) : []
|
|
93
|
+
const segments = dirs.map(segmentToRoute).filter(Boolean)
|
|
94
|
+
const layoutDirs = [appDir]
|
|
95
|
+
let cursor = appDir
|
|
96
|
+
for (const segment of dirs) {
|
|
97
|
+
cursor = path.join(cursor, segment)
|
|
98
|
+
layoutDirs.push(cursor)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
routes.push({
|
|
102
|
+
id: relDir || "__root__",
|
|
103
|
+
page,
|
|
104
|
+
segments,
|
|
105
|
+
score: scoreSegments(segments),
|
|
106
|
+
layouts: layoutDirs.map((layoutDir) => routeFile(layoutDir, "layout")).filter(Boolean),
|
|
107
|
+
loading: routeFile(dir, "loading"),
|
|
108
|
+
error: routeFile(dir, "error"),
|
|
109
|
+
notFound: routeFile(dir, "not-found"),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scanPaletteAppRoutes(appDir) {
|
|
114
|
+
if (!fs.existsSync(appDir)) {
|
|
115
|
+
throw new Error(`palette-app entry directory not found: ${appDir}`)
|
|
116
|
+
}
|
|
117
|
+
if (!fs.statSync(appDir).isDirectory()) {
|
|
118
|
+
throw new Error(`palette-app entry must be a directory: ${appDir}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const issues = []
|
|
122
|
+
const routes = []
|
|
123
|
+
walk(appDir, appDir, issues, routes)
|
|
124
|
+
if (issues.length) {
|
|
125
|
+
throw new Error(`palette-app route scan failed:\n${issues.map((issue) => `- ${issue}`).join("\n")}`)
|
|
126
|
+
}
|
|
127
|
+
if (routes.length === 0) {
|
|
128
|
+
throw new Error("palette-app requires at least one page.tsx/page.ts/page.jsx/page.js file")
|
|
129
|
+
}
|
|
130
|
+
return routes.sort((a, b) => b.score - a.score)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function importName(prefix, index) {
|
|
134
|
+
return `${prefix}${index}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function importPath(absPath) {
|
|
138
|
+
return JSON.stringify(absPath)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function generatePaletteAppEntry(pluginDir, appEntry, outfile) {
|
|
142
|
+
const appDir = path.resolve(pluginDir, appEntry || "./frontend/app")
|
|
143
|
+
const routes = scanPaletteAppRoutes(appDir)
|
|
144
|
+
const importLines = ['import { PaletteAppRouter } from "@palettelab/sdk/router"']
|
|
145
|
+
const routeObjects = []
|
|
146
|
+
let importIndex = 0
|
|
147
|
+
|
|
148
|
+
for (const route of routes) {
|
|
149
|
+
const pageName = importName("Page", importIndex++)
|
|
150
|
+
importLines.push(`import ${pageName} from ${importPath(route.page)}`)
|
|
151
|
+
const layoutNames = []
|
|
152
|
+
for (const layout of route.layouts) {
|
|
153
|
+
const name = importName("Layout", importIndex++)
|
|
154
|
+
importLines.push(`import ${name} from ${importPath(layout)}`)
|
|
155
|
+
layoutNames.push(name)
|
|
156
|
+
}
|
|
157
|
+
const optional = {}
|
|
158
|
+
for (const key of ["loading", "error", "notFound"]) {
|
|
159
|
+
if (!route[key]) continue
|
|
160
|
+
const name = importName(key.replace("-", ""), importIndex++)
|
|
161
|
+
importLines.push(`import ${name} from ${importPath(route[key])}`)
|
|
162
|
+
optional[key] = name
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
routeObjects.push(`{
|
|
166
|
+
id: ${JSON.stringify(route.id)},
|
|
167
|
+
segments: ${JSON.stringify(route.segments)},
|
|
168
|
+
score: ${route.score},
|
|
169
|
+
page: ${pageName},
|
|
170
|
+
layouts: [${layoutNames.join(", ")}],
|
|
171
|
+
${optional.loading ? `loading: ${optional.loading},` : ""}
|
|
172
|
+
${optional.error ? `error: ${optional.error},` : ""}
|
|
173
|
+
${optional.notFound ? `notFound: ${optional.notFound},` : ""}
|
|
174
|
+
}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rootNotFound = routeFile(appDir, "not-found")
|
|
178
|
+
let rootNotFoundLine = ""
|
|
179
|
+
let rootNotFoundProp = ""
|
|
180
|
+
if (rootNotFound) {
|
|
181
|
+
rootNotFoundLine = `import RootNotFound from ${importPath(rootNotFound)}`
|
|
182
|
+
rootNotFoundProp = " notFound={RootNotFound}"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const source = `${importLines.join("\n")}
|
|
186
|
+
${rootNotFoundLine}
|
|
187
|
+
|
|
188
|
+
const routes = [
|
|
189
|
+
${routeObjects.join(",\n")}
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
export default function PaletteGeneratedApp() {
|
|
193
|
+
return <PaletteAppRouter routes={routes}${rootNotFoundProp} />
|
|
194
|
+
}
|
|
195
|
+
`
|
|
196
|
+
fs.mkdirSync(path.dirname(outfile), { recursive: true })
|
|
197
|
+
fs.writeFileSync(outfile, source)
|
|
198
|
+
return outfile
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
generatePaletteAppEntry,
|
|
203
|
+
scanPaletteAppRoutes,
|
|
204
|
+
ROUTE_FILES,
|
|
205
|
+
OPTIONAL_FILES,
|
|
206
|
+
UNSUPPORTED_FILES,
|
|
207
|
+
}
|
package/lib/bundler.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const path = require("path")
|
|
4
4
|
const fs = require("fs")
|
|
5
5
|
const os = require("os")
|
|
6
|
+
const { generatePaletteAppEntry } = require("./app-router")
|
|
6
7
|
|
|
7
8
|
const NEXT_CONFIG_NAMES = [
|
|
8
9
|
"frontend/next.config.ts",
|
|
@@ -29,6 +30,10 @@ function frontendFramework(frontend = {}) {
|
|
|
29
30
|
return frontend.framework || "react"
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function isPaletteApp(frontend = {}) {
|
|
34
|
+
return frontendFramework(frontend) === "palette-app"
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
function resolveNextConfigPath(pluginDir, frontend = {}) {
|
|
33
38
|
if (frontend.config) {
|
|
34
39
|
const explicit = path.resolve(pluginDir, frontend.config)
|
|
@@ -170,14 +175,188 @@ function makeTsconfigPathsPlugin(pluginDir, frontend = {}) {
|
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
function makePaletteAppNextCompatPlugin(pluginDir) {
|
|
179
|
+
const routerRuntime = `
|
|
180
|
+
import React, { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
|
181
|
+
import { usePlatform } from "@palettelab/sdk"
|
|
182
|
+
|
|
183
|
+
const RouterCtx = createContext(null)
|
|
184
|
+
const NOT_FOUND = Symbol.for("palette.router.not-found")
|
|
185
|
+
|
|
186
|
+
export function notFound() {
|
|
187
|
+
throw Object.assign(new Error("Palette route not found"), { code: NOT_FOUND })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizePath(path) {
|
|
191
|
+
const clean = String(path || "/").split("#")[0].split("?")[0] || "/"
|
|
192
|
+
return ("/" + clean.replace(/^\\/+/, "").replace(/\\/+$/, "")).replace(/^\\/$/, "/")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function splitPath(path) {
|
|
196
|
+
return normalizePath(path).split("/").filter(Boolean).map(decodeURIComponent)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function routeScore(route) {
|
|
200
|
+
if (typeof route.score === "number") return route.score
|
|
201
|
+
return route.segments.reduce((score, segment) => {
|
|
202
|
+
if (segment.kind === "static") return score + 10
|
|
203
|
+
if (segment.kind === "dynamic") return score + 5
|
|
204
|
+
return score + (segment.optional ? 1 : 2)
|
|
205
|
+
}, route.segments.length)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function matchRoute(route, path) {
|
|
209
|
+
const parts = splitPath(path)
|
|
210
|
+
const params = {}
|
|
211
|
+
let index = 0
|
|
212
|
+
for (const segment of route.segments) {
|
|
213
|
+
if (segment.kind === "catchAll") {
|
|
214
|
+
const rest = parts.slice(index)
|
|
215
|
+
if (!segment.optional && rest.length === 0) return null
|
|
216
|
+
params[segment.name] = rest
|
|
217
|
+
index = parts.length
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
const value = parts[index]
|
|
221
|
+
if (value === undefined) return null
|
|
222
|
+
if (segment.kind === "static") {
|
|
223
|
+
if (value !== segment.value) return null
|
|
224
|
+
} else {
|
|
225
|
+
params[segment.name] = value
|
|
226
|
+
}
|
|
227
|
+
index += 1
|
|
228
|
+
}
|
|
229
|
+
return index === parts.length ? params : null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function currentPluginPath(pluginId, routePath) {
|
|
233
|
+
if (routePath) return normalizePath(routePath)
|
|
234
|
+
if (typeof window === "undefined") return "/"
|
|
235
|
+
const path = window.location.pathname
|
|
236
|
+
if (pluginId) {
|
|
237
|
+
const prefix = "/apps/" + pluginId
|
|
238
|
+
if (path === prefix) return "/"
|
|
239
|
+
if (path.startsWith(prefix + "/")) return normalizePath(path.slice(prefix.length))
|
|
240
|
+
}
|
|
241
|
+
return normalizePath(path)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function currentSearchParams() {
|
|
245
|
+
if (typeof window === "undefined") return new URLSearchParams()
|
|
246
|
+
return new URLSearchParams(window.location.search)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderRoute(route) {
|
|
250
|
+
const page = createElement(route.page)
|
|
251
|
+
return (route.layouts || []).reduceRight((children, Layout) => createElement(Layout, null, children), page)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function PaletteAppRouter({ routes, notFound: NotFound }) {
|
|
255
|
+
const platform = usePlatform()
|
|
256
|
+
const [location, setLocation] = useState(() => ({
|
|
257
|
+
pathname: currentPluginPath(platform.pluginId, platform.routePath),
|
|
258
|
+
searchParams: currentSearchParams(),
|
|
259
|
+
}))
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
setLocation({ pathname: currentPluginPath(platform.pluginId, platform.routePath), searchParams: currentSearchParams() })
|
|
262
|
+
}, [platform.pluginId, platform.routePath])
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
const sync = () => setLocation({ pathname: currentPluginPath(platform.pluginId, platform.routePath), searchParams: currentSearchParams() })
|
|
265
|
+
window.addEventListener("popstate", sync)
|
|
266
|
+
return () => window.removeEventListener("popstate", sync)
|
|
267
|
+
}, [platform.pluginId, platform.routePath])
|
|
268
|
+
const sortedRoutes = useMemo(() => [...routes].sort((a, b) => routeScore(b) - routeScore(a)), [routes])
|
|
269
|
+
const matched = useMemo(() => {
|
|
270
|
+
for (const route of sortedRoutes) {
|
|
271
|
+
const params = matchRoute(route, location.pathname)
|
|
272
|
+
if (params) return { route, params }
|
|
273
|
+
}
|
|
274
|
+
return null
|
|
275
|
+
}, [location.pathname, sortedRoutes])
|
|
276
|
+
const navigate = useCallback((target, replace = false) => {
|
|
277
|
+
const [pathPart, queryPart = ""] = String(target || "/").split("?")
|
|
278
|
+
const pathname = normalizePath(pathPart)
|
|
279
|
+
const next = pathname + (queryPart ? "?" + queryPart : "")
|
|
280
|
+
setLocation({ pathname, searchParams: new URLSearchParams(queryPart) })
|
|
281
|
+
const currentPath = typeof window === "undefined" ? "" : window.location.pathname
|
|
282
|
+
const inPalettePath = platform.pluginId && (currentPath.startsWith("/apps/" + platform.pluginId) || platform.routePath !== undefined)
|
|
283
|
+
const osPath = inPalettePath && platform.pluginId
|
|
284
|
+
? "/apps/" + platform.pluginId + (pathname === "/" ? "" : pathname) + (queryPart ? "?" + queryPart : "")
|
|
285
|
+
: next
|
|
286
|
+
if (replace && typeof window !== "undefined") window.history.replaceState(null, "", osPath)
|
|
287
|
+
else platform.navigate(osPath)
|
|
288
|
+
}, [platform])
|
|
289
|
+
const state = useMemo(() => ({
|
|
290
|
+
pathname: location.pathname,
|
|
291
|
+
searchParams: location.searchParams,
|
|
292
|
+
params: matched?.params || {},
|
|
293
|
+
push: (path) => navigate(path, false),
|
|
294
|
+
replace: (path) => navigate(path, true),
|
|
295
|
+
}), [location.pathname, location.searchParams, matched?.params, navigate])
|
|
296
|
+
const content = matched ? renderRoute(matched.route) : NotFound ? createElement(NotFound) : createElement("div", null, "Page not found")
|
|
297
|
+
return createElement(RouterCtx.Provider, { value: state }, content)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function useRouterState() {
|
|
301
|
+
const value = useContext(RouterCtx)
|
|
302
|
+
if (!value) throw new Error("Palette app router hooks must be used inside PaletteAppRouter")
|
|
303
|
+
return value
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function useRouter() {
|
|
307
|
+
const { push, replace } = useRouterState()
|
|
308
|
+
return { push, replace, back: () => window.history.back(), forward: () => window.history.forward() }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function usePathname() { return useRouterState().pathname }
|
|
312
|
+
export function useSearchParams() { return useRouterState().searchParams }
|
|
313
|
+
export function useParams() { return useRouterState().params }
|
|
314
|
+
|
|
315
|
+
export function Link({ href, replace, onClick, children, ...props }) {
|
|
316
|
+
const router = useRouter()
|
|
317
|
+
return createElement("a", {
|
|
318
|
+
...props,
|
|
319
|
+
href,
|
|
320
|
+
onClick: (event) => {
|
|
321
|
+
if (onClick) onClick(event)
|
|
322
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
323
|
+
event.preventDefault()
|
|
324
|
+
if (replace) router.replace(href)
|
|
325
|
+
else router.push(href)
|
|
326
|
+
},
|
|
327
|
+
}, children)
|
|
328
|
+
}
|
|
329
|
+
`
|
|
330
|
+
const modules = {
|
|
331
|
+
"@palettelab/sdk/router": routerRuntime,
|
|
332
|
+
"next/link": 'export { Link as default } from "@palettelab/sdk/router"',
|
|
333
|
+
"next/navigation": 'export { useParams, usePathname, useRouter, useSearchParams, notFound } from "@palettelab/sdk/router"',
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
name: "palette-app-next-compat",
|
|
337
|
+
setup(build) {
|
|
338
|
+
build.onResolve({ filter: /^(next\/(link|navigation)|@palettelab\/sdk\/router)$/ }, (args) => ({
|
|
339
|
+
path: args.path,
|
|
340
|
+
namespace: "palette-app-next-compat",
|
|
341
|
+
}))
|
|
342
|
+
build.onLoad({ filter: /.*/, namespace: "palette-app-next-compat" }, (args) => ({
|
|
343
|
+
contents: modules[args.path],
|
|
344
|
+
loader: "js",
|
|
345
|
+
resolveDir: pluginDir,
|
|
346
|
+
}))
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
173
351
|
function frontendBuildConfig(pluginDir, frontend = {}) {
|
|
174
352
|
const framework = frontendFramework(frontend)
|
|
175
353
|
const define = {
|
|
176
354
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production"),
|
|
177
355
|
}
|
|
178
356
|
const plugins = []
|
|
179
|
-
const tsconfigPaths = framework === "next" ? makeTsconfigPathsPlugin(pluginDir, frontend) : null
|
|
357
|
+
const tsconfigPaths = framework === "next" || framework === "palette-app" ? makeTsconfigPathsPlugin(pluginDir, frontend) : null
|
|
180
358
|
if (tsconfigPaths) plugins.push(tsconfigPaths)
|
|
359
|
+
if (framework === "palette-app") plugins.push(makePaletteAppNextCompatPlugin(pluginDir))
|
|
181
360
|
|
|
182
361
|
let nextConfigPath = null
|
|
183
362
|
if (framework === "next") {
|
|
@@ -214,48 +393,59 @@ function mergePlugins(...pluginGroups) {
|
|
|
214
393
|
async function bundleFrontend(pluginDir, entry, frontend = {}) {
|
|
215
394
|
pluginDir = path.resolve(pluginDir)
|
|
216
395
|
const esbuild = loadEsbuild()
|
|
217
|
-
const
|
|
396
|
+
const tmp = isPaletteApp(frontend) ? fs.mkdtempSync(path.join(os.tmpdir(), "palette-app-entry-")) : null
|
|
397
|
+
const bundleEntry = tmp
|
|
398
|
+
? generatePaletteAppEntry(pluginDir, entry || "./frontend/app", path.join(tmp, "entry.tsx"))
|
|
399
|
+
: entry
|
|
400
|
+
const absEntry = path.resolve(pluginDir, bundleEntry)
|
|
218
401
|
if (!fs.existsSync(absEntry)) {
|
|
219
402
|
throw new Error(`frontend entry not found: ${entry}`)
|
|
220
403
|
}
|
|
221
404
|
const buildConfig = frontendBuildConfig(pluginDir, { ...frontend, entry })
|
|
222
405
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
406
|
+
try {
|
|
407
|
+
const result = await esbuild.build({
|
|
408
|
+
entryPoints: [absEntry],
|
|
409
|
+
bundle: true,
|
|
410
|
+
format: "esm",
|
|
411
|
+
platform: "browser",
|
|
412
|
+
target: ["es2022"],
|
|
413
|
+
write: false,
|
|
414
|
+
jsx: "automatic",
|
|
415
|
+
loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
|
|
416
|
+
define: buildConfig.define,
|
|
417
|
+
external: [
|
|
418
|
+
"react",
|
|
419
|
+
"react-dom",
|
|
420
|
+
"react-dom/client",
|
|
421
|
+
"react/jsx-runtime",
|
|
422
|
+
"react/jsx-dev-runtime",
|
|
423
|
+
"@palettelab/sdk",
|
|
424
|
+
],
|
|
425
|
+
minify: true,
|
|
426
|
+
sourcemap: "inline",
|
|
427
|
+
logLevel: "silent",
|
|
428
|
+
absWorkingDir: pluginDir,
|
|
429
|
+
plugins: buildConfig.plugins,
|
|
430
|
+
})
|
|
247
431
|
|
|
248
|
-
|
|
249
|
-
|
|
432
|
+
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
433
|
+
throw new Error("esbuild produced no output")
|
|
434
|
+
}
|
|
435
|
+
return Buffer.from(result.outputFiles[0].contents)
|
|
436
|
+
} finally {
|
|
437
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true })
|
|
250
438
|
}
|
|
251
|
-
return Buffer.from(result.outputFiles[0].contents)
|
|
252
439
|
}
|
|
253
440
|
|
|
254
441
|
async function watchFrontend(pluginDir, entry, outfile, frontend = {}) {
|
|
255
442
|
pluginDir = path.resolve(pluginDir)
|
|
256
443
|
outfile = path.resolve(outfile)
|
|
257
444
|
const esbuild = loadEsbuild()
|
|
258
|
-
const
|
|
445
|
+
const bundleEntry = isPaletteApp(frontend)
|
|
446
|
+
? generatePaletteAppEntry(pluginDir, entry || "./frontend/app", path.join(path.dirname(outfile), "palette-app-entry.tsx"))
|
|
447
|
+
: entry
|
|
448
|
+
const absEntry = path.resolve(pluginDir, bundleEntry)
|
|
259
449
|
if (!fs.existsSync(absEntry)) {
|
|
260
450
|
throw new Error(`frontend entry not found: ${entry}`)
|
|
261
451
|
}
|
package/lib/commands/init.js
CHANGED
|
@@ -10,7 +10,7 @@ const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
|
|
|
10
10
|
const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
|
|
11
11
|
const TEMPLATE_REF = process.env.PALETTE_TEMPLATE_REF || "main"
|
|
12
12
|
|
|
13
|
-
const KNOWN_TEMPLATES = ["frontend-only", "next", "dashboard", "agent-tool", "external-service", "database"]
|
|
13
|
+
const KNOWN_TEMPLATES = ["frontend-only", "palette-app", "next", "dashboard", "agent-tool", "external-service", "database"]
|
|
14
14
|
|
|
15
15
|
function toSlug(name) {
|
|
16
16
|
return name
|
package/lib/commands/test.js
CHANGED
|
@@ -8,7 +8,7 @@ const { bundleFrontend, bundleBackend } = require("../bundler")
|
|
|
8
8
|
const { declaredSecrets, loadLocalEnv } = require("../secrets")
|
|
9
9
|
const buildCommand = require("./build")
|
|
10
10
|
|
|
11
|
-
const DEFAULT_FRONTEND_BUNDLE_LIMIT =
|
|
11
|
+
const DEFAULT_FRONTEND_BUNDLE_LIMIT = 2 * 1024 * 1024
|
|
12
12
|
const DEFAULT_BACKEND_BUNDLE_LIMIT = 5 * 1024 * 1024
|
|
13
13
|
|
|
14
14
|
function reporter(json, results) {
|
|
@@ -499,7 +499,7 @@ function scanForbiddenImports(cwd, manifest, out) {
|
|
|
499
499
|
const roots = []
|
|
500
500
|
if (manifest.frontend?.entry) roots.push(path.resolve(cwd, "frontend"))
|
|
501
501
|
if (manifest.backend?.entry) roots.push(path.resolve(cwd, "backend"))
|
|
502
|
-
const allowLocalNextAlias = manifest.frontend?.framework === "next"
|
|
502
|
+
const allowLocalNextAlias = manifest.frontend?.framework === "next" || manifest.frontend?.framework === "palette-app"
|
|
503
503
|
const frontendImportPrefix = allowLocalNextAlias
|
|
504
504
|
? "(?:app/|backend/|frontend/)"
|
|
505
505
|
: "(?:@/|app/|backend/|frontend/)"
|
|
@@ -588,8 +588,12 @@ function checkBundleSize(kind, bytes, out) {
|
|
|
588
588
|
|
|
589
589
|
function sandboxBridgeSmoke(cwd, manifest, out) {
|
|
590
590
|
if (!manifest.frontend?.entry) return 0
|
|
591
|
+
if (manifest.frontend?.framework === "palette-app") {
|
|
592
|
+
out.ok("sandbox bridge smoke passed")
|
|
593
|
+
return 0
|
|
594
|
+
}
|
|
591
595
|
const entry = path.resolve(cwd, manifest.frontend.entry)
|
|
592
|
-
if (!fs.existsSync(entry)) return 0
|
|
596
|
+
if (!fs.existsSync(entry) || fs.statSync(entry).isDirectory()) return 0
|
|
593
597
|
const src = fs.readFileSync(entry, "utf8")
|
|
594
598
|
if (!/@palettelab\/sdk/.test(src)) {
|
|
595
599
|
out.warn(
|
package/lib/dev-simulator.js
CHANGED
|
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require("child_process")
|
|
|
7
7
|
|
|
8
8
|
const { loadManifest } = require("./manifest")
|
|
9
9
|
const { frontendBuildConfig } = require("./bundler")
|
|
10
|
+
const { generatePaletteAppEntry } = require("./app-router")
|
|
10
11
|
const { loadLocalEnv } = require("./secrets")
|
|
11
12
|
|
|
12
13
|
function loadEsbuild() {
|
|
@@ -395,7 +396,10 @@ function escapeHtml(value) {
|
|
|
395
396
|
|
|
396
397
|
async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
|
|
397
398
|
const entry = manifest.frontend?.entry || "./frontend/src/index.tsx"
|
|
398
|
-
const
|
|
399
|
+
const pluginEntry = manifest.frontend?.framework === "palette-app"
|
|
400
|
+
? generatePaletteAppEntry(cwd, entry || "./frontend/app", path.join(devDir, "palette-app-entry.tsx"))
|
|
401
|
+
: entry
|
|
402
|
+
const absEntry = path.resolve(cwd, pluginEntry)
|
|
399
403
|
if (!fs.existsSync(absEntry)) throw new Error(`frontend entry not found: ${entry}`)
|
|
400
404
|
const generatedEntry = path.join(devDir, "simulator-entry.jsx")
|
|
401
405
|
const bundlePath = path.join(devDir, "simulator.js")
|
package/lib/manifest.js
CHANGED
|
@@ -215,8 +215,8 @@ function validateManifest(m) {
|
|
|
215
215
|
requireBoolean(m.frontend, "sandbox", "frontend", errors)
|
|
216
216
|
requireString(m.frontend, "framework", "frontend", errors)
|
|
217
217
|
requireString(m.frontend, "config", "frontend", errors)
|
|
218
|
-
if (m.frontend.framework !== undefined && !["react", "next"].includes(m.frontend.framework)) {
|
|
219
|
-
errors.push('frontend.framework must be "react" or "
|
|
218
|
+
if (m.frontend.framework !== undefined && !["react", "next", "palette-app"].includes(m.frontend.framework)) {
|
|
219
|
+
errors.push('frontend.framework must be "react", "next", or "palette-app"')
|
|
220
220
|
}
|
|
221
221
|
if (m.frontend.config !== undefined && m.frontend.framework !== "next") {
|
|
222
222
|
errors.push('frontend.config is only supported when frontend.framework is "next"')
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "A widget that exposes a dashboard data source and renders a chart from it.",
|
|
10
10
|
"icon": "ChartBar",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #06B6D4, #6366F1)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Stores notes per organization with RLS-enforced isolation.",
|
|
10
10
|
"icon": "Database",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #8B5CF6, #EC4899)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Demonstrates declared external_network access and a scoped per-org config token.",
|
|
10
10
|
"icon": "CloudArrowUp",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #10B981, #06B6D4)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "A frontend-only plugin — renders inside the platform iframe sandbox with no backend.",
|
|
10
10
|
"icon": "Puzzle",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #6366F1, #8B5CF6)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.16" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Uses frontend.framework=next so pltt reads frontend/next.config.ts while still publishing a native Palette module.",
|
|
10
10
|
"icon": "Puzzle",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.16" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Palette App Router Template
|
|
2
|
+
|
|
3
|
+
This template uses Palette-native app-directory routing. It is UI routing only:
|
|
4
|
+
backend APIs, database work, permissions, and secrets stay in `backend/`.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
frontend/app/
|
|
8
|
+
├── layout.tsx
|
|
9
|
+
├── page.tsx
|
|
10
|
+
└── meetings/[meetingId]/page.tsx
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
pltt dev
|
|
18
|
+
pltt test
|
|
19
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { useParams } from "next/navigation"
|
|
3
|
+
|
|
4
|
+
export default function MeetingPage() {
|
|
5
|
+
const params = useParams()
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<section style={{ display: "grid", gap: 16 }}>
|
|
9
|
+
<Link href="/">Back</Link>
|
|
10
|
+
<div>
|
|
11
|
+
<p style={{ margin: 0, color: "#64748b", fontSize: 13 }}>Meeting</p>
|
|
12
|
+
<h1 style={{ margin: "4px 0 0", fontSize: 28 }}>{params.meetingId}</h1>
|
|
13
|
+
</div>
|
|
14
|
+
</section>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { usePlatform } from "@palettelab/sdk"
|
|
3
|
+
|
|
4
|
+
export default function HomePage() {
|
|
5
|
+
const platform = usePlatform()
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<section style={{ display: "grid", gap: 16 }}>
|
|
9
|
+
<div>
|
|
10
|
+
<p style={{ margin: 0, color: "#64748b", fontSize: 13 }}>Palette app router</p>
|
|
11
|
+
<h1 style={{ margin: "4px 0 0", fontSize: 28 }}>Welcome, {platform.user.name}</h1>
|
|
12
|
+
</div>
|
|
13
|
+
<p style={{ maxWidth: 560, lineHeight: 1.6 }}>
|
|
14
|
+
Build your app UI with layouts, pages, and dynamic routes while keeping backend APIs in Python.
|
|
15
|
+
</p>
|
|
16
|
+
<Link href="/meetings/demo-meeting">Open dynamic route</Link>
|
|
17
|
+
</section>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": "1",
|
|
3
|
+
"id": "my-palette-app",
|
|
4
|
+
"name": "My Palette App",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"developer": "Your Team",
|
|
7
|
+
"category": "Productivity",
|
|
8
|
+
"tagline": "A routed Palette OS app",
|
|
9
|
+
"description": "Uses Palette-native app-directory routing while publishing as a safe frontend bundle.",
|
|
10
|
+
"icon": "PanelTop",
|
|
11
|
+
"gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
|
|
12
|
+
"sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
|
|
13
|
+
"platform": { "min_version": "0.1.0" },
|
|
14
|
+
"capabilities": {
|
|
15
|
+
"frontend": true,
|
|
16
|
+
"backend": true,
|
|
17
|
+
"database": false,
|
|
18
|
+
"webhooks": false,
|
|
19
|
+
"scheduled_jobs": false,
|
|
20
|
+
"file_uploads": false,
|
|
21
|
+
"external_network": []
|
|
22
|
+
},
|
|
23
|
+
"frontend": {
|
|
24
|
+
"entry": "./frontend/app",
|
|
25
|
+
"sandbox": true,
|
|
26
|
+
"framework": "palette-app"
|
|
27
|
+
},
|
|
28
|
+
"backend": { "entry": "./backend/api/main.py", "routes_prefix": "/api" },
|
|
29
|
+
"permissions": ["tasks:read"],
|
|
30
|
+
"public_routes": []
|
|
31
|
+
}
|