@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3

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 (251) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +55 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +4 -1
  117. package/src/components/import/runner.ts +1 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,186 @@
1
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, relative, sep } from 'node:path';
3
+ import { SectionDefinition, Slug, ThemeManifest, BUILTIN_SECTIONS } from '../src/theme-sdk/contract.mjs';
4
+ import { CORE, project, fail, exists, readJson, atomicJson } from './common.mjs';
5
+ import { targetApp, libraryTwin, stubTwin } from './native-sections.mjs';
6
+
7
+ // Editable-section tooling for coding agents and developers:
8
+ // section library list the starter sections shipped with core
9
+ // section add <name> --theme <t> copy a starter section into a project theme
10
+ // [--native] …and an editable native copy into the mobile app
11
+ // section new <type> --theme <t> scaffold a blank coded section (and, when the
12
+ // site has a mobile app, a typed native stub)
13
+ // theme types <t> write src/themes/<t>/section-types.d.ts
14
+
15
+ const LIBRARY = join(CORE, 'section-library');
16
+ const pascal = (type) => type.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join('');
17
+
18
+ export async function libraryEntries() {
19
+ const out = [];
20
+ for (const name of (await readdir(LIBRARY).catch(() => [])).sort()) {
21
+ const file = join(LIBRARY, name, 'section.json');
22
+ if (!(await exists(file))) continue;
23
+ const raw = await readJson(file);
24
+ const def = SectionDefinition.parse(raw.definition);
25
+ out.push({ name, type: raw.type, title: def.name, description: def.description ?? '', variants: def.variants, fields: Object.fromEntries(Object.entries(def.fields).map(([k, f]) => [k, f.type])) });
26
+ }
27
+ return out;
28
+ }
29
+
30
+ async function projectTheme(p, slug) {
31
+ if (!slug) fail('USAGE', 'Choose a project theme with --theme <slug>.', [], ['acms theme create <slug> makes one.']);
32
+ Slug.parse(slug);
33
+ const dir = join(p.cwd, 'src/themes', slug);
34
+ if (!(await exists(join(dir, 'theme.json')))) fail('NOT_FOUND', `Project theme ${slug} was not found in src/themes/.`, [], [`acms theme create ${slug}`]);
35
+ return dir;
36
+ }
37
+
38
+ async function addToManifest(dir, type, definition, flags) {
39
+ const manifestPath = join(dir, 'theme.json');
40
+ const raw = await readJson(manifestPath);
41
+ raw.sections ??= {};
42
+ if (raw.sections[type] && !flags.force) fail('EXISTS', `theme.json already defines a “${type}” section.`, [], ['Pick another name with --as <type>, or --force to replace it.']);
43
+ raw.sections[type] = definition;
44
+ ThemeManifest.parse(raw);
45
+ await atomicJson(manifestPath, raw, 0o644);
46
+ }
47
+
48
+ export async function sectionCommand(command, args, flags) {
49
+ if (command === 'library') return { sections: await libraryEntries(), next: 'acms section add <name> --theme <slug>' };
50
+ const p = await project();
51
+ if (command === 'add') {
52
+ const name = args[0];
53
+ if (!name) fail('USAGE', 'Name a starter section.', [], ['acms section library lists them.']);
54
+ const file = join(LIBRARY, name, 'section.json');
55
+ if (!(await exists(file))) fail('NOT_FOUND', `No starter section named ${name}.`, [], ['acms section library lists them.']);
56
+ const dir = await projectTheme(p, flags.theme);
57
+ const raw = await readJson(file);
58
+ const type = flags.as ?? raw.type;
59
+ Slug.parse(type);
60
+ if (BUILTIN_SECTIONS[type] && !flags.force) fail('EXISTS', `“${type}” is a built-in section; replacing it would change every page that uses it.`, [], ['Pick another name with --as <type>.']);
61
+ const componentFile = raw.definition.component.split('/').pop();
62
+ const target = type === raw.type ? componentFile : `${pascal(type)}.astro`;
63
+ const definition = { ...raw.definition, component: `sections/${target}` };
64
+ const dest = join(dir, 'sections', target);
65
+ // the mobile app: packaged native component by default, an editable copy with --native
66
+ const mobile = await targetApp(flags, { required: !!flags.native });
67
+ // already in the theme + --native: only add the app's copy, leave the web section alone
68
+ const nativeOnly = flags.native && !flags.force && (await exists(dest)) && Boolean((await readJson(join(dir, 'theme.json'))).sections?.[type]);
69
+ if ((await exists(dest)) && !flags.force && !nativeOnly) fail('EXISTS', `sections/${target} already exists.`, [], ['Use --as <type> or --force.']);
70
+ const nativePlan = flags.native ? await libraryTwin(mobile.app, name, type, flags, false) : null;
71
+ if (flags['dry-run']) return { dryRun: true, type, files: nativeOnly ? [] : [`sections/${target}`, 'theme.json'], ...(nativePlan ? { native: nativePlan } : {}) };
72
+ if (nativeOnly) return { added: type, web: 'unchanged', native: await libraryTwin(mobile.app, name, type, flags, true) };
73
+ await mkdir(join(dir, 'sections'), { recursive: true });
74
+ await writeFile(dest, await readFile(join(LIBRARY, name, componentFile), 'utf8'));
75
+ await addToManifest(dir, type, definition, flags);
76
+ const native = flags.native ? await libraryTwin(mobile.app, name, type, flags, true)
77
+ : mobile.app ? { app: mobile.app.dir, packaged: type === raw.type, note: type === raw.type
78
+ ? `The app renders “${type}” with the packaged component from @acms-subir/native/sections. For an editable copy in ${mobile.app.dir}/sections/, run: acms section add ${name} --theme ${flags.theme} --native`
79
+ : `“${type}” is a renamed ${raw.type}; the app shows it as a web view until you add a native copy: acms section add ${name} --theme ${flags.theme} --as ${type} --native` }
80
+ : mobile.warning ? { note: mobile.warning } : undefined;
81
+ return { added: type, component: `src/themes/${flags.theme}/sections/${target}`, example: raw.example, ...(native ? { native } : {}), next: [`acms theme types ${flags.theme}`, `acms theme check ${flags.theme}`] };
82
+ }
83
+ if (command === 'new') {
84
+ if (flags.native) fail('USAGE', '--native is for section add; section new writes a native stub whenever the site has a mobile app.');
85
+ const type = args[0];
86
+ if (!type) fail('USAGE', 'Name the section type, e.g. acms section new service-times --theme studio');
87
+ Slug.parse(type);
88
+ if (BUILTIN_SECTIONS[type]) fail('EXISTS', `“${type}” is a built-in section. Pick another name.`);
89
+ const dir = await projectTheme(p, flags.theme);
90
+ const file = `${pascal(type)}.astro`;
91
+ const title = flags.name ?? type.split('-').map((w, i) => (i ? w : w[0].toUpperCase() + w.slice(1))).join(' ');
92
+ const definition = {
93
+ name: title, version: 1, component: `sections/${file}`, variants: ['default'],
94
+ fields: {
95
+ heading: { label: 'Heading', type: 'text', default: title },
96
+ items: { label: 'Items', type: 'list', itemLabel: 'title', max: 12, fields: { title: { label: 'Title', type: 'text' }, text: { label: 'Text', type: 'textarea' } } },
97
+ },
98
+ };
99
+ if (await exists(join(dir, 'sections', file))) fail('EXISTS', `sections/${file} already exists.`);
100
+ if (flags['dry-run']) return { dryRun: true, type, files: [`sections/${file}`, 'theme.json'] };
101
+ await mkdir(join(dir, 'sections'), { recursive: true });
102
+ await writeFile(join(dir, 'sections', file), `---
103
+ import type { SectionProps } from '@acms-subir/core/theme';
104
+ import type { ${pascal(type)}Data } from '../section-types';
105
+ const { data, variant } = Astro.props as SectionProps<${pascal(type)}Data>;
106
+ ---
107
+ <div class:list={['${type}', \`is-\${variant}\`]}>
108
+ <h2>{data.heading}</h2>
109
+ <ul>
110
+ {data.items.filter((item) => item.title).map((item) => (
111
+ <li><b>{item.title}</b>{item.text && <p>{item.text}</p>}</li>
112
+ ))}
113
+ </ul>
114
+ </div>
115
+ <style>
116
+ ul { list-style: none; margin: 1.25rem 0 0; padding: 0; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
117
+ li { padding: 1rem; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); }
118
+ p { margin: 0.35rem 0 0; color: var(--muted); }
119
+ </style>
120
+ `);
121
+ await addToManifest(dir, type, definition, flags);
122
+ await writeTypes(dir);
123
+ // a typed native stub beside it, when the site has a mobile app
124
+ const mobile = await targetApp(flags);
125
+ const native = mobile.app ? { ...(await writeAppTypes(mobile.app.dir, dir)), ...(await stubTwin(mobile.app, type, title, flags.theme, flags)) } : mobile.warning ? { note: mobile.warning } : undefined;
126
+ return { created: type, component: `src/themes/${flags.theme}/sections/${file}`, ...(native ? { native } : {}), next: ['Edit the fields in theme.json and the markup in the component', ...(native?.component ? [`Mirror the markup in ${native.component}`] : []), `acms theme types ${flags.theme}`, `acms theme check ${flags.theme} --build`] };
127
+ }
128
+ fail('USAGE', 'Unknown section command.', [], ['acms section library | add <name> --theme <slug> | new <type> --theme <slug>']);
129
+ }
130
+
131
+ // ---- types ---------------------------------------------------------------
132
+
133
+ const quote = (s) => JSON.stringify(s);
134
+ function tsType(field, indent = ' ') {
135
+ switch (field.type) {
136
+ case 'number': return 'number';
137
+ case 'toggle': return 'boolean';
138
+ case 'link': return 'LinkValue';
139
+ case 'select': return field.options?.length ? field.options.map(quote).join(' | ') : 'string';
140
+ case 'source': return field.source === 'events' ? 'EventItem[]' : field.source === 'posts' ? 'PostItem[]' : 'BookingItem[]';
141
+ case 'list': return `Array<{\n${Object.entries(field.fields).flatMap(([k, f]) => [`${indent} ${k}: ${tsType(f, indent + ' ')};`, ...(f.type === 'image' ? [`${indent} ${k}Focus: string;`] : [])]).join('\n')}\n${indent}}>`;
142
+ default: return 'string';
143
+ }
144
+ }
145
+
146
+ export function sectionTypes(manifest, { importFrom = '@acms-subir/core/theme', header = '// Generated by `acms theme types` from theme.json. Rerun after changing section fields.' } = {}) {
147
+ const lines = [
148
+ header,
149
+ `import type { LinkValue, EventItem, PostItem, BookingItem } from '${importFrom}';`,
150
+ '',
151
+ ];
152
+ const map = [];
153
+ for (const [type, def] of Object.entries(manifest.sections ?? {})) {
154
+ const name = `${pascal(type)}Data`;
155
+ map.push(` ${quote(type)}: ${name};`);
156
+ lines.push(`/** ${def.name}${def.description ? ` — ${def.description}` : ''} */`);
157
+ lines.push(`export interface ${name} {`);
158
+ for (const [key, field] of Object.entries(def.fields)) {
159
+ lines.push(` /** ${field.label} */\n ${key}: ${tsType(field, ' ')};`);
160
+ if (field.type === 'image') lines.push(` /** focal point for object-position, e.g. "30% 20%" */\n ${key}Focus: string;`);
161
+ }
162
+ lines.push('}', '');
163
+ }
164
+ lines.push('export interface SectionData {', ...map, '}', '');
165
+ return lines.join('\n');
166
+ }
167
+
168
+ export async function writeTypes(dir) {
169
+ const manifest = ThemeManifest.parse(await readJson(join(dir, 'theme.json')));
170
+ const text = sectionTypes(manifest);
171
+ const file = join(dir, 'section-types.d.ts');
172
+ const before = await readFile(file, 'utf8').catch(() => '');
173
+ if (before !== text) await writeFile(file, text);
174
+ return { file, changed: before !== text, sections: Object.keys(manifest.sections) };
175
+ }
176
+
177
+ /** The same types for the mobile app (<app>/sections/types.ts), imported from @acms-subir/native/sections. */
178
+ export async function writeAppTypes(appDir, themeDir) {
179
+ const manifest = ThemeManifest.parse(await readJson(join(themeDir, 'theme.json')));
180
+ const slug = themeDir.split(/[\\/]/).pop();
181
+ const text = sectionTypes(manifest, { importFrom: '@acms-subir/native/sections', header: `// Generated from src/themes/${slug}/theme.json by \`acms section new\` and \`acms check\`. Rerun after changing section fields.` });
182
+ const file = join(appDir, 'sections', 'types.ts');
183
+ const before = await readFile(file, 'utf8').catch(() => '');
184
+ if (before !== text) { await mkdir(join(appDir, 'sections'), { recursive: true }); await writeFile(file, text); }
185
+ return { types: relative(process.cwd(), file).split(sep).join('/'), typesChanged: before !== text };
186
+ }
package/cli/themes.mjs CHANGED
@@ -7,9 +7,11 @@ import { pathToFileURL } from 'node:url';
7
7
  import { zodToJsonSchema } from 'zod-to-json-schema';
