apptify 0.5.0 → 0.5.4

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 (68) hide show
  1. package/README.md +39 -308
  2. package/dist/apptify.css +1 -1
  3. package/dist/apptify.js +2298 -2140
  4. package/dist/components/AppleCard.vue.d.ts +76 -0
  5. package/dist/components/button.d.ts +3 -3
  6. package/dist/components/content.d.ts +0 -90
  7. package/dist/components/foundation.d.ts +201 -129
  8. package/dist/components/link.d.ts +31 -0
  9. package/dist/components/overlays.d.ts +8 -2
  10. package/dist/core/context.d.ts +11 -0
  11. package/dist/core/overlay-surface.d.ts +6 -0
  12. package/dist/core/popup-motion.d.ts +5 -5
  13. package/dist/core/popup-placement.d.ts +3 -2
  14. package/dist/index.d.ts +144 -79
  15. package/docs/COMPONENTS.md +24 -10
  16. package/docs/INPUT-WORKSHOP.md +59 -0
  17. package/docs/PLAYGROUND-TEMPLATE.md +32 -55
  18. package/docs/VALIDATION.md +1 -1
  19. package/package.json +14 -1
  20. package/templates/playground/README.md +14 -10
  21. package/templates/playground/index.html +1 -1
  22. package/templates/playground/package.json +6 -2
  23. package/templates/playground/source-snapshot.json +139 -54
  24. package/templates/playground/{playground → src}/App.vue +5 -4
  25. package/templates/playground/{playground → src}/ComponentDemo.vue +8 -9
  26. package/templates/playground/{playground → src}/catalog.ts +2 -3
  27. package/templates/playground/src/components/ComponentIndex.vue +43 -0
  28. package/templates/playground/src/components/DirectoryDialog.vue +20 -0
  29. package/templates/playground/src/components/PageFooter.vue +7 -0
  30. package/templates/playground/{playground → src}/components/UserMenu.vue +11 -11
  31. package/templates/playground/src/editor/AppleInputExample.vue +78 -0
  32. package/templates/playground/src/editor/AppleInputPreview.vue +82 -0
  33. package/templates/playground/src/editor/ComponentDocumentLink.vue +19 -0
  34. package/templates/playground/src/editor/ComponentWorkshop.vue +81 -0
  35. package/templates/playground/src/editor/ConfigurableEditor.vue +35 -0
  36. package/templates/playground/src/editor/LiveVueEditor.vue +133 -0
  37. package/templates/playground/src/editor/apple-input-source.ts +2 -0
  38. package/templates/playground/src/editor/apple-input.ts +48 -0
  39. package/templates/playground/src/editor/contract.ts +14 -0
  40. package/templates/playground/src/editor/documents.ts +20 -0
  41. package/templates/playground/src/editor/input-playground.ts +59 -0
  42. package/templates/playground/src/editor/runtime.ts +5 -0
  43. package/templates/playground/src/editor/sections.ts +10 -0
  44. package/templates/playground/{playground → src}/style.css +40 -39
  45. package/templates/playground/{playground → src}/views/[...all].vue +2 -2
  46. package/templates/playground/src/views/component-docs/[component].vue +162 -0
  47. package/templates/playground/src/views/components.vue +148 -0
  48. package/templates/playground/src/views/index.vue +73 -0
  49. package/templates/playground/src/views/settings.vue +225 -0
  50. package/templates/playground/tooling/playground-editor.mjs +114 -0
  51. package/templates/playground/tooling/prepare-input-source.mjs +38 -0
  52. package/templates/playground/tsconfig.json +2 -2
  53. package/templates/playground/vite.config.ts +3 -1
  54. package/tooling/playground-editor.mjs +114 -0
  55. package/tooling/prepare-input-source.mjs +38 -0
  56. package/tooling/prepare-playground.mjs +45 -19
  57. package/tooling/test-create-apptify.mjs +86 -0
  58. package/tooling/test-playground-package.mjs +13 -7
  59. package/templates/playground/playground/components/ComponentIndex.vue +0 -62
  60. package/templates/playground/playground/components/PageFooter.vue +0 -3
  61. package/templates/playground/playground/views/components.vue +0 -126
  62. package/templates/playground/playground/views/index.vue +0 -75
  63. package/templates/playground/playground/views/settings.vue +0 -98
  64. /package/templates/playground/{playground → src}/components/not-found-paper-scene.vue +0 -0
  65. /package/templates/playground/{playground → src}/components/not-found-paper-story.ts +0 -0
  66. /package/templates/playground/{playground → src}/env.d.ts +0 -0
  67. /package/templates/playground/{playground → src}/main.ts +0 -0
  68. /package/templates/playground/{playground → src}/router/index.ts +0 -0
@@ -1,9 +1,11 @@
1
1
  import { defineConfig } from 'vite'
2
2
  import vue from '@vitejs/plugin-vue'
3
3
  import apptifyRoutes from 'apptify/vite'
4
+ import playgroundEditor from './tooling/playground-editor.mjs'
4
5
 
5
6
  export default defineConfig({
6
- plugins: [apptifyRoutes({ dirs: [{ dir: 'playground/views', baseRoute: '' }] }), vue()],
7
+ optimizeDeps: { exclude: ['@vue/repl'] },
8
+ plugins: [playgroundEditor({ appDir: 'src', libraryDir: null }), apptifyRoutes(), vue()],
7
9
  server: { host: '127.0.0.1' },
8
10
  build: { outDir: 'dist' },
9
11
  })
