create-pactor-app 1.20.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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -0
  3. package/dist/cli.js +220 -0
  4. package/package.json +47 -0
  5. package/templates/next-app/README.md +112 -0
  6. package/templates/next-app/app/[[...slug]]/page.tsx +7 -0
  7. package/templates/next-app/app/api/auth/login/route.ts +32 -0
  8. package/templates/next-app/app/api/auth/logout/route.ts +22 -0
  9. package/templates/next-app/app/api/pages/[id]/route.ts +40 -0
  10. package/templates/next-app/app/api/sitemap/route.ts +21 -0
  11. package/templates/next-app/app/favicon.ico +0 -0
  12. package/templates/next-app/app/globals.css +12 -0
  13. package/templates/next-app/app/layout.tsx +28 -0
  14. package/templates/next-app/eslint.config.mjs +18 -0
  15. package/templates/next-app/package.json +30 -0
  16. package/templates/next-app/postcss.config.mjs +7 -0
  17. package/templates/next-app/public/file.svg +1 -0
  18. package/templates/next-app/public/globe.svg +1 -0
  19. package/templates/next-app/public/next.svg +1 -0
  20. package/templates/next-app/public/vercel.svg +1 -0
  21. package/templates/next-app/public/window.svg +1 -0
  22. package/templates/next-app/src/pactor/App.tsx +47 -0
  23. package/templates/next-app/src/pactor/config.ts +17 -0
  24. package/templates/next-app/src/server/accounts.ts +63 -0
  25. package/templates/next-app/src/server/cors.ts +34 -0
  26. package/templates/next-app/src/server/sitemap.ts +558 -0
  27. package/templates/next-app/tsconfig.json +34 -0
  28. package/templates/spa/README.md +97 -0
  29. package/templates/spa/index.html +13 -0
  30. package/templates/spa/package.json +29 -0
  31. package/templates/spa/src/main.tsx +12 -0
  32. package/templates/spa/src/pactor/App.tsx +27 -0
  33. package/templates/spa/src/pactor/config.ts +18 -0
  34. package/templates/spa/tsconfig.json +19 -0
  35. package/templates/spa/vite.config.ts +14 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pactor Contributors
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,33 @@
1
+ # create-pactor-app
2
+
3
+ Pactor 应用脚手架:一条命令从内置模板生成业务工程。
4
+
5
+ ## 用法
6
+
7
+ ```bash
8
+ pnpm create pactor-app my-app # 交互选择模板(非 TTY 默认 next-app)
9
+ pnpm create pactor-app my-app -t next-app # Next.js 全栈模板(含 mock API)
10
+ pnpm create pactor-app my-app -t spa # Vite 纯前端模板
11
+ ```
12
+
13
+ 等价于运行 npm 包 `create-pactor-app` 的 bin(`npm create` / `pnpm create` 约定)。
14
+
15
+ 生成后:
16
+
17
+ ```bash
18
+ cd my-app
19
+ pnpm install
20
+ pnpm dev
21
+ ```
22
+
23
+ ## 模板
24
+
25
+ - `next-app`:Next.js 全栈动态页面工程,含 mock API(dev 端口 9011)
26
+ - `spa`:Vite + React 纯前端工程(dev 端口 9012)
27
+
28
+ 模板由 `pnpm sync-templates` 从仓库 `starters/` 同步生成,依赖中的 `link:` 会被改写为已发布的 `^<version>`。
29
+
30
+ ## 退出码
31
+
32
+ - `0` 成功
33
+ - `2` 用法错误 / 目标目录已存在且非空(不覆盖任何内容)
package/dist/cli.js ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createInterface } from "readline";
5
+ import { pathToFileURL } from "url";
6
+
7
+ // src/create.ts
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ statSync,
14
+ writeFileSync
15
+ } from "fs";
16
+ import { basename, dirname, join, resolve } from "path";
17
+ import { fileURLToPath } from "url";
18
+ var APP_NAME_PLACEHOLDER = /__APP_NAME__/g;
19
+ var TEMPLATES = [
20
+ { id: "next-app", label: "Next.js \u5168\u6808\uFF08\u542B mock API\uFF0Cdev \u7AEF\u53E3 9011\uFF09" },
21
+ { id: "spa", label: "Vite \u7EAF\u524D\u7AEF\uFF08dev \u7AEF\u53E3 9012\uFF09" }
22
+ ];
23
+ var DEFAULT_TEMPLATE = "next-app";
24
+ function defaultTemplatesDir(template) {
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ for (const candidate of [
27
+ join(here, "../templates", template),
28
+ join(here, "../../templates", template)
29
+ ]) {
30
+ if (existsSync(candidate)) return candidate;
31
+ }
32
+ return join(here, "../templates", template);
33
+ }
34
+ function copyTemplate(src, dest, appName) {
35
+ mkdirSync(dest, { recursive: true });
36
+ for (const entry of readdirSync(src)) {
37
+ const from = join(src, entry);
38
+ const to = join(dest, entry);
39
+ if (statSync(from).isDirectory()) {
40
+ copyTemplate(from, to, appName);
41
+ continue;
42
+ }
43
+ const content = readFileSync(from);
44
+ if (content.includes("__APP_NAME__")) {
45
+ writeFileSync(
46
+ to,
47
+ content.toString("utf8").replace(APP_NAME_PLACEHOLDER, () => appName),
48
+ "utf8"
49
+ );
50
+ } else {
51
+ writeFileSync(to, content);
52
+ }
53
+ }
54
+ }
55
+ function scaffold(target, template, templatesDir = defaultTemplatesDir(template)) {
56
+ const appName = basename(target);
57
+ copyTemplate(templatesDir, target, appName);
58
+ return appName;
59
+ }
60
+ function checkTarget(target, io) {
61
+ if (!existsSync(target)) return true;
62
+ if (!statSync(target).isDirectory()) {
63
+ io.err(`\u76EE\u6807\u5DF2\u5B58\u5728\u4E14\u4E0D\u662F\u76EE\u5F55: ${target}`);
64
+ return false;
65
+ }
66
+ if (readdirSync(target).length > 0) {
67
+ io.err(`\u76EE\u6807\u76EE\u5F55\u975E\u7A7A\uFF0C\u62D2\u7EDD\u8986\u76D6: ${target}`);
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ function resolveTarget(name) {
73
+ return resolve(name);
74
+ }
75
+ var DEV_HINTS = {
76
+ "next-app": " pnpm dev # http://localhost:9011",
77
+ spa: " pnpm dev # http://localhost:9012"
78
+ };
79
+ function printNextSteps(io, name, template) {
80
+ io.out("\u4E0B\u4E00\u6B65:");
81
+ io.out(` cd ${name}`);
82
+ io.out(" pnpm install");
83
+ io.out(DEV_HINTS[template]);
84
+ }
85
+
86
+ // src/cli.ts
87
+ var HELP_TEXT = `create-pactor-app \u2014 \u4ECE\u6A21\u677F\u521B\u5EFA Pactor \u5E94\u7528
88
+
89
+ \u7528\u6CD5: create-pactor-app <name> [-t next-app|spa]
90
+
91
+ \u53C2\u6570:
92
+ <name> \u76EE\u6807\u76EE\u5F55\uFF08\u53EF\u4E3A\u76F8\u5BF9/\u7EDD\u5BF9\u8DEF\u5F84\uFF0C\u5E94\u7528\u540D\u53D6\u76EE\u5F55\u540D\uFF09
93
+ -t, --template <id> \u6A21\u677F\uFF1Anext-app\uFF08Next.js \u5168\u6808\uFF09/ spa\uFF08Vite \u7EAF\u524D\u7AEF\uFF09
94
+ -h, --help \u663E\u793A\u5E2E\u52A9
95
+
96
+ \u672A\u6307\u5B9A -t \u65F6\uFF1A\u4EA4\u4E92\u7EC8\u7AEF\u7F16\u53F7\u9009\u62E9\uFF08\u9ED8\u8BA4 next-app\uFF09\uFF0C\u975E TTY \u76F4\u63A5\u7528 next-app\u3002
97
+ \u9000\u51FA\u7801: 0 \u6210\u529F\uFF1B2 \u7528\u6CD5/\u76EE\u6807\u9519\u8BEF
98
+ `;
99
+ var UsageError = class extends Error {
100
+ };
101
+ function parseArgs(argv) {
102
+ const result = { help: false };
103
+ for (let i = 0; i < argv.length; i += 1) {
104
+ const token = argv[i];
105
+ if (token === "-h" || token === "--help") {
106
+ result.help = true;
107
+ continue;
108
+ }
109
+ if (token === "-t" || token === "--template") {
110
+ const value = argv[i + 1];
111
+ if (value === void 0) throw new UsageError(`\u9009\u9879 ${token} \u7F3A\u5C11\u503C`);
112
+ result.template = value;
113
+ i += 1;
114
+ continue;
115
+ }
116
+ if (token.startsWith("--template=")) {
117
+ result.template = token.slice("--template=".length);
118
+ continue;
119
+ }
120
+ if (token.startsWith("-")) {
121
+ throw new UsageError(`\u672A\u77E5\u9009\u9879: ${token}`);
122
+ }
123
+ if (result.name !== void 0) {
124
+ throw new UsageError(`\u591A\u4F59\u53C2\u6570: ${token}\uFF08\u4EC5\u63A5\u53D7\u4E00\u4E2A <name>\uFF09`);
125
+ }
126
+ result.name = token;
127
+ }
128
+ return result;
129
+ }
130
+ function parseTemplateId(value) {
131
+ if (TEMPLATES.some((t) => t.id === value)) return value;
132
+ throw new UsageError(
133
+ `\u672A\u77E5\u6A21\u677F: ${value}\uFF08\u53EF\u9009: ${TEMPLATES.map((t) => t.id).join(" | ")}\uFF09`
134
+ );
135
+ }
136
+ async function selectTemplateInteractive(io) {
137
+ io.out("\u8BF7\u9009\u62E9\u6A21\u677F:");
138
+ TEMPLATES.forEach((t, index) => io.out(` ${index + 1}) ${t.id} \u2014 ${t.label}`));
139
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
140
+ try {
141
+ const answer = await new Promise((resolvePromise) => {
142
+ rl.question(`\u8BF7\u8F93\u5165\u7F16\u53F7 [1]: `, resolvePromise);
143
+ });
144
+ const trimmed = answer.trim();
145
+ if (trimmed === "") return DEFAULT_TEMPLATE;
146
+ const index = Number.parseInt(trimmed, 10) - 1;
147
+ const picked = TEMPLATES[index];
148
+ if (picked === void 0) {
149
+ throw new UsageError(`\u65E0\u6548\u7F16\u53F7: ${trimmed}`);
150
+ }
151
+ return picked.id;
152
+ } finally {
153
+ rl.close();
154
+ }
155
+ }
156
+ async function runCli(argv, io, options = {}) {
157
+ let args;
158
+ try {
159
+ args = parseArgs(argv);
160
+ } catch (error) {
161
+ if (error instanceof UsageError) {
162
+ io.err(`\u53C2\u6570\u9519\u8BEF: ${error.message}`);
163
+ io.err(HELP_TEXT);
164
+ return 2;
165
+ }
166
+ throw error;
167
+ }
168
+ if (args.help) {
169
+ io.out(HELP_TEXT);
170
+ return 0;
171
+ }
172
+ if (args.name === void 0) {
173
+ io.err("\u7F3A\u5C11\u53C2\u6570 <name>");
174
+ io.err(HELP_TEXT);
175
+ return 2;
176
+ }
177
+ let template;
178
+ try {
179
+ if (args.template !== void 0) {
180
+ template = parseTemplateId(args.template);
181
+ } else if (options.selectTemplate !== void 0) {
182
+ template = await options.selectTemplate();
183
+ } else if (process.stdin.isTTY && process.stdout.isTTY) {
184
+ template = await selectTemplateInteractive(io);
185
+ } else {
186
+ template = DEFAULT_TEMPLATE;
187
+ }
188
+ } catch (error) {
189
+ if (error instanceof UsageError) {
190
+ io.err(error.message);
191
+ return 2;
192
+ }
193
+ throw error;
194
+ }
195
+ const target = resolveTarget(args.name);
196
+ if (!checkTarget(target, io)) return 2;
197
+ const templatesDir = options.templatesDir ?? defaultTemplatesDir;
198
+ scaffold(target, template, templatesDir(template));
199
+ io.out(`\u5DF2\u521B\u5EFA\u5DE5\u7A0B: ${target}`);
200
+ printNextSteps(io, args.name, template);
201
+ return 0;
202
+ }
203
+ var isMain = process.argv[1] !== void 0 && import.meta.url === pathToFileURL(process.argv[1]).href;
204
+ if (isMain) {
205
+ const io = {
206
+ out: (line) => process.stdout.write(`${line}
207
+ `),
208
+ err: (line) => process.stderr.write(`${line}
209
+ `)
210
+ };
211
+ void runCli(process.argv.slice(2), io).then((code) => process.exit(code));
212
+ }
213
+ export {
214
+ HELP_TEXT,
215
+ UsageError,
216
+ parseArgs,
217
+ parseTemplateId,
218
+ runCli,
219
+ selectTemplateInteractive
220
+ };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "create-pactor-app",
3
+ "version": "1.20.0",
4
+ "description": "Create a Pactor app from starter templates (pnpm create pactor-app)",
5
+ "keywords": [
6
+ "pactor",
7
+ "create",
8
+ "scaffold",
9
+ "starter",
10
+ "server-driven-ui"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/426-330/pactor.git",
16
+ "directory": "packages/create-app"
17
+ },
18
+ "homepage": "https://github.com/426-330/pactor/tree/main/packages/create-app",
19
+ "bugs": "https://github.com/426-330/pactor/issues",
20
+ "type": "module",
21
+ "bin": {
22
+ "create-pactor-app": "./dist/cli.js"
23
+ },
24
+ "main": "./dist/cli.js",
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "templates"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "tsup": "^8.3.5",
38
+ "typescript": "^5.6.3",
39
+ "vitest": "^2.1.9"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "sync-templates": "node scripts/sync-templates.mjs",
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit"
46
+ }
47
+ }
@@ -0,0 +1,112 @@
1
+ # __APP_NAME__
2
+
3
+ 按 `design/dynamic-page.md`(方案 B)实现的动态页面 starter:开发者在入口配置
4
+ 一个或多个返回 **sitemap 对象**的接口 URL,应用的页面 url、页面名称、导航结构、
5
+ 布局与页面 DSL 全部由该对象驱动。核心能力由框架 `@pactor-app/app` 的
6
+ **`SitemapApp`** 组件内置(路由解析 / 匿名守卫 / DSL 布局 / 页面加载 /
7
+ session 桥),模板只做宿主接线。
8
+
9
+ ## 快速开始
10
+
11
+ ```bash
12
+ pnpm install
13
+ pnpm dev # http://localhost:9011
14
+ ```
15
+
16
+ 演示账号(登录页可见):
17
+
18
+ | 账号 | 密码 | 角色差异 |
19
+ | --- | --- | --- |
20
+ | `admin` | `pactor123` | 可见「报表」菜单(`report:view`),月报页懒加载 |
21
+ | `guest` | `guest123` | 仅可见首页 |
22
+
23
+ 匿名访问任意非 public 路径会重定向到 `/login`;登录 / 登出后整页重载,
24
+ sitemap 端点按新会话重新裁剪内容。
25
+
26
+ ## 结构
27
+
28
+ ```
29
+ app/
30
+ layout.tsx # PactorApp 挂载点(Next layout 跨导航常驻:
31
+ # 切换导航只刷新内容区,sitemap 不重复拉取)
32
+ [[...slug]]/page.tsx # 可选 catch-all:匹配全部站内路径,渲染为空
33
+ api/
34
+ sitemap/route.ts # sitemap 端点(会话感知:匿名 / 角色裁剪)
35
+ pages/[id]/route.ts # 懒加载页面端点(PageResource envelope,接口侧鉴权)
36
+ auth/login/route.ts # 演示登录(写会话 cookie)
37
+ auth/logout/route.ts # 演示登出(清 cookie)
38
+ src/
39
+ pactor/
40
+ config.ts # ★ 入口配置:sitemapSources 接口 URL 列表
41
+ App.tsx # 薄壳:框架 SitemapApp + 宿主路由(pathname / navigate)
42
+ server/
43
+ accounts.ts # 演示账号与会话 cookie
44
+ cors.ts # CORS 头(演示放开跨源,含预检响应)
45
+ sitemap.ts # mock sitemap 数据源(layouts / pages / menus 按角色裁剪;
46
+ # 登录 / 登出在 DSL 中用内置 request 动作表达)
47
+ ```
48
+
49
+ `App.tsx` 全部内容即:
50
+
51
+ ```tsx
52
+ const httpHooks = createHttpHooks();
53
+
54
+ <SitemapApp
55
+ sources={sitemapSources}
56
+ baseUrl={apiBaseUrl}
57
+ pathname={pathname}
58
+ query={query}
59
+ navigate={(to) => router.push(to)}
60
+ pageEndpoint={pageEndpoint}
61
+ httpAdapter={httpHooks}
62
+ />
63
+ ```
64
+
65
+ > 依赖说明:`SitemapApp` 于 `@pactor-app/app` v1.19.0 引入。本模板开发期间
66
+ > pactor 依赖经 `link:../../packages/*` 指向本仓;`v1.19.0` 发布到 npm 后
67
+ > 改回 `^1.19.0` 版本号即可独立使用。
68
+
69
+ ## 接入真实后端
70
+
71
+ 1. 把 `src/pactor/config.ts` 的 `sitemapSources` 改成你的接口 URL(可多个,
72
+ 冲突后源覆盖前源;相对路径由 `apiBaseUrl` 补全,见「跨域与 HTTP 钩子」)。
73
+ 2. 服务端按 `SitemapResource`(自 `pactor-app` 导入类型)返回 JSON:
74
+ - `layouts`:DSL 布局(`PageOutlet` 为页面出口,`SitemapMenu` 渲染导航,
75
+ `${data.session.*}` 读会话数据)
76
+ - `pages`:页面定义(`pageDsl` 内联或缺省,`version` 参与缓存键 `id@version`)
77
+ - `menus`:导航 / 路由树(`pageId` 引用页面、`layout` 引用布局、
78
+ `public` 免登录;`path` / `title` 缺省从引用页面继承)
79
+ - `auth.loginPath`:未登录重定向目标
80
+ 3. 懒加载页:实现 `GET {pageEndpoint}/{id}` 返回 PageResource envelope
81
+ (缺省 `/api/pages/{id}`,可在 `config.ts` 调整)。
82
+ 4. 登录 / 登出等接口调用直接在 DSL 中用内置 `request` 动作表达(参考
83
+ `src/server/sitemap.ts` 的登录页 / 布局 DSL,URL 指向你的真实接口);
84
+ 有客户端服务层时可用内置 `service.call` 动作桥接,`session.context`
85
+ 会话桥由框架内置,均无需自行注册。
86
+
87
+ ## 跨域与 HTTP 钩子
88
+
89
+ 模板的外部接口以 `apiBaseUrl`(`src/pactor/config.ts`,缺省
90
+ `http://localhost:9011`)为基准:`sitemapSources` / `pageEndpoint`
91
+ 只写相对路径,由 `SitemapApp` 的 `baseUrl` 补全为外部绝对地址。
92
+ **假定服务端已开通 CORS,浏览器直连,无需项目内代理**——本模板的
93
+ mock API 已在路由层放开跨源(`src/server/cors.ts`:Allow-Origin 回显
94
+ 请求源 + Allow-Credentials,POST 预检经 `OPTIONS` 响应;cookie 会话
95
+ 跨源可达,框架 fetch 均带 `credentials: 'include'`)。
96
+
97
+ **HTTP 钩子**(框架默认实现 `createHttpHooks`,`src/pactor/App.tsx`):
98
+ 接入框架的全部 HTTP 出口(sitemap 源、页面懒加载端点、DSL `request`
99
+ 动作、http 类型 dataSource、动态 DSL 引用)——
100
+ - `request`:缺省不改写地址(CORS 直连);服务端不开 CORS 时传
101
+ `proxyPrefix`,把跨域地址改写到项目内代理前缀(需自行配置
102
+ Next rewrites / nginx 转发);可注入公共头(Authorization 等)
103
+ - `response`:`{ code, data, message }` 包裹结构拆包为 `data`,业务错误码
104
+ 抛错(DSL 动作按失败处理);后端包裹协议不同传 `envelope: false`
105
+ 或自行实现 `PactorHttpHooks`
106
+
107
+ ## 与设计文档的差异
108
+
109
+ `design/dynamic-page.md` 的目标态是布局 / 页面 DSL 直接读 `${user}` /
110
+ `${app}` / `${menus}` 作用域(框架尚未原生开放)。当前由 `SitemapApp` 内置的
111
+ `session.context` 服务桥接,DSL 中写作 `${data.session.user.name}`、
112
+ `${data.session.menus}`;框架支持原生作用域后可平滑迁回。
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 可选 catch-all:匹配全部站内路径,但渲染为空——应用本体挂在
3
+ * app/layout.tsx 的 PactorApp(layout 跨导航常驻,page 会被重挂载)。
4
+ */
5
+ export default function CatchAllPage() {
6
+ return null;
7
+ }
@@ -0,0 +1,32 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { findAccount, SESSION_COOKIE } from "@/server/accounts";
4
+ import { corsHeaders, corsPreflight } from "@/server/cors";
5
+
6
+ /** 演示登录:校验账号 → 写会话 cookie → 返回用户信息 */
7
+ export async function POST(request: Request) {
8
+ const body = (await request.json().catch(() => ({}))) as {
9
+ username?: string;
10
+ password?: string;
11
+ };
12
+ const user = findAccount(body.username ?? "", body.password ?? "");
13
+ if (!user) {
14
+ return Response.json(
15
+ { error: "用户名或密码错误" },
16
+ { status: 401, headers: corsHeaders(request) },
17
+ );
18
+ }
19
+ const response = NextResponse.json(
20
+ { token: `demo-${user.id}`, user },
21
+ { headers: corsHeaders(request) },
22
+ );
23
+ response.cookies.set(SESSION_COOKIE, user.username, {
24
+ httpOnly: true,
25
+ sameSite: "lax",
26
+ path: "/",
27
+ });
28
+ return response;
29
+ }
30
+
31
+ /** 预检(跨源 POST JSON) */
32
+ export const OPTIONS = corsPreflight;
@@ -0,0 +1,22 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { SESSION_COOKIE } from "@/server/accounts";
4
+ import { corsHeaders, corsPreflight } from "@/server/cors";
5
+
6
+ /** 演示登出:清会话 cookie */
7
+ export async function POST(request: Request) {
8
+ const response = NextResponse.json(
9
+ { ok: true },
10
+ { headers: corsHeaders(request) },
11
+ );
12
+ response.cookies.set(SESSION_COOKIE, "", {
13
+ httpOnly: true,
14
+ sameSite: "lax",
15
+ path: "/",
16
+ maxAge: 0,
17
+ });
18
+ return response;
19
+ }
20
+
21
+ /** 预检(跨源 POST JSON) */
22
+ export const OPTIONS = corsPreflight;
@@ -0,0 +1,40 @@
1
+ import { cookies } from "next/headers";
2
+
3
+ import { findUserBySession, SESSION_COOKIE } from "@/server/accounts";
4
+ import { corsHeaders, corsPreflight } from "@/server/cors";
5
+ import { getLazyPage } from "@/server/sitemap";
6
+
7
+ /**
8
+ * 懒加载页面端点:sitemap 中未内联 pageDsl 的页面按需获取。
9
+ * 返回 PageResource envelope(id / version / dslVersion / schema),
10
+ * 接口侧鉴权——前端隐藏菜单不等于安全。
11
+ */
12
+ export async function GET(
13
+ request: Request,
14
+ context: RouteContext<"/api/pages/[id]">,
15
+ ) {
16
+ const { id } = await context.params;
17
+ const store = await cookies();
18
+ const user = findUserBySession(store.get(SESSION_COOKIE)?.value);
19
+ const entry = getLazyPage(id, user);
20
+ if (!entry) {
21
+ return Response.json(
22
+ { error: "页面不存在或无权限" },
23
+ { status: 404, headers: corsHeaders(request) },
24
+ );
25
+ }
26
+ return Response.json(
27
+ {
28
+ id,
29
+ resourceType: "page",
30
+ version: entry.version,
31
+ dslVersion: "1.0",
32
+ schema: entry.pageDsl,
33
+ updatedAt: new Date().toISOString(),
34
+ },
35
+ { headers: { "cache-control": "no-store", ...corsHeaders(request) } },
36
+ );
37
+ }
38
+
39
+ /** 预检(跨源带凭据 / 自定义头时) */
40
+ export const OPTIONS = corsPreflight;
@@ -0,0 +1,21 @@
1
+ import { cookies } from "next/headers";
2
+
3
+ import { findUserBySession, SESSION_COOKIE } from "@/server/accounts";
4
+ import { corsPreflight, corsHeaders } from "@/server/cors";
5
+ import { buildSitemap } from "@/server/sitemap";
6
+
7
+ /**
8
+ * Sitemap 端点(design/dynamic-page.md):
9
+ * 会话感知——同一 URL,匿名与不同角色拿到不同 sitemap。
10
+ * 入口可配置多个此类 URL(src/pactor/config.ts 的 sources)。
11
+ */
12
+ export async function GET(request: Request) {
13
+ const store = await cookies();
14
+ const user = findUserBySession(store.get(SESSION_COOKIE)?.value);
15
+ return Response.json(buildSitemap(user), {
16
+ headers: { "cache-control": "no-store", ...corsHeaders(request) },
17
+ });
18
+ }
19
+
20
+ /** 预检(跨源带凭据 / 自定义头时) */
21
+ export const OPTIONS = corsPreflight;
@@ -0,0 +1,12 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme inline {
4
+ --font-sans: var(--font-geist-sans);
5
+ --font-mono: var(--font-geist-mono);
6
+ }
7
+
8
+ body {
9
+ margin: 0;
10
+ background: var(--background, #ffffff);
11
+ color: var(--foreground, #171717);
12
+ }
@@ -0,0 +1,28 @@
1
+ import type { Metadata } from "next";
2
+ import { PactorApp } from "@/pactor/App";
3
+ import "@pactor-app/ui/styles.css";
4
+ import "./globals.css";
5
+
6
+ export const metadata: Metadata = {
7
+ title: "Pactor 动态页面 Starter",
8
+ description: "Sitemap 驱动的动态页面应用模板(design/dynamic-page.md)",
9
+ };
10
+
11
+ export default function RootLayout({ children }: LayoutProps<"/">) {
12
+ return (
13
+ <html
14
+ lang="zh-CN"
15
+ className={`h-full antialiased`}
16
+ >
17
+ <body className="min-h-full flex flex-col">
18
+ {/*
19
+ * PactorApp 挂在 Next layout 而非 page:App Router 导航时 page 会
20
+ * 重挂载、layout 常驻——sitemap 只拉取一次,切换导航只刷新内容区。
21
+ * children(page)恒为 null,路由由 PactorApp 内部按 sitemap 解析。
22
+ */}
23
+ <PactorApp />
24
+ {children}
25
+ </body>
26
+ </html>
27
+ );
28
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev -p 9011",
7
+ "build": "next build",
8
+ "start": "next start -p 9011",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@pactor-app/core": "^1.20.0",
13
+ "@pactor-app/runtime": "^1.20.0",
14
+ "@pactor-app/ui": "^1.20.0",
15
+ "next": "16.3.5",
16
+ "pactor-app": "^1.20.0",
17
+ "react": "19.2.8",
18
+ "react-dom": "19.2.8"
19
+ },
20
+ "devDependencies": {
21
+ "@tailwindcss/postcss": "^4",
22
+ "@types/node": "^20",
23
+ "@types/react": "^19",
24
+ "@types/react-dom": "^19",
25
+ "eslint": "^9",
26
+ "eslint-config-next": "16.3.5",
27
+ "tailwindcss": "^4",
28
+ "typescript": "^5"
29
+ }
30
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1 @@
1
+ <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>