8
8
  import semver from 'semver';
9
9
  import * as tar from 'tar';
10
- import { ThemeManifest, SectionDefinition, SectionPages, Slug, sectionDefinitions, BUILTIN_SECTIONS } from '../src/theme-sdk/contract.mjs';
10
+ import { ThemeManifest, SectionDefinition, SectionPages, PagePreset, Slug, sectionDefinitions, sectionIssues, BUILTIN_SECTIONS } from '../src/theme-sdk/contract.mjs';
11
+ import { writeTypes, writeAppTypes } from './sections.mjs';
12
+ import { nativeCoverage, libraryComponents } from './native-sections.mjs';
11
13
  import { CORE, project, fail, exists, readJson, atomicJson } from './common.mjs';
12
- export async function schemaData(){return {apiVersion:1,manifest:zodToJsonSchema(ThemeManifest,{$refStrategy:'none'}),sections:zodToJsonSchema(SectionDefinition,{$refStrategy:'none'}),pages:zodToJsonSchema(SectionPages,{$refStrategy:'none'})};}
14
+ export async function schemaData(){return {apiVersion:1,manifest:zodToJsonSchema(ThemeManifest,{$refStrategy:'none'}),sections:zodToJsonSchema(SectionDefinition,{$refStrategy:'none'}),presets:zodToJsonSchema(PagePreset,{$refStrategy:'none'}),pages:zodToJsonSchema(SectionPages,{$refStrategy:'none'})};}
13
15
  export async function listLocalThemes(p){
14
16
  const out=new Map();for(const [origin,base] of [['builtin',join(p.core,'src/themes')],['project',join(p.cwd,'src/themes')]]){
15
17
  for(const name of await readdir(base).catch(()=>[])){const file=join(base,name,'theme.json');if(await exists(file)){const raw=await readJson(file);out.set(name,{slug:name,name:raw.name??name,version:raw.version??'1.0.0',origin});}}
@@ -24,7 +26,7 @@ export async function filesIn(root){
24
26
  if(ent.isDirectory())await walk(file);else files.push(rel);
25
27
  }}await walk(root);return files.sort();
26
28
  }