@@ -0,0 +1,114 @@
1
+ import { build } from 'esbuild'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+ import { resolve } from 'node:path'
5
+ import { existsSync } from 'node:fs'
6
+ import { createHash } from 'node:crypto'
7
+ import { parse, compileScript, compileStyle } from 'vue/compiler-sfc'
8
+
9
+ // The editor runtime uses esbuild, so compile library SFCs just as Vite does.
10
+ const vueComponents = {
11
+ name: 'vue-components',
12
+ setup(builder) {
13
+ builder.onResolve({ filter: /\.vue\?style$/ }, args => ({ path: args.path.slice(0, -6), namespace: 'vue-style' }))
14
+ builder.onLoad({ filter: /\.vue$/ }, async args => {
15
+ const { descriptor, errors } = parse(await readFile(args.path, 'utf8'), { filename: args.path })
16
+ if (errors.length) throw errors[0]
17
+ const id = createHash('sha256').update(args.path).digest('hex').slice(0, 8)
18
+ const scope = `data-v-${id}`
19
+ if (args.namespace === 'vue-style') {
20
+ const styles = descriptor.styles.map(style => {
21
+ const result = compileStyle({ source: style.content, filename: args.path, id: scope, scoped: style.scoped })
22
+ if (result.errors.length) throw result.errors[0]
23
+ return result.code
24
+ })
25
+ return { contents: styles.join('\n'), loader: 'css' }
26
+ }
27
+ const script = compileScript(descriptor, { id, inlineTemplate: true, genDefaultAs: '__component' })
28
+ return {
29
+ contents: `${script.content}\n__component.__scopeId = ${JSON.stringify(scope)};\nexport default __component;\nimport ${JSON.stringify(args.path + '?style')};`,
30
+ loader: 'ts', resolveDir: resolve(args.path, '..'),
31
+ }
32
+ })
33
+ },
34
+ }
35
+
36
+ /** Local-only resources for the official Vue REPL. No CDN or compile service. */
37
+ export default function playgroundEditor({ appDir = 'playground', libraryDir = 'src' } = {}) {
38
+ let config
39
+ let resources
40
+ let rebuilding = Promise.resolve()
41
+ function rebuild() { rebuilding = rebuilding.catch(() => {}).then(prepare); return rebuilding }
42
+ async function prepare() {
43
+ if (libraryDir && existsSync(resolve(config.root, libraryDir, 'components/forms.ts'))) {
44
+ const { prepareInputSource } = await import('./prepare-input-source.mjs')
45
+ await prepareInputSource()
46
+ }
47
+ const require = createRequire(resolve(config.root, 'package.json'))
48
+ const bundle = await build({
49
+ absWorkingDir: config.root, entryPoints: [resolve(config.root, appDir, 'editor/runtime.ts')],
50
+ bundle: true, format: 'esm', platform: 'browser', external: ['vue'],
51
+ outfile: 'runtime.js', write: false, minify: true,
52
+ define: { 'process.env.NODE_ENV': '"production"' },
53
+ plugins: [vueComponents],
54
+ })
55
+ resources = new Map(bundle.outputFiles.map(file => [file.path.endsWith('.css') ? 'runtime.css' : 'runtime.js', file.contents]))
56
+ resources.set('vue.js', await readFile(require.resolve('vue/dist/vue.runtime.esm-browser.prod.js')))
57
+ resources.set('shims.js', await readFile(require.resolve('es-module-shims')))
58
+ }
59
+ return {
60
+ name: 'apptify-local-editor', enforce: 'pre',
61
+ configResolved(value) { config = value },
62
+ // Harden the official sandbox without replacing its compiler or module processing.
63
+ // Fail closed when upstream changes the reviewed implementation.
64
+ transform(code, id) {
65
+ if (!id.split('?')[0].replaceAll('\\', '/').endsWith('/@vue/repl/dist/vue-repl.js')) return
66
+ for (const permission of ['allow-forms', 'allow-modals', 'allow-pointer-lock', 'allow-popups', 'allow-same-origin', 'allow-top-navigation-by-user-activation']) {
67
+ const token = `"${permission}",`
68
+ if (!code.includes(token) && !code.includes(`"${permission}"\n`)) throw new Error('Vue REPL sandbox changed: review permissions before updating')
69
+ code = code.replace(token, '').replace(`"${permission}"\n`, '')
70
+ }
71
+ code = code.replace('const html = sandbox.contentDocument?.documentElement;', 'const html = null; // Opaque origin: rebuild on theme changes.')
72
+ code = code.replace('sandbox.contentWindow?.location.reload();', 'createSandbox();')
73
+ code = code.replace('\n\t\t\t\tswitchPreviewTheme();', '') // srcdoc already has the initial theme; avoid rebuilding on load.
74
+ return { code, map: null }
75
+ },
76
+ async handleHotUpdate(ctx) {
77
+ const source = libraryDir ? resolve(config.root, libraryDir) + '/' : undefined
78
+ if (!(source && ctx.file.startsWith(source)) && ctx.file !== resolve(config.root, appDir, 'editor/runtime.ts')) return
79
+ // Preview resources are an in-memory bundle, separate from Vite's normal CSS HMR.
80
+ // Rebuild it when library files change, then recreate the preview realm.
81
+ await rebuild()
82
+ ctx.server.ws.send({ type: 'full-reload', path: '*' })
83
+ return []
84
+ },
85
+ async configureServer(server) {
86
+ await rebuild()
87
+ server.middlewares.use((req, res, next) => {
88
+ const path = (req.url || '').split('?')[0]
89
+ const prefix = config.base + 'assets/editor/'
90
+ if (!path.startsWith(prefix)) return next()
91
+ const name = path.slice(prefix.length), content = resources.get(name)
92
+ if (!content) return next()
93
+ res.setHeader('Access-Control-Allow-Origin', '*')
94
+ res.setHeader('Cache-Control', 'no-store')
95
+ res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css' : 'text/javascript')
96
+ res.end(content)
97
+ })
98
+ },
99
+ configurePreviewServer(server) {
100
+ server.middlewares.use((req, res, next) => {
101
+ if ((req.url || '').startsWith(config.base + 'assets/editor/')) {
102
+ res.setHeader('Access-Control-Allow-Origin', '*')
103
+ res.setHeader('Cache-Control', 'no-cache')
104
+ }
105
+ next()
106
+ })
107
+ },
108
+ async buildStart() {
109
+ if (config.command !== 'build') return
110
+ await prepare()
111
+ for (const [name, source] of resources) this.emitFile({ type: 'asset', fileName: 'assets/editor/' + name, source })
112
+ },
113
+ }
114
+ }
@@ -0,0 +1,38 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { fileURLToPath } from 'node:url'
3
+ import ts from 'typescript'
4
+ import postcss from 'postcss'
5
+
6
+ // Extract the actual AppleInput declarations with TypeScript's AST. Preserve
7
+ // implementation logic; only erase TS types and route dependencies to public ESM.
8
+ export async function prepareInputSource() {
9
+ const root = new URL('../', import.meta.url)
10
+ const source = await readFile(new URL('src/components/forms.ts', root), 'utf8')
11
+ const ast = ts.createSourceFile('forms.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
12
+ const names = ['fieldProps', 'FieldVm', 'fieldId', 'controlAttrs', 'field', 'spinner', 'stringValue', 'AppleInput']
13
+ const declarations = new Map()
14
+ for (const statement of ast.statements) {
15
+ const name = ts.isVariableStatement(statement) ? statement.declarationList.declarations[0]?.name.getText(ast) : statement.name?.getText(ast)
16
+ if (names.includes(name)) declarations.set(name, statement.getText(ast))
17
+ }
18
+ for (const name of names) if (!declarations.has(name)) throw new Error(`AppleInput source changed: missing ${name}`)
19
+ const script = [
20
+ "import { defineComponent, h, useId, type PropType, type Slots, type VNodeChild } from 'vue'",
21
+ "import { ripple } from 'apptify'",
22
+ "import { Eye, EyeOff, LoaderCircle, X } from 'lucide-vue-next'",
23
+ ...names.map(name => declarations.get(name)),
24
+ 'export default AppleInput',
25
+ ].join('\n\n')
26
+ const js = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
27
+ const cssAst = postcss.parse(await readFile(new URL('src/styles/forms.css', root), 'utf8'))
28
+ const boundary = cssAst.nodes.findIndex(node => node.type === 'rule' && node.selector === '.apple-textarea')
29
+ if (boundary < 0) throw new Error('Input CSS section changed: review source extraction')
30
+ const css = cssAst.nodes.slice(0, boundary).map(node => node.toString()).join('\n')
31
+ const code = `<!-- AppleInput 同源实现:src/components/forms.ts + src/styles/forms.css。\n只移除 TypeScript 类型并转接浏览器公开依赖,保留 Options API/render 实现。 -->\n<script>\n${js}</script>\n\n<style>\n${css}\n</style>\n`
32
+ const destination = new URL('playground/editor/apple-input-source.ts', root)
33
+ const snapshot = '// Generated by tooling/prepare-input-source.mjs; do not edit the snapshot by hand.\nexport default ' + JSON.stringify(code) + '\n'
34
+ // Do not trigger a hot update during dev-server startup for unchanged data.
35
+ const previous = await readFile(destination, 'utf8').catch(() => undefined)
36
+ if (previous !== snapshot) await writeFile(destination, snapshot)
37
+ }
38
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await prepareInputSource()
@@ -3,22 +3,32 @@ import { basename, join, relative } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { createHash } from 'node:crypto'
5
5
 
6
+ import { prepareInputSource } from './prepare-input-source.mjs'
7
+ await prepareInputSource()
6
8
  const root = fileURLToPath(new URL('../', import.meta.url))
7
9
  const output = join(root, 'templates/playground')
8
10
  const library = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
9
11
  await rm(output, { recursive: true, force: true })
10
12
  await mkdir(output, { recursive: true })
11
13
  const copyOptions = { recursive: true, filter: source => basename(source) !== '.DS_Store' }
12
- await cp(join(root, 'playground'), join(output, 'playground'), copyOptions)
14
+ await cp(join(root, 'playground'), join(output, 'src'), copyOptions)
13
15
  await cp(join(root, 'public'), join(output, 'public'), copyOptions)
14
- await cp(join(root, 'index.html'), join(output, 'index.html'))
16
+ await writeFile(join(output, 'index.html'), (await readFile(join(root, 'index.html'), 'utf8')).replace('/playground/main.ts', '/src/main.ts'))
17
+ await mkdir(join(output, 'tooling'), { recursive: true })
18
+ await cp(join(root, 'tooling/prepare-input-source.mjs'), join(output, 'tooling/prepare-input-source.mjs'))
19
+ await cp(join(root, 'tooling/playground-editor.mjs'), join(output, 'tooling/playground-editor.mjs'))
15
20
 
16
- const snapshot = { createdAt: new Date().toISOString(), libraryVersion: library.version, files: {} }
21
+ const snapshot = { createdAt: new Date().toISOString(), libraryVersion: library.version, files: {}, sourcePaths: {} }
17
22
  async function record(directory) {
18
23
  for (const entry of await readdir(directory, { withFileTypes: true })) {
19
24
  const path = join(directory, entry.name)
20
25
  if (entry.isDirectory()) await record(path)
21
- else snapshot.files[relative(output, path)] = createHash('sha256').update(await readFile(path)).digest('hex')
26
+ else {
27
+ const key = relative(output, path).replaceAll('\\', '/')
28
+ const sourcePath = key.startsWith('src/') ? 'playground/' + key.slice(4) : key
29
+ snapshot.sourcePaths[key] = sourcePath
30
+ snapshot.files[key] = createHash('sha256').update(await readFile(join(root, sourcePath))).digest('hex')
31
+ }
22
32
  }
23
33
  }
24
34
  await record(output)
@@ -32,14 +42,15 @@ async function adapt(directory) {
32
42
  let source = await readFile(path, 'utf8')
33
43
  source = source.replace(/(['"])(?:\.\.\/)+src(?:\/(?:index|core\/motion|core\/context|components\/content))?\1/g, "'apptify'")
34
44
  if (/(['"])(?:\.\.\/)+src(?:\/[^'"]*)?\1/.test(source)) throw new Error('Unmapped private library import: ' + path)
45
+ if (path === join(output, 'src/editor/runtime.ts')) source = "import 'apptify/style.css'\n" + source
35
46
  if (entry.name === 'main.ts') source = "import 'apptify/style.css'\n" + source
36
- if (path === join(output, 'playground/App.vue')) {
47
+ if (path === join(output, 'src/App.vue')) {
37
48
  const navigationEnd = ']\n\nfunction navigate'
38
49
  if (!source.includes(navigationEnd)) throw new Error('App navigation layout changed; update template adaptation')
39
50
  source = source.replace('to="/" aria-label="Apptify 首页"', ':to="homePath" aria-label="Apptify 首页"')
40
51
  source = source.replace(navigationEnd, "].filter(item => router.getRoutes().some(page => page.path === item.href))\nconst homePath = navigation[0]?.href ?? '/'\n\nfunction navigate")
41
52
  }
42
- if (path === join(output, 'playground/views/[...all].vue')) {
53
+ if (path === join(output, 'src/views/[...all].vue')) {
43
54
  source = source.replace('to="/" custom', ':to="homePath" custom')
44
55
  source = source.replace('const router = useRouter()', "const router = useRouter()\nconst homePath = router.getRoutes().some(page => page.path === '/') ? '/' : '/settings'")
45
56
  source = source.replace("else void router.push('/')", 'else void router.push(homePath)')
@@ -48,7 +59,7 @@ async function adapt(directory) {
48
59
  }
49
60
  }
50
61
  }
51
- await adapt(join(output, 'playground'))
62
+ await adapt(join(output, 'src'))
52
63
  const manifest = {
53
64
  name: 'apptify-app', version: '0.0.0', private: true, type: 'module',
54
65
  scripts: { dev: 'vite', typecheck: 'vue-tsc --noEmit', build: 'vue-tsc --noEmit && vite build', preview: 'vite preview --host 127.0.0.1' },
@@ -57,19 +68,21 @@ const manifest = {
57
68
  'vue-router': library.devDependencies['vue-router'], vuetify: library.peerDependencies.vuetify,
58
69
  'lucide-vue-next': library.dependencies['lucide-vue-next'],
59
70
  },
60
- devDependencies: Object.fromEntries(['@vitejs/plugin-vue', 'typescript', 'vite', 'vite-plugin-pages', 'vue-tsc'].map(name => [name, library.devDependencies[name]])),
71
+ devDependencies: Object.fromEntries(['@vitejs/plugin-vue', 'typescript', 'vite', 'vite-plugin-pages', 'vue-tsc', '@vue/repl', 'es-module-shims', 'esbuild', 'postcss'].map(name => [name, library.devDependencies[name]])),
61
72
  }
62
73
  await writeFile(join(output, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
63
74
  const tsconfig = JSON.parse(await readFile(join(root, 'tsconfig.json'), 'utf8'))
64
- tsconfig.include = ['playground/**/*.ts', 'playground/**/*.vue']
75
+ tsconfig.include = ['src/**/*.ts', 'src/**/*.vue']
65
76
  await writeFile(join(output, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2) + '\n')
66
77
  await writeFile(join(output, 'vite.config.ts'), [
67
78
  "import { defineConfig } from 'vite'",
68
79
  "import vue from '@vitejs/plugin-vue'",
69
80
  "import apptifyRoutes from 'apptify/vite'",
81
+ "import playgroundEditor from './tooling/playground-editor.mjs'",
70
82
  '',
71
83
  'export default defineConfig({',
72
- " plugins: [apptifyRoutes({ dirs: [{ dir: 'playground/views', baseRoute: '' }] }), vue()],",
84
+ " optimizeDeps: { exclude: ['@vue/repl'] },",
85
+ " plugins: [playgroundEditor({ appDir: 'src', libraryDir: null }), apptifyRoutes(), vue()],",
73
86
  " server: { host: '127.0.0.1' },",
74
87
  " build: { outDir: 'dist' },",
75
88
  '})', '',
@@ -77,14 +90,27 @@ await writeFile(join(output, 'vite.config.ts'), [
77
90
  // npm excludes .gitignore from tarballs; the copy command restores its dot.
78
91
  await writeFile(join(output, 'gitignore'), 'node_modules/\ndist/\n.env.local\n')
79
92
  await writeFile(join(output, 'README.md'), [
80
- '# Apptify 可编辑应用模板', '',
81
- '完整源码位于 playground/,静态图片与来源说明位于 public/images/。导航、账号/登录、设置、404 和页面动效均可直接编辑复用。', '',
82
- '通过 apptify-playground 复制命令创建时,vendor/ 包含本地组件库 tgz,package.json 使用相对 file: 依赖;移动整个项目后仍可独立安装。', '',
83
- '运行 npm install、npm run typecheck、npm run build;开发使用 npm run dev。请使用 Node.js 22 或 24 LTS。', '',
84
- '开始业务开发可直接删除 playground/views/index.vue 和 playground/views/components.vue,再添加自己的页面;文件路由自动生成,导航自动隐藏已删除页面,品牌链接回到第一个保留的导航页面。删除页面后重启开发服务。', '',
85
- '导航项在 playground/App.vue 的 navigation 数组;页面文件在 playground/views/;布局和账号入口在 App.vue 与 playground/components/UserMenu.vue。', '',
86
- '设置保留主题、动效与玻璃偏好。404 在 playground/views/[...all].vue;首页和组件总览没有被其他页面硬导入。ComponentDemo.vue、catalog.ts 和 ComponentIndex.vue 可以保留,也可在删除总览页后按需删除。', '',
87
- '登录、注册、忘记密码、邮箱验证码仍为 UI 演示入口,需要接入自己的后端。部署采用 history 路由,服务器应把未知页面路径回退到 index.html。', '',
88
- '若手工复制包内 templates/playground,先将 package.json 的 apptify 依赖替换为实际本地 tgz 路径,或安装自己的正式发布版本;当前项目尚未发布 npm。', '',
93
+ "# Apptify 应用模板",
94
+ "",
95
+ "可编辑的 Vue 应用,包含导航、账号表单、外观设置、404 与组件示例。建议 Node.js 22 或 24 LTS。",
96
+ "",
97
+ "```sh",
98
+ "npm install",
99
+ "npm run dev",
100
+ "```",
101
+ "",
102
+ "检查:`npm run typecheck`、`npm run build`。",
103
+ "",
104
+ "- 页面:`src/views/`,文件路由自动生成。",
105
+ "- 布局与导航:`src/App.vue` 的 `navigation` 数组。",
106
+ "- 账号表单:`src/components/UserMenu.vue`,需要接入自己的后端。",
107
+ "- 样式:`src/style.css`;图片与来源:`public/images/`。",
108
+ "",
109
+ "可删除 `views/index.vue` 和 `views/components.vue`,再添加业务页面;删除后重启开发服务。删除组件总览后,可一并删除 `ComponentDemo.vue`、`catalog.ts` 和 `components/ComponentIndex.vue`。",
110
+ "",
111
+ "复制命令在 `vendor/` 保存库归档,移动整个项目后仍可独立安装。使用 npm 更新库:`npm install apptify@latest`。手工复制模板时,将 `gitignore` 重命名为 `.gitignore`。",
112
+ "",
113
+ "部署时将页面请求回退到 `index.html`,保留 API 与静态资源路由。",
114
+ "",
89
115
  ].join('\n'))
90
116
  console.log('Prepared editable playground template: ' + output)
@@ -0,0 +1,86 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { execFileSync, spawnSync } from 'node:child_process'
7
+
8
+ const root = fileURLToPath(new URL('../', import.meta.url))
9
+ const scratch = await mkdtemp(join(tmpdir(), 'create-apptify-test-'))
10
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
11
+ const env = { ...process.env, npm_config_cache: join(scratch, 'cache'), npm_config_offline: 'true' }
12
+ const installer = join(scratch, 'installer')
13
+ const creator = join(installer, 'node_modules/create-apptify')
14
+ const library = join(installer, 'node_modules/apptify')
15
+ const cli = join(creator, 'bin/create-apptify.mjs')
16
+
17
+ async function unpack(cwd, destination) {
18
+ const packed = JSON.parse(execFileSync(npm, ['pack', '--ignore-scripts', '--json', '--pack-destination', scratch], { cwd, env, encoding: 'utf8' }))[0]
19
+ await mkdir(destination, { recursive: true })
20
+ execFileSync('tar', ['-xzf', join(scratch, packed.filename), '--strip-components=1', '-C', destination])
21
+ return packed
22
+ }
23
+
24
+ try {
25
+ // Use real tarballs in an installed layout, without fetching dependencies.
26
+ // The generator only reads/copies the library; it never imports its Vue code.
27
+ const packed = await unpack(join(root, 'packages/create-apptify'), creator)
28
+ const files = new Set(packed.files.map(file => file.path))
29
+ for (const path of ['package.json', 'bin/create-apptify.mjs', 'README.md', 'LICENSE']) assert(files.has(path), 'Missing creator file: ' + path)
30
+ await unpack(root, library)
31
+ await mkdir(join(installer, 'node_modules/.bin'))
32
+ await symlink('../create-apptify/bin/create-apptify.mjs', join(installer, 'node_modules/.bin/create-apptify'))
33
+ await writeFile(join(installer, 'package.json'), JSON.stringify({ private: true, dependencies: { 'create-apptify': packed.version } }) + '\n')
34
+
35
+ const run = (args, cwd = installer) => spawnSync(process.execPath, [cli, ...args], { cwd, env, encoding: 'utf8' })
36
+ const help = run(['--help'])
37
+ assert.equal(help.status, 0, help.stderr)
38
+ assert.match(help.stdout, /npm create apptify@latest/)
39
+ assert.equal(run([]).status, 1)
40
+ assert.match(run([]).stderr, /Destination must be empty/)
41
+ assert.equal(run(['--unknown']).status, 1)
42
+ assert.equal(run(['one', 'two']).status, 1)
43
+
44
+ const app = join(scratch, 'my app')
45
+ const result = spawnSync(npm, ['create', 'apptify', '--offline', '--', app], { cwd: installer, env, encoding: 'utf8', timeout: 60_000 })
46
+ assert.equal(result.status, 0, result.stdout + result.stderr)
47
+ const manifest = JSON.parse(await readFile(join(app, 'package.json'), 'utf8'))
48
+ assert.match(manifest.dependencies.apptify, /^file:\.\/vendor\/apptify-/)
49
+ assert.match(await readFile(join(app, '.gitignore'), 'utf8'), /node_modules/)
50
+ await readFile(join(app, 'src/App.vue'))
51
+ assert(!(await readdir(app)).includes('playground'), 'Generated app must use src/')
52
+ assert.match(await readFile(join(app, 'index.html'), 'utf8'), /src="\/src\/main\.ts"/)
53
+ assert.deepEqual(JSON.parse(await readFile(join(app, 'tsconfig.json'), 'utf8')).include, ['src/**/*.ts', 'src/**/*.vue'])
54
+ const archive = join(app, manifest.dependencies.apptify.slice('file:./'.length))
55
+ const portableManifest = JSON.parse(execFileSync('tar', ['-xOf', archive, 'package/package.json'], { encoding: 'utf8' }))
56
+ assert.equal(portableManifest.name, 'apptify')
57
+
58
+ const before = await readFile(join(app, 'package.json'), 'utf8')
59
+ const refused = run([app])
60
+ assert.equal(refused.status, 1)
61
+ assert.match(refused.stderr, /Destination must be empty/)
62
+ assert.equal(await readFile(join(app, 'package.json'), 'utf8'), before)
63
+ assert.equal(run([join(library, 'unsafe')]).status, 1)
64
+ const alias = join(scratch, 'library-alias')
65
+ await symlink(library, alias, 'dir')
66
+ assert.equal(run([join(alias, 'unsafe')]).status, 1)
67
+
68
+ const empty = join(scratch, 'existing-empty')
69
+ await mkdir(empty)
70
+ const copied = run([empty])
71
+ assert.equal(copied.status, 0, copied.stderr)
72
+ await readFile(join(empty, 'src/App.vue'))
73
+ const current = join(scratch, 'current-directory')
74
+ await mkdir(current)
75
+ const defaultTarget = run([], current)
76
+ assert.equal(defaultTarget.status, 0, defaultTarget.stderr)
77
+ await readFile(join(current, 'src/router/index.ts'))
78
+ assert.match(JSON.parse(await readFile(join(current, 'package.json'), 'utf8')).dependencies.apptify, /^file:\.\/vendor\//)
79
+ await rm(installer, { recursive: true, force: true })
80
+ execFileSync('tar', ['-t', '-f', archive])
81
+ console.log('create-apptify: packed CLI, npm create, portable archive, help, argument validation and overwrite protection passed.')
82
+ await rm(scratch, { recursive: true, force: true })
83
+ } catch (error) {
84
+ console.error('Acceptance workspace retained: ' + scratch)
85
+ throw error
86
+ }
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { execFileSync, spawn } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
- import { chromium } from '@playwright/test'
8
+ import { chromium, expect } from '@playwright/test'
9
9
 
10
10
  const root = fileURLToPath(new URL('../', import.meta.url))
11
11
  const scratch = await mkdtemp(join(tmpdir(), 'apptify-package-'))
@@ -123,6 +123,12 @@ try {
123
123
  assert.equal(await image.evaluate(async image => { await image.decode(); return image.naturalWidth > 0 }), true)
124
124
  await page.locator('.component-index [aria-current]').first().waitFor()
125
125
  })
126
+ await checkPage(server.url, '/component-docs/apple-input', async page => {
127
+ const frame = page.frameLocator('[data-testid="input-playground"] .vue-repl iframe')
128
+ await expect(frame.locator('.apple-input-wrap input').first()).toBeVisible()
129
+ await frame.locator('.apple-input-wrap input').first().fill('Template src preview')
130
+ await expect(frame.locator('.apple-input-wrap input').first()).toHaveValue('Template src preview')
131
+ })
126
132
  await checkPage(server.url, '/settings', async page => {
127
133
  await page.setViewportSize({ width: 320, height: 900 })
128
134
  await page.getByRole('button', { name: '深色', exact: true }).click()
@@ -150,12 +156,12 @@ try {
150
156
  server.stop()
151
157
  server = undefined
152
158
 
153
- await rm(join(app, 'playground/views/index.vue'))
154
- await rm(join(app, 'playground/views/components.vue'))
159
+ await rm(join(app, 'src/views/index.vue'))
160
+ await rm(join(app, 'src/views/components.vue'))
155
161
  // These files only served the removed overview page. Deleting them must be optional.
156
- await rm(join(app, 'playground/ComponentDemo.vue'))
157
- await rm(join(app, 'playground/catalog.ts'))
158
- await rm(join(app, 'playground/components/ComponentIndex.vue'))
162
+ await rm(join(app, 'src/ComponentDemo.vue'))
163
+ await rm(join(app, 'src/catalog.ts'))
164
+ await rm(join(app, 'src/components/ComponentIndex.vue'))
159
165
  run(['run', 'typecheck'], app)
160
166
  run(['run', 'build'], app)
161
167
  server = await serve()
@@ -178,7 +184,7 @@ try {
178
184
  await browser.close()
179
185
  }
180
186
  for (const [path, hash] of Object.entries(snapshot.files)) {
181
- assert.equal(createHash('sha256').update(await readFile(join(root, path))).digest('hex'), hash, 'Source changed during packing; rerun for its latest snapshot: ' + path)
187
+ assert.equal(createHash('sha256').update(await readFile(join(root, snapshot.sourcePaths?.[path] ?? path))).digest('hex'), hash, 'Source changed during packing; rerun for its latest snapshot: ' + path)
182
188
  }
183
189
  for (const [path, hash] of Object.entries(snapshot.buildSources || {})) {
184
190
  assert.equal(createHash('sha256').update(await readFile(join(root, path))).digest('hex'), hash, 'Build source changed after this archive was created; pack the latest version: ' + path)
@@ -1,62 +0,0 @@
1
- <template>
2
- <nav class="component-index" aria-label="组件定位">
3
- <AppleTree :items="treeItems" :model-value="activeId || activeGroup" :expanded="expandedGroups" label="组件目录树" @update:expanded="expanded = $event" @select="navigate">
4
- <template #item="{ item }">
5
- <router-link :to="`/components#${item.value}`" :class="['index-link', { 'index-group': item.children, active: activeId === item.value || (item.children && activeGroup === item.value) }]" :aria-current="activeId === item.value ? 'location' : undefined" tabindex="-1" @click.prevent>
6
- <component :is="icons[item.label]" v-if="item.children" :size="15" aria-hidden="true" />
7
- <span>{{ item.label }}</span><small v-if="item.children">{{ item.children.length }}</small>
8
- </router-link>
9
- </template>
10
- </AppleTree>
11
- <p v-if="!treeItems.length" class="index-empty" role="status">未找到组件,请尝试其他关键词。</p>
12
- </nav>
13
- </template>
14
-
15
- <script setup lang="ts">
16
- import { computed, ref, watch } from 'vue'
17
- import { useRoute, useRouter } from 'vue-router'
18
- import { Bell, Box, MousePointer2, Smartphone, Table2, TextCursorInput } from 'lucide-vue-next'
19
- import { AppleTree } from 'apptify'
20
- import type { AppleTreeItem, AppleValue } from 'apptify'
21
- import { sections, componentId } from '../catalog'
22
-
23
- const props = defineProps<{ activeGroup: string; activeId: string; search?: string }>()
24
- const emit = defineEmits<{ navigate: [] }>()
25
- const router = useRouter()
26
- const route = useRoute()
27
- const expanded = ref<AppleValue[]>([props.activeGroup])
28
- const icons: Record<string, typeof Box> = { '基础': Box, '表单': TextCursorInput, '导航': MousePointer2, '数据展示': Table2, '反馈': Bell, '移动交互': Smartphone }
29
- const query = computed(() => props.search?.trim().toLocaleLowerCase() ?? '')
30
- const treeItems = computed<AppleTreeItem[]>(() => sections.flatMap(section => {
31
- const groupMatches = section.label.toLocaleLowerCase().includes(query.value)
32
- const items = section.items.filter(item => groupMatches || `${item.label} ${item.name} ${componentId(item.name)} ${item.description}`.toLocaleLowerCase().includes(query.value))
33
- return items.length ? [{ label: section.label, value: section.id, children: items.map(item => ({ label: item.label, value: componentId(item.name) })) }] : []
34
- }))
35
- const expandedGroups = computed(() => query.value ? treeItems.value.map(item => item.value) : expanded.value)
36
- watch(() => props.activeGroup, group => {
37
- if (!expanded.value.includes(group)) expanded.value = [...expanded.value, group]
38
- })
39
- function navigate(item: AppleTreeItem) {
40
- const hash = `#${item.value}`
41
- if (route.hash === hash) {
42
- const target = document.getElementById(String(item.value))
43
- if (target) window.scrollTo({ top: target.getBoundingClientRect().top + window.scrollY - 96, behavior: 'instant' })
44
- } else void router.push(`/components${hash}`)
45
- emit('navigate')
46
- }
47
- </script>
48
-
49
- <style scoped>
50
- .component-index { min-width: 0; }
51
- .component-index :deep(.apple-tree__row) { min-height: 38px; gap: 0; font-size: 12px; border-radius: 6px; }
52
- .component-index :deep(.apple-tree__toggle), .component-index :deep(.apple-tree__spacer) { flex-basis: 24px; width: 24px; height: 34px; }
53
- .component-index .index-link { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; padding: 8px 4px; font-size: 12px; color: inherit; text-decoration: none; }
54
- .component-index .index-group { border-radius: 0; }
55
- /* The tree row owns the fade; avoid a second, smaller hover background on its link. */
56
- .component-index .index-link:hover { background: transparent; }
57
- .index-link > span { flex: 1; overflow-wrap: anywhere; }
58
- .index-link > svg { flex-shrink: 0; }
59
- .index-link.active { color: var(--apple-text); font-weight: 600; }
60
- .index-link small { font-size: 10px; color: var(--apple-secondary); font-variant-numeric: tabular-nums; }
61
- .index-empty { margin: 12px 8px; font-size: 12px; line-height: 1.7; color: var(--apple-secondary); }
62
- </style>
@@ -1,3 +0,0 @@
1
- <template>
2
- <footer class="page-footer"><span>Apptify</span><span>Write your own footer here</span></footer>
3
- </template>
@@ -1,126 +0,0 @@
1
- <template>
2
- <apple-aside class="sidebar" aria-label="组件目录">
3
- <div class="sidebar-heading"><strong>组件目录</strong><span>点击定位,直接预览</span></div>
4
- <div class="directory-search"><apple-search v-model="directorySearch" label="搜索组件目录" placeholder="名称、标签或描述" /></div>
5
- <ComponentIndex :active-group="activeGroup" :active-id="activeId" :search="directorySearch" />
6
- </apple-aside>
7
- <main class="main-content components-page">
8
- <header class="page-heading"><div><div class="page-kicker">COMPONENTS</div><h1>组件<span>。</span></h1><p>{{ catalog.length }} 个组件,完整呈现。向下浏览,直接体验。</p></div></header>
9
- <div class="feed-toolbar">
10
- <apple-button class="mobile-index-toggle" variant="ghost" size="small" :icon="PanelLeft" @click="mobileIndex = true">组件目录</apple-button>
11
- <span class="feed-count">全部组件 <small>{{ catalog.length }}</small></span>
12
- <div class="preview-controls" role="group" aria-label="预览尺寸">
13
- <apple-button variant="ghost" size="small" icon-only :icon="Monitor" :class="{ active: previewMode === 'desktop' }" :aria-pressed="previewMode === 'desktop'" label="桌面预览" @click="previewMode = 'desktop'" />
14
- <apple-button variant="ghost" size="small" icon-only :icon="Smartphone" :class="{ active: previewMode === 'mobile' }" :aria-pressed="previewMode === 'mobile'" label="手机预览" @click="previewMode = 'mobile'" />
15
- </div>
16
- </div>
17
- <div ref="feed" class="component-feed">
18
- <section v-for="(section, index) in sections" :id="section.id" :key="section.id" class="feed-group feed-anchor" :data-group="section.id" :aria-labelledby="`${section.id}-heading`">
19
- <header class="feed-group-heading"><span>0{{ index + 1 }}</span><h2 :id="`${section.id}-heading`">{{ section.label }}</h2><small>{{ section.items.length }} 个组件</small></header>
20
- <article v-for="item in section.items" :id="componentId(item.name)" :key="item.name" class="component-card feed-anchor" :data-group="section.id" :aria-labelledby="`${componentId(item.name)}-heading`">
21
- <header class="component-card-heading"><div><h3 :id="`${componentId(item.name)}-heading`"><router-link :to="`#${componentId(item.name)}`">{{ item.label }}<Hash :size="14" /></router-link></h3><p>{{ item.description }}</p></div><code>{{ componentId(item.name) }}</code></header>
22
- <section class="detail-preview" :class="{ 'mobile-preview': previewMode === 'mobile' }" :aria-label="`${item.label}预览`"><ComponentDemo :name="item.name" @navigate="navigateGroup" /></section>
23
- <apple-accordion class="component-source" :items="sourceSections">
24
- <template #item>
25
- <div class="code-view"><div class="code-toolbar"><span>使用示例</span><apple-button variant="ghost" size="small" @click="copy(item.code)">复制代码</apple-button></div><pre><code>{{ item.code }}</code></pre><h4>接口</h4><p class="api-line">{{ item.api }}</p></div>
26
- </template>
27
- </apple-accordion>
28
- </article>
29
- </section>
30
- </div>
31
- <PageFooter />
32
- </main>
33
- <apple-drawer v-model="mobileIndex" title="组件目录" placement="left" width="290px">
34
- <div class="directory-search"><apple-search v-model="directorySearch" label="搜索组件目录" placeholder="名称、标签或描述" /></div>
35
- <ComponentIndex :active-group="activeGroup" :active-id="activeId" :search="directorySearch" @navigate="mobileIndex = false" />
36
- </apple-drawer>
37
- </template>
38
-
39
- <script setup lang="ts">
40
- import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
41
- import { useRoute, useRouter } from 'vue-router'
42
- import { Hash, Monitor, PanelLeft, Smartphone } from 'lucide-vue-next'
43
- import { useApple } from 'apptify'
44
- import { catalog, sections, componentId } from '../catalog'
45
- import ComponentDemo from '../ComponentDemo.vue'
46
- import ComponentIndex from '../components/ComponentIndex.vue'
47
- import PageFooter from '../components/PageFooter.vue'
48
-
49
- const route = useRoute()
50
- const router = useRouter()
51
- const apple = useApple()
52
- const feed = ref<HTMLElement>()
53
- const activeGroup = ref(sections[0].id)
54
- const activeId = ref('')
55
- const previewMode = ref('desktop')
56
- const mobileIndex = ref(false)
57
- const directorySearch = ref('')
58
- const sourceSections = [{ label: '代码与 API', value: 'source' }]
59
- let anchors: HTMLElement[] = []
60
- let frame = 0
61
- let observer: ResizeObserver | undefined
62
-
63
- function updatePosition() {
64
- frame = 0
65
- let current: HTMLElement | undefined
66
- for (const anchor of anchors) {
67
- if (anchor.getBoundingClientRect().top > 128) break
68
- if (!current || Math.abs(anchor.getBoundingClientRect().top - current.getBoundingClientRect().top) > 1) current = anchor
69
- else if (anchor.id === route.hash.slice(1) || (current.id !== route.hash.slice(1) && anchor.id === activeId.value)) current = anchor
70
- }
71
- activeId.value = current?.id ?? ''
72
- activeGroup.value = current?.dataset.group ?? sections[0].id
73
- }
74
- function schedulePosition() {
75
- if (!frame) frame = requestAnimationFrame(updatePosition)
76
- }
77
- function navigateGroup(label: string) {
78
- const section = sections.find(section => section.label === label)
79
- void router.push(section ? `/components#${section.id}` : '/components')
80
- }
81
- async function copy(value: string) {
82
- try { await navigator.clipboard.writeText(value); apple.notify('已复制', { tone: 'success' }) }
83
- catch { apple.notify('无法访问剪贴板,请手动选择代码复制', { tone: 'warning' }) }
84
- }
85
- watch(() => route.hash, () => { mobileIndex.value = false; schedulePosition() })
86
- watch(activeId, async () => {
87
- await nextTick()
88
- const sidebar = document.querySelector<HTMLElement>('.sidebar')
89
- const current = sidebar?.querySelector<HTMLElement>('[aria-current="location"]')
90
- if (!sidebar || !current) return
91
- const item = current.getBoundingClientRect(), bounds = sidebar.getBoundingClientRect()
92
- if (item.top < bounds.top + 16) sidebar.scrollTop -= bounds.top + 16 - item.top
93
- else if (item.bottom > bounds.bottom - 16) sidebar.scrollTop += item.bottom - bounds.bottom + 16
94
- })
95
- onMounted(() => {
96
- anchors = Array.from(feed.value!.querySelectorAll<HTMLElement>('.feed-anchor'))
97
- window.addEventListener('scroll', schedulePosition, { passive: true })
98
- window.addEventListener('resize', schedulePosition)
99
- observer = new ResizeObserver(schedulePosition)
100
- observer.observe(feed.value!)
101
- schedulePosition()
102
- })
103
- onBeforeUnmount(() => {
104
- window.removeEventListener('scroll', schedulePosition)
105
- window.removeEventListener('resize', schedulePosition)
106
- observer?.disconnect()
107
- cancelAnimationFrame(frame)
108
- })
109
- </script>
110
-
111
- <style scoped>
112
- .directory-search { min-width: 0; margin: 0 4px 16px; }
113
- .directory-search :deep(.apple-field__label) { font-size: 11px; }
114
- .directory-search :deep(input) { min-width: 0; font-size: 12px; }
115
- .components-page > * { max-width: none; }
116
- .feed-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr)); gap: 20px; align-items: start; }
117
- .feed-group-heading { grid-column: 1 / -1; padding-bottom: 0; }
118
- .component-card { margin-bottom: 0; }
119
- .component-source :deep(.apple-accordion__item) { border: 0; }
120
- .component-source :deep(.apple-accordion__item > h3) { font-size: 11px; font-weight: 400; }
121
- .component-source :deep(.apple-accordion__item > h3 > button) { min-height: 44px; gap: 8px; padding: 13px 28px; color: var(--apple-secondary); }
122
- .component-source :deep(.apple-accordion__content) { padding: 0; }
123
- @media (max-width: 800px) {
124
- .component-source :deep(.apple-accordion__item > h3 > button) { padding-inline: 18px; }
125
- }
126
- </style>