27
- function allowedFile(file){return file==='theme.json'||file==='theme.css'||file==='README.md'||/^(templates|sections)\/[a-zA-Z0-9-]+\.astro$/.test(file)||/^assets\/[a-zA-Z0-9_./-]+\.(png|jpe?g|webp|gif|svg|avif|woff2?)$/i.test(file);}
29
+ function allowedFile(file){return file==='theme.json'||file==='theme.css'||file==='README.md'||file==='section-types.d.ts'||/^migrations\/[a-z][a-z0-9-]*\.mjs$/.test(file)||/^(templates|sections)\/[a-zA-Z0-9-]+\.astro$/.test(file)||/^assets\/[a-zA-Z0-9_./-]+\.(png|jpe?g|webp|gif|svg|avif|woff2?)$/i.test(file);}
28
30
  async function validate(dir,p){
29
31
  const manifest=ThemeManifest.parse(await readJson(join(dir,'theme.json')));
30
32
  if(!semver.validRange(manifest.core)||!semver.satisfies(p.version,manifest.core,{includePrerelease:true}))fail('INCOMPATIBLE',`Theme requires core ${manifest.core}; installed ${p.version}.`);
@@ -32,12 +34,33 @@ async function validate(dir,p){
32
34
  if(!semver.valid(manifest.version))issues.push({file:'theme.json',message:'Invalid semantic version.'});
33
35
  for(const [type,def] of Object.entries(manifest.sections)){
34
36
  if(!BUILTIN_SECTIONS[type]&&!def.component)issues.push({file:'theme.json',message:`Custom section ${type} must declare a component.`});
35
- for(const [key,f] of Object.entries(def.fields))if(f.type==='select'&&(!f.options?.length || (f.default!==undefined&&!f.options.includes(f.default))))issues.push({file:'theme.json',message:`Section ${type}.${key} needs options and a matching default.`});
37
+ for(const [key,f] of Object.entries(def.fields)){
38
+ const selects=f.type==='list'?Object.entries(f.fields).map(([k,sub])=>[`${key}.${k}`,sub]):[[key,f]];
39
+ for(const [path,sf] of selects)if(sf.type==='select'&&(!sf.options?.length || (sf.default!==undefined&&!sf.options.includes(sf.default))))issues.push({file:'theme.json',message:`Section ${type}.${path} needs options and a matching default.`});
40
+ if(f.type==='list'&&f.itemLabel&&!f.fields[f.itemLabel])issues.push({file:'theme.json',message:`Section ${type}.${key} itemLabel “${f.itemLabel}” is not one of its fields.`});
41
+ if(f.visibleIf&&!def.fields[f.visibleIf.field])issues.push({file:'theme.json',message:`Section ${type}.${key} visibleIf refers to unknown field “${f.visibleIf.field}”.`});
42
+ }
43
+ // example data and section presets must be valid content for the section
44
+ const samples=[...(def.example?[['example',def.example,undefined]]:[]),...(def.presets??[]).map((p,i)=>[`preset “${p.name}”`,p.data,p.variant])];
45
+ for(const [what,data,variant] of samples){
46
+ const unknown=Object.keys(data).filter(k=>!(k in def.fields));if(unknown.length)issues.push({file:'theme.json',message:`Section ${type} ${what}: no field(s) ${unknown.join(', ')}.`});
47
+ if(variant&&!def.variants.includes(variant))issues.push({file:'theme.json',message:`Section ${type} ${what}: unknown variant ${variant}.`});
48
+ for(const x of sectionIssues({mode:'sections',sections:[{id:'sample',type,version:def.version,variant:def.variants[0],hidden:false,data}]},{[type]:def}))issues.push({file:'theme.json',message:`Section ${type} ${what}: ${x.message}`});
49
+ }
36
50
  }
51
+ const defs=sectionDefinitions(manifest);
52
+ for(const [key,preset] of Object.entries(manifest.presets))preset.sections.forEach((ps,i)=>{
53
+ const d=defs[ps.type];const where=`preset ${key} section ${i+1} (${ps.type})`;
54
+ if(!d)return issues.push({file:'theme.json',message:`${where}: unknown section type.`});
55
+ if(ps.variant&&!d.variants.includes(ps.variant))issues.push({file:'theme.json',message:`${where}: unknown variant ${ps.variant}.`});
56
+ const unknown=Object.keys(ps.data).filter(k=>!(k in d.fields));if(unknown.length)issues.push({file:'theme.json',message:`${where}: no field(s) ${unknown.join(', ')}.`});
57
+ for(const s of sectionIssues({mode:'sections',sections:[{id:'preset',type:ps.type,version:d.version,variant:ps.variant??d.variants[0],hidden:false,data:ps.data}]},defs))issues.push({file:'theme.json',message:`${where}: ${s.message}`});
58
+ });
59
+ for(const file of files.filter(f=>f.startsWith('migrations/'))){const type=file.slice(11,-4);if(!manifest.sections[type])issues.push({file,message:`No section “${type}” to migrate.`});}
37
60
  for(const file of files){if(!allowedFile(file))issues.push({file,message:'File is outside the theme package allowlist.'});
38
61
  const stat=await lstat(join(dir,file));if(stat.size>20*1024*1024)issues.push({file,message:'File exceeds 20 MB.'});
39
62
  if(file.endsWith('.astro')||file.endsWith('.css')){const text=await readFile(join(dir,file),'utf8');if(/(?:from\s*|import\s*)['"]\.\.\/.*(?:lib|components)\//.test(text))issues.push({file,message:'Use public @acms-subir/core/theme exports instead of relative core internals.'});
40
- for(const match of text.matchAll(/(?:from\s*|import\s*)['"](\.[^'"]+)['"]/g)){const path=resolve(dirname(join(dir,file)),match[1]);if(!path.startsWith(dir+'/'))issues.push({file,message:'Relative import escapes theme directory.'});else if(!await exists(path))issues.push({file,message:`Missing import: ${match[1]}`});}
63
+ for(const match of text.matchAll(/(?:from\s*|import\s*)['"](\.[^'"]+)['"]/g)){const path=resolve(dirname(join(dir,file)),match[1]);if(!path.startsWith(dir+'/'))issues.push({file,message:'Relative import escapes theme directory.'});else if(!(await Promise.all(['','.d.ts','.ts','.mjs','.js'].map(ext=>exists(path+ext)))).some(Boolean))issues.push({file,message:`Missing import: ${match[1]}`});}
41
64
  for(const match of text.matchAll(/url\(\s*["']?((?:\.\/)?assets\/[^"')\s]+)/g)){if(match[1].startsWith('http'))continue;const path=resolve(dirname(join(dir,file)),match[1]);if(!await exists(path))issues.push({file,message:`Missing asset: ${match[1]}`});}
42
65
  }
43
66
  }
@@ -47,6 +70,20 @@ async function validate(dir,p){
47
70
  for(const [name,palette] of [['light',manifest.tokens],['dark',manifest.tokens.dark]])if(palette.bg&&palette.ink){const ratio=contrast(palette.bg,palette.ink);reports.push({check:`${name} body text contrast`,ratio,needsReview:ratio===null||ratio<4.5});}
48
71
  return {manifest,files,reports};
49
72
  }
73
+ // Responsive lint: the usual ways a section breaks on desktop or phones.
74
+ // Warnings only; looking at 390/768/1280px is still the real check.
75
+ export async function responsiveHints(dir,files){
76
+ const hints=[];
77
+ for(const file of files.filter(f=>/^(sections|templates)\/.+\.astro$|^theme\.css$/.test(f))){
78
+ const text=await readFile(join(dir,file),'utf8');const css=file.endsWith('.css')?text:(text.match(/<style[^>]*>([\s\S]*?)<\/style>/g)??[]).join('\n');
79
+ const hasMedia=/@media|@container/.test(css);
80
+ for(const m of css.matchAll(/(?<![-\w])width\s*:\s*(\d{3,})px/g))if(Number(m[1])>=400)hints.push({file,message:`Fixed width ${m[1]}px: use max-width, %, fr or min() so it fits phones and fills desktop.`});
81
+ for(const m of css.matchAll(/grid-template-columns\s*:\s*repeat\(\s*([2-9])\s*,/g))if(!hasMedia)hints.push({file,message:`${m[1]} fixed grid columns with no @media step-down: use repeat(auto-fit, minmax(min(100%, 15rem), 1fr)) or add breakpoints.`});
82
+ for(const m of css.matchAll(/grid-template-columns\s*:[^;{}]*?\b(\d{3,})px/g))if(Number(m[1])>=300&&!hasMedia)hints.push({file,message:`Fixed ${m[1]}px grid column with no breakpoint: stack it on phones.`});
83
+ for(const m of css.matchAll(/min-width\s*:\s*(\d{3,})px/g))if(Number(m[1])>=400&&!/@media[^{]*min-width\s*:\s*${m[1]}px/.test(css))hints.push({file,message:`min-width ${m[1]}px forces sideways scrolling on phones.`});
84
+ }
85
+ return [...new Map(hints.map(h=>[h.file+h.message,h])).values()];
86
+ }
50
87
  async function snapshot(dir){const entries={};for(const file of await filesIn(dir))entries[file]=createHash('sha256').update(await readFile(join(dir,file))).digest('hex');return entries;}
51
88
  export async function themeCommand(command,args,flags){
52
89
  const p=await project(),slug=args[0];
@@ -68,18 +105,25 @@ export async function themeCommand(command,args,flags){
68
105
  else {await mkdir(join(local,'templates'));await writeFile(join(local,'theme.css'),'.acms-sections { --section-accent: var(--accent); }\n');
69
106
  await writeFile(join(local,'templates/home.astro'),`---\nimport type { HomeProps } from '@acms-subir/core/theme';\ninterface Props extends HomeProps {}\nconst { page } = Astro.props;\n---\n<article class="page-body">{page?.bodyHtml ? <div set:html={page.bodyHtml} /> : <h1>Welcome</h1>}</article>\n`);}
70
107
  const raw=sourceDir?await readJson(join(local,'theme.json')):{name:slug,tokens:{accent:'#3355ff'},templates:['home.astro'],sections:{statement:{name:'Statement',version:1,component:'sections/Statement.astro',variants:['default'],fields:{heading:{label:'Heading',type:'text',default:''},text:{label:'Supporting text',type:'textarea',default:''}}}}};
71
- if(builtin){await mkdir(join(local,'sections'));await writeFile(join(local,'sections/Statement.astro'),`---\nimport type { SectionProps } from '@acms-subir/core/theme';\ninterface Props extends SectionProps {}\nconst { data } = Astro.props;\n---\n<div class="studio-statement"><h2>{data.heading}</h2><p>{data.text}</p></div>\n<style>.studio-statement { max-width: 48rem; margin-inline: auto; text-align: center; } p { white-space: pre-line; }</style>\n`);}
108
+ if(builtin){await mkdir(join(local,'sections'));await writeFile(join(local,'sections/Statement.astro'),`---\nimport type { SectionProps } from '@acms-subir/core/theme';\nimport type { StatementData } from '../section-types';\nconst { data } = Astro.props as SectionProps<StatementData>;\n---\n<div class="studio-statement"><h2>{data.heading}</h2><p>{data.text}</p></div>\n<style>.studio-statement { max-width: 48rem; margin-inline: auto; text-align: center; } p { white-space: pre-line; }</style>\n`);}
72
109
 
73
110
  await atomicJson(join(local,'theme.json'),{...raw,name:slug,slug,version:'1.0.0',apiVersion:1,core:`>=${p.version} <1.0.0`},0o644);
74
111
  await atomicJson(join(p.cwd,'.acms/baselines',slug+'.json'),await snapshot(local));
75
- return {created:local,next:[`acms theme dev ${slug}`,`acms theme check ${slug} --build`],sections:Object.keys(sectionDefinitions(ThemeManifest.parse(raw)))};
112
+ await writeTypes(local);
113
+ return {created:local,next:[`acms theme dev ${slug}`,`acms section library`,`acms theme check ${slug} --build`],sections:Object.keys(sectionDefinitions(ThemeManifest.parse(raw)))};
76
114
  }
77
115
  if(!await exists(join(dir,'theme.json')))fail('NOT_FOUND',`Theme ${slug} was not found.`);
78
116
  if(command==='inspect'){const {manifest,files}=await validate(dir,p);return {slug,origin:dir===local?'project':'builtin',manifest,files,sections:sectionDefinitions(manifest),overrides:'Use design pull --site <name> to inspect runtime overrides.'};}
117
+ if(command==='types'){if(dir!==local)fail('USAGE','Types are written for project themes in src/themes/.');if(flags['dry-run'])return {dryRun:true,file:join(local,'section-types.d.ts')};return writeTypes(local);}
79
118
  if(command==='check') {
119
+ // types first: components import ../section-types, which validate checks
120
+ const types=dir===local&&!flags['dry-run']?await writeTypes(local).catch(()=>null):null;
80
121
  const result=await validate(dir,p);let built=false;
81
122
  if(flags.build&&!flags['dry-run']){const {buildFixture}=await import('./preview.mjs');await buildFixture(p,dir,slug);built=true;}
82
- return {valid:true,slug,files:result.files,contrast:result.reports,built,manualReview:['Keyboard navigation','Narrow layouts','Custom CSS selector compatibility'],hint:built?null:'Use --build for Astro compiler checks.'};
123
+ // the mobile app: which sections render natively (info only, never a failure)
124
+ const mobile=dir===local?await nativeCoverage(result.manifest,await libraryComponents()):null;
125
+ if(mobile&&!flags['dry-run']){const file=join(mobile.app,'sections/types.ts');if((await readFile(file,'utf8').catch(()=>'')).includes(`src/themes/${slug}/theme.json`))mobile.types=await writeAppTypes(mobile.app,local);}
126
+ return {valid:true,slug,files:result.files,contrast:result.reports,responsive:await responsiveHints(dir,result.files),built,types:types&&{file:relative(p.cwd,types.file),changed:types.changed},...(mobile?{mobile}:{}),manualReview:['Every section at 390px (phone), 768px (tablet) and 1280px+ (desktop): wide frame used on desktop, no sideways scroll on phones','Keyboard navigation','Custom CSS selector compatibility'],hint:built?null:'Use --build for Astro compiler checks.'};
83
127
  }
84
128
  if(command==='dev'){if(flags['dry-run'])return {dryRun:true,action:'Create isolated preview and start Astro'};const {devFixture}=await import('./preview.mjs');return devFixture(p,dir,slug,flags);}
85
129
  if(command==='diff'){