@acms-subir/core 0.3.3-next.20260929113209 → 0.3.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.
- package/CHANGELOG.md +105 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +17 -13
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +205 -174
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +11 -7
- package/src/layouts/Site.astro +136 -21
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +26 -14
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +14 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- package/src/pages/admin/calendar/index.ts +0 -4
package/cli/mobile.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve, relative, dirname, isAbsolute, sep } from 'node:path';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { networkInterfaces } from 'node:os';
|
|
5
|
+
import { CORE, fail, exists, readJson } from './common.mjs';
|
|
6
|
+
|
|
7
|
+
// Mobile apps are standalone Expo projects beside the site (default mobile/),
|
|
8
|
+
// never npm workspaces of it. Their package.json carries "acms": {"app": kind}.
|
|
9
|
+
// add mobile [client|editor] [--dir] [--name] [--bundle] [--url] copy a blank starter
|
|
10
|
+
// mobile list | mobile dev [--dir] [--port] find / run apps
|
|
11
|
+
// mobile recipe list | mobile recipe add <name> [--dir] copy recipe screens
|
|
12
|
+
// Client apps (default dir mobile/) show the site to visitors; editor apps
|
|
13
|
+
// (default dir mobile-editor/) are for staff: sign in through the site, edit.
|
|
14
|
+
|
|
15
|
+
const TEMPLATES = join(CORE, 'mobile-template');
|
|
16
|
+
const RECIPES = join(TEMPLATES, 'recipes');
|
|
17
|
+
export const KINDS = ['client', 'editor'];
|
|
18
|
+
const DEFAULT_DIR = { client: 'mobile', editor: 'mobile-editor' };
|
|
19
|
+
// npm drops dotfiles from published packages, so the template ships them bare.
|
|
20
|
+
const RENAMES = { gitignore: '.gitignore', 'env.example': '.env.example' };
|
|
21
|
+
const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'my-app';
|
|
22
|
+
const titleCase = (s) => String(s).split(/[-_\s]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
23
|
+
const BUNDLE = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
|
|
24
|
+
|
|
25
|
+
async function walk(root, base = root) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const entry of (await readdir(root, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
28
|
+
const path = join(root, entry.name);
|
|
29
|
+
if (entry.isDirectory()) out.push(...(await walk(path, base)));
|
|
30
|
+
else out.push(relative(base, path).split(sep).join('/'));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
const target = (file) => file.split('/').map((p) => RENAMES[p] ?? p).join('/');
|
|
35
|
+
|
|
36
|
+
export function appDir(dir) {
|
|
37
|
+
const path = resolve(dir);
|
|
38
|
+
const rel = relative(process.cwd(), path);
|
|
39
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) fail('USAGE', '--dir must be a folder inside this site, e.g. mobile.');
|
|
40
|
+
return { path, rel: rel.split(sep).join('/') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultBundle(url, slug, suffix = 'app') {
|
|
44
|
+
const host = new URL(url).hostname;
|
|
45
|
+
const id = (s) => { const v = s.toLowerCase().replace(/[^a-z0-9_]/g, ''); return /^[a-z]/.test(v) ? v : `x${v}`; };
|
|
46
|
+
if (host === 'localhost' || /^[\d.]+$/.test(host) || host.includes(':') || !host.includes('.')) return { bundle: `com.example.${id(slug)}`, placeholder: true };
|
|
47
|
+
return { bundle: [...host.replace(/^www\./, '').split('.').reverse().map(id), suffix].join('.'), placeholder: false };
|
|
48
|
+
}
|
|
49
|
+
/** an editor app's URL scheme: its bundle id, lowercased (RFC 8252 §7.1 reverse-DNS scheme) */
|
|
50
|
+
export const editorScheme = (bundle) => bundle.toLowerCase().replace(/_/g, '-');
|
|
51
|
+
export const editorRedirect = (scheme) => `${scheme}://auth`;
|
|
52
|
+
|
|
53
|
+
const CONFIG_FILES = ['astro.config.mjs', 'astro.config.ts', 'astro.config.js', 'astro.config.mts'];
|
|
54
|
+
const quote = (v) => `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
55
|
+
/**
|
|
56
|
+
* Add the editor app's return address to acms({ mobile: { editor: { redirectUris } } })
|
|
57
|
+
* when the config has one of a few unambiguous shapes; otherwise say exactly
|
|
58
|
+
* what to paste. Never rewrites anything it doesn't fully recognize.
|
|
59
|
+
*/
|
|
60
|
+
export async function editorConfigChange(redirectUri, name) {
|
|
61
|
+
const snippet = `acms({ mobile: { editor: { name: ${quote(name)}, redirectUris: [${quote(redirectUri)}] } } })`;
|
|
62
|
+
const file = (await Promise.all(CONFIG_FILES.map(async (f) => ((await exists(f)) ? f : null)))).find(Boolean);
|
|
63
|
+
if (!file) return { file: 'astro.config.mjs', skipped: `No astro.config found. Add ${snippet} to your acms() options.`, snippet };
|
|
64
|
+
const text = await readFile(file, 'utf8');
|
|
65
|
+
if (text.includes(`'${redirectUri}'`) || text.includes(`"${redirectUri}"`)) return { file, already: `${redirectUri} is already listed.`, snippet };
|
|
66
|
+
const count = (re) => (text.match(re) ?? []).length;
|
|
67
|
+
const write = (next) => async () => { await writeFile(file, next); };
|
|
68
|
+
const uri = quote(redirectUri);
|
|
69
|
+
if (count(/redirectUris\s*:\s*\[/g) === 1) {
|
|
70
|
+
const next = text.replace(/redirectUris\s*:\s*\[/, (m) => `${m}${uri}, `);
|
|
71
|
+
return { file, change: `add ${redirectUri} to mobile.editor.redirectUris`, apply: write(next), snippet };
|
|
72
|
+
}
|
|
73
|
+
const calls = count(/\bacms\s*\(/g);
|
|
74
|
+
if (calls === 1 && !/\beditor\s*:/.test(text)) {
|
|
75
|
+
const entry = `editor: { name: ${quote(name)}, redirectUris: [${uri}] }`;
|
|
76
|
+
if (/\bacms\s*\(\s*\)/.test(text)) return { file, change: 'turn on editor apps: acms({ mobile: { editor } })', apply: write(text.replace(/\bacms\s*\(\s*\)/, `acms({ mobile: { ${entry} } })`)), snippet };
|
|
77
|
+
if (count(/\bmobile\s*:\s*\{/g) === 1) return { file, change: 'turn on editor apps (mobile.editor)', apply: write(text.replace(/\bmobile\s*:\s*\{/, (m) => `${m} ${entry},`)), snippet };
|
|
78
|
+
if (!/\bmobile\s*:/.test(text) && /\bacms\s*\(\s*\{/.test(text)) {
|
|
79
|
+
// keep the file's layout: a multi-line options object gets its own line
|
|
80
|
+
const next = text.replace(/\bacms\s*\(\s*\{([ \t]*\r?\n([ \t]*))?/, (m, nl, indent) => (nl ? `${m}mobile: { ${entry} },\n${indent}` : `${m} mobile: { ${entry} },`));
|
|
81
|
+
return { file, change: 'turn on editor apps: acms({ mobile: { editor } })', apply: write(next), snippet };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { file, skipped: `Couldn't safely edit ${file}. Add the return address yourself: ${snippet} (merge into your existing acms options), then restart or deploy the site.`, snippet };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Keep the site's TypeScript and Docker builds from picking up the app.
|
|
88
|
+
async function siteChanges(rel) {
|
|
89
|
+
const changes = [];
|
|
90
|
+
if (await exists('tsconfig.json')) {
|
|
91
|
+
const text = await readFile('tsconfig.json', 'utf8');
|
|
92
|
+
let raw;
|
|
93
|
+
try { raw = JSON.parse(text); } catch { changes.push({ file: 'tsconfig.json', skipped: `Not plain JSON; add "${rel}" to its "exclude" if it includes the whole folder.` }); }
|
|
94
|
+
if (raw) {
|
|
95
|
+
const include = Array.isArray(raw.include) ? raw.include : null;
|
|
96
|
+
const covers = !include || include.some((p) => !/^(\.\/)?(src|\.astro)(\/|$)/.test(p));
|
|
97
|
+
const excluded = (raw.exclude ?? []).some((p) => p.replace(/^\.\//, '').replace(/\/(\*\*)?$/, '') === rel);
|
|
98
|
+
if (covers && !excluded) changes.push({ file: 'tsconfig.json', change: `exclude "${rel}"`, apply: async () => { raw.exclude = [...(raw.exclude ?? []), rel]; await writeFile('tsconfig.json', JSON.stringify(raw, null, 2) + '\n'); } });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (await exists('.dockerignore')) {
|
|
102
|
+
const lines = (await readFile('.dockerignore', 'utf8')).split(/\r?\n/).map((l) => l.trim());
|
|
103
|
+
if (!lines.includes(rel) && !lines.includes(`${rel}/`)) changes.push({ file: '.dockerignore', change: `ignore "${rel}"`, apply: () => appendFile('.dockerignore', `${lines[lines.length - 1] === '' ? '' : '\n'}${rel}\n`) });
|
|
104
|
+
}
|
|
105
|
+
return changes;
|
|
106
|
+
}
|
|
107
|
+
const report = (changes) => changes.map(({ apply, ...c }) => c);
|
|
108
|
+
|
|
109
|
+
export async function addMobile(args, flags) {
|
|
110
|
+
const kind = args[0] ?? 'client';
|
|
111
|
+
if (!KINDS.includes(kind)) fail('USAGE', `Unknown app kind “${kind}”.`, [], ['acms add mobile client', 'acms add mobile editor']);
|
|
112
|
+
if (args.length > 1) fail('USAGE', 'Too many arguments.', [], ['acms add mobile [client|editor] [--dir mobile]']);
|
|
113
|
+
const editor = kind === 'editor';
|
|
114
|
+
let pkg;
|
|
115
|
+
try { pkg = await readJson('package.json'); } catch { fail('NOT_A_PROJECT', "Run from your site's folder (it has a package.json)."); }
|
|
116
|
+
const { path, rel } = appDir(flags.dir ?? DEFAULT_DIR[kind]);
|
|
117
|
+
if ((await exists(path)) && (await readdir(path).catch(() => ['?'])).length && !flags.force)
|
|
118
|
+
fail('EXISTS', `${rel}/ already exists and is not empty; nothing was changed.`, [], ['Pick another folder with --dir, or --force to write the starter over it.']);
|
|
119
|
+
const siteName = titleCase(pkg.name?.replace(/^@[^/]+\//, '') || 'My App');
|
|
120
|
+
const name = (flags.name ?? (editor ? `${siteName} Staff` : siteName)).trim();
|
|
121
|
+
if (!name || name.length > 60) fail('USAGE', '--name must be 1–60 characters.');
|
|
122
|
+
const url = (flags.url ?? 'http://localhost:4321').replace(/\/+$/, '');
|
|
123
|
+
let parsed;
|
|
124
|
+
try { parsed = new URL(url); } catch { fail('USAGE', '--url must be your site address, e.g. https://example.com.'); }
|
|
125
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) fail('USAGE', '--url must be an http(s) site address without credentials, query or fragment.');
|
|
126
|
+
const slug = slugify(name);
|
|
127
|
+
const fallback = defaultBundle(url, slug, editor ? 'staff' : 'app');
|
|
128
|
+
const bundle = flags.bundle ?? fallback.bundle;
|
|
129
|
+
if (!BUNDLE.test(bundle)) fail('USAGE', `Bundle id “${bundle}” is not valid. Use reverse-DNS letters, digits and underscores, e.g. org.riverside.${editor ? 'staff' : 'app'}.`);
|
|
130
|
+
// client apps use a short scheme for links; editor apps a reverse-DNS one (it receives sign-in codes)
|
|
131
|
+
const scheme = editor ? editorScheme(bundle) : slug;
|
|
132
|
+
const redirectUri = editor ? editorRedirect(scheme) : null;
|
|
133
|
+
const root = join(TEMPLATES, kind);
|
|
134
|
+
const files = (await walk(root)).map((f) => ({ from: f, to: target(f) }));
|
|
135
|
+
const changes = await siteChanges(rel);
|
|
136
|
+
const config = editor && !flags['no-config'] ? await editorConfigChange(redirectUri, name) : null;
|
|
137
|
+
if (config?.apply) changes.push(config);
|
|
138
|
+
const warnings = [
|
|
139
|
+
...(!flags.bundle && fallback.placeholder ? [`Bundle id ${bundle} is a placeholder; set a real one in ${rel}/app.json (or --bundle) before your first store build.${editor ? ' The scheme and the site\'s redirectUris must change with it.' : ''}`] : []),
|
|
140
|
+
...(!flags.url ? [`The app points at ${url}. Use --url https://your-site or edit ${rel}/.env; npx acms mobile dev sets a phone-reachable address for development.`] : []),
|
|
141
|
+
...(config?.skipped ? [config.skipped] : []),
|
|
142
|
+
...(editor && flags['no-config'] ? [`The site won't accept sign-ins from this app until you add it: ${config?.snippet ?? `acms({ mobile: { editor: { redirectUris: ['${redirectUri}'] } } })`}`] : []),
|
|
143
|
+
];
|
|
144
|
+
const plan = {
|
|
145
|
+
kind, dir: rel, name, slug, scheme, bundle, url, ...(editor ? { redirectUri, clientId: bundle, webRedirect: 'https://<where you host the web build>/auth (http://localhost/auth covers local development)' } : {}),
|
|
146
|
+
files: [...files.map((f) => f.to), '.env'], siteChanges: report(changes), warnings,
|
|
147
|
+
};
|
|
148
|
+
if (flags['dry-run']) return { dryRun: true, ...plan };
|
|
149
|
+
const replacements = { __ACMS_APP_NAME__: name, __ACMS_APP_SLUG__: slug, __ACMS_APP_SCHEME__: scheme, __ACMS_BUNDLE_ID__: bundle, __ACMS_SITE_URL__: url, __ACMS_APP_DIR__: rel, __ACMS_REDIRECT_URI__: redirectUri ?? '' };
|
|
150
|
+
for (const f of files) {
|
|
151
|
+
let text = await readFile(join(root, f.from), 'utf8');
|
|
152
|
+
for (const [k, v] of Object.entries(replacements)) text = text.replaceAll(k, f.from.endsWith('.json') ? JSON.stringify(v).slice(1, -1) : v);
|
|
153
|
+
await mkdir(dirname(join(path, f.to)), { recursive: true });
|
|
154
|
+
await writeFile(join(path, f.to), text);
|
|
155
|
+
}
|
|
156
|
+
await writeFile(join(path, '.env'), `# Public site address baked into the app. Development overrides go in .env.local.\nEXPO_PUBLIC_ACMS_URL=${url}\n`);
|
|
157
|
+
for (const c of changes) await c.apply();
|
|
158
|
+
const next = editor
|
|
159
|
+
? [`cd ${rel} && npm install`, ...(config?.apply ? ['Restart or deploy the site so it accepts sign-ins from the app'] : config?.already ? [] : [`Add ${redirectUri} to acms({ mobile: { editor: { redirectUris } } }) in astro.config and restart the site`]), `npx acms mobile dev --dir ${rel}`, 'npx acms mobile recipe list']
|
|
160
|
+
: [`cd ${rel} && npm install`, 'npx acms mobile dev', 'npx acms mobile recipe list'];
|
|
161
|
+
return { created: rel, ...plan, next };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- find apps ---------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export async function readApp(dir) {
|
|
167
|
+
const pkg = await readJson(join(dir, 'package.json')).catch(() => null);
|
|
168
|
+
if (!pkg?.acms?.app) return null;
|
|
169
|
+
const app = await readJson(join(dir, 'app.json')).catch(() => ({}));
|
|
170
|
+
const env = async (f) => (await readFile(join(dir, f), 'utf8').catch(() => '')).match(/^EXPO_PUBLIC_ACMS_URL=(.*)$/m)?.[1]?.trim();
|
|
171
|
+
return { dir: relative(process.cwd(), dir).split(sep).join('/') || '.', kind: pkg.acms.app, name: app.expo?.name ?? pkg.name, url: await env('.env'), devUrl: await env('.env.local'), installed: await exists(join(dir, 'node_modules')) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function listApps() {
|
|
175
|
+
const apps = [];
|
|
176
|
+
const scan = async (dir, depth) => {
|
|
177
|
+
for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
178
|
+
if (!e.isDirectory() || e.name.startsWith('.') || ['node_modules', 'dist', 'src', 'data'].includes(e.name)) continue;
|
|
179
|
+
const app = await readApp(join(dir, e.name));
|
|
180
|
+
if (app) apps.push(app); else if (depth > 1) await scan(join(dir, e.name), depth - 1);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
await scan(process.cwd(), 2);
|
|
184
|
+
return apps;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function pickApp(dir, kind) {
|
|
188
|
+
if (dir) {
|
|
189
|
+
const app = await readApp(appDir(dir).path);
|
|
190
|
+
if (!app) fail('NOT_FOUND', `${dir} is not an ACMS mobile app (no "acms" marker in its package.json).`, [], ['acms mobile list']);
|
|
191
|
+
return app;
|
|
192
|
+
}
|
|
193
|
+
const apps = await listApps();
|
|
194
|
+
if (!apps.length) fail('NOT_FOUND', 'No mobile app found in this site.', [], ['acms add mobile']);
|
|
195
|
+
// with a client and an editor app, a kind (from the recipe) picks the one
|
|
196
|
+
const matching = kind ? apps.filter((a) => a.kind === kind) : apps;
|
|
197
|
+
if (matching.length === 1) return matching[0];
|
|
198
|
+
if (apps.length > 1) fail('USAGE', 'Several mobile apps found; choose one with --dir.', apps.map((a) => `${a.dir} (${a.kind})`));
|
|
199
|
+
return apps[0];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- dev ---------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function tailscaleIp() {
|
|
205
|
+
for (const bin of ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']) {
|
|
206
|
+
const r = spawnSync(bin, ['ip', '-4'], { encoding: 'utf8', timeout: 3000 });
|
|
207
|
+
const ip = r.status === 0 ? r.stdout.split(/\s+/).find((x) => /^100\.\d+\.\d+\.\d+$/.test(x)) : undefined;
|
|
208
|
+
if (ip) return ip;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export function phoneAddress() {
|
|
212
|
+
const ts = tailscaleIp();
|
|
213
|
+
if (ts) return { ip: ts, via: 'tailscale' };
|
|
214
|
+
const candidates = Object.entries(networkInterfaces()).flatMap(([name, list]) => (list ?? []).filter((a) => (a.family === 'IPv4' || a.family === 4) && !a.internal && !a.address.startsWith('169.254.')).map((a) => ({ name, ip: a.address })));
|
|
215
|
+
const privateLan = (ip) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip);
|
|
216
|
+
const lan = candidates.find((c) => privateLan(c.ip) && /^(en|eth|wlan|wl)/.test(c.name)) ?? candidates.find((c) => privateLan(c.ip)) ?? candidates[0];
|
|
217
|
+
if (!lan) fail('NO_NETWORK', 'No network address a phone could reach was found.', [], ['Join Wi-Fi or Tailscale, then try again.']);
|
|
218
|
+
return { ip: lan.ip, via: 'lan' };
|
|
219
|
+
}
|
|
220
|
+
async function probe(url) {
|
|
221
|
+
try { const r = await fetch(url, { signal: AbortSignal.timeout(1500) }); await r.body?.cancel(); return true; } catch { return false; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function mobileDev(flags) {
|
|
225
|
+
const app = await pickApp(flags.dir);
|
|
226
|
+
const port = Number(flags.port ?? 4321);
|
|
227
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) fail('USAGE', '--port must be a port number.');
|
|
228
|
+
const { ip, via } = phoneAddress();
|
|
229
|
+
const siteUrl = `http://${ip}:${port}`;
|
|
230
|
+
const local = async () => (await probe(`http://127.0.0.1:${port}/api/v1/site`)) || probe(`http://[::1]:${port}/api/v1/site`);
|
|
231
|
+
const [reachable, onLocal] = [await probe(`${siteUrl}/api/v1/site`), await local()];
|
|
232
|
+
const site = reachable ? 'running' : onLocal ? 'localhost-only' : 'start';
|
|
233
|
+
const plan = {
|
|
234
|
+
app: app.dir, address: ip, via, siteUrl, site,
|
|
235
|
+
steps: [
|
|
236
|
+
...(site === 'start' ? [`npx astro dev --host 0.0.0.0 --port ${port} (log: .acms/mobile-dev-site.log)`] : []),
|
|
237
|
+
`write ${app.dir}/.env.local: EXPO_PUBLIC_ACMS_URL=${siteUrl}`,
|
|
238
|
+
`cd ${app.dir} && npx expo start (REACT_NATIVE_PACKAGER_HOSTNAME=${ip})`,
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
if (flags['dry-run']) return { dryRun: true, ...plan, installed: app.installed };
|
|
242
|
+
if (site === 'localhost-only') fail('UNREACHABLE', `The site is running on port ${port} but only on localhost, so a phone can't reach it.`, [], [`Restart it with: npx astro dev --host 0.0.0.0 --port ${port}`]);
|
|
243
|
+
if (!app.installed) fail('NOT_INSTALLED', `Install the app first: cd ${app.dir} && npm install`);
|
|
244
|
+
if (flags.json) fail('USAGE', 'mobile dev is interactive; use --dry-run --json to see the plan.');
|
|
245
|
+
let child;
|
|
246
|
+
if (site === 'start') {
|
|
247
|
+
await mkdir('.acms', { recursive: true });
|
|
248
|
+
const { openSync } = await import('node:fs');
|
|
249
|
+
const log = openSync('.acms/mobile-dev-site.log', 'a');
|
|
250
|
+
child = spawn('npx', ['astro', 'dev', '--host', '0.0.0.0', '--port', String(port)], { stdio: ['ignore', log, log] });
|
|
251
|
+
console.error(`Starting the site on ${siteUrl} …`);
|
|
252
|
+
const until = Date.now() + 120_000;
|
|
253
|
+
while (!(await local())) {
|
|
254
|
+
if (child.exitCode !== null || Date.now() > until) { child.kill(); fail('SITE_FAILED', 'The site dev server did not start.', [], ['See .acms/mobile-dev-site.log']); }
|
|
255
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
await writeFile(join(app.dir, '.env.local'), `# Written by acms mobile dev: the site as your phone reaches it (${via}).\nEXPO_PUBLIC_ACMS_URL=${siteUrl}\n`);
|
|
259
|
+
console.error(`Site: ${siteUrl} (${via === 'tailscale' ? 'Tailscale' : 'local network'})`);
|
|
260
|
+
const code = await new Promise((done) => {
|
|
261
|
+
const expo = spawn('npx', ['expo', 'start'], { cwd: app.dir, stdio: 'inherit', env: { ...process.env, REACT_NATIVE_PACKAGER_HOSTNAME: ip } });
|
|
262
|
+
const stop = () => expo.kill('SIGINT');
|
|
263
|
+
process.once('SIGINT', stop);
|
|
264
|
+
expo.on('exit', (c) => { process.off('SIGINT', stop); done(c ?? 0); });
|
|
265
|
+
});
|
|
266
|
+
child?.kill();
|
|
267
|
+
return { stopped: true, ...plan, exitCode: code };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- recipes -----------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
export async function recipes() {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const name of (await readdir(RECIPES).catch(() => [])).sort()) {
|
|
275
|
+
const meta = await readJson(join(RECIPES, name, 'recipe.json')).catch(() => null);
|
|
276
|
+
if (meta) out.push({ name, kind: meta.kind ?? 'client', ...meta, files: (await walk(join(RECIPES, name, 'files'))).map(target) });
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function recipeCommand(sub, args, flags) {
|
|
282
|
+
if (sub === 'list') {
|
|
283
|
+
const all = await recipes();
|
|
284
|
+
const apps = await listApps();
|
|
285
|
+
const kinds = new Set(apps.map((a) => a.kind));
|
|
286
|
+
return { recipes: kinds.size === 1 ? all.filter((r) => kinds.has(r.kind)) : all, ...(kinds.size === 1 ? { app: apps[0].dir, kind: apps[0].kind } : {}), next: 'acms mobile recipe add <name>' };
|
|
287
|
+
}
|
|
288
|
+
if (sub !== 'add') fail('USAGE', 'Unknown recipe command.', [], ['acms mobile recipe list | add <name>']);
|
|
289
|
+
const all = await recipes();
|
|
290
|
+
const recipe = all.find((r) => r.name === args[0]);
|
|
291
|
+
if (!recipe) fail(args[0] ? 'NOT_FOUND' : 'USAGE', args[0] ? `No recipe named ${args[0]}.` : 'Name a recipe.', [], [`Recipes: ${all.map((r) => r.name).join(', ')}`]);
|
|
292
|
+
const app = await pickApp(flags.dir, recipe.kind);
|
|
293
|
+
if (app.kind !== recipe.kind) fail('USAGE', `${recipe.name} is a recipe for ${recipe.kind} apps, but ${app.dir}/ is a${app.kind === 'editor' ? 'n' : ''} ${app.kind} app; nothing was changed.`, [], [`Recipes for ${app.kind} apps: ${all.filter((r) => r.kind === app.kind).map((r) => r.name).join(', ')}`]);
|
|
294
|
+
const conflicts = [];
|
|
295
|
+
for (const f of recipe.files) if (await exists(join(app.dir, f))) conflicts.push(f);
|
|
296
|
+
if (conflicts.length && !flags.force) fail('EXISTS', `The ${recipe.name} recipe would overwrite files in ${app.dir}/; nothing was changed.`, conflicts, ['Move or rename those files, or use --force.']);
|
|
297
|
+
const pkg = await readJson(join(app.dir, 'package.json'));
|
|
298
|
+
const missing = recipe.packages.filter((p) => !pkg.dependencies?.[p] && !pkg.devDependencies?.[p]);
|
|
299
|
+
const install = missing.length ? `cd ${app.dir} && npx expo install ${missing.join(' ')}` : null;
|
|
300
|
+
const result = { recipe: recipe.name, kind: recipe.kind, app: app.dir, files: recipe.files.map((f) => `${app.dir}/${f}`), routes: recipe.routes, install, next: [...(install ? [install] : []), ...recipe.next] };
|
|
301
|
+
if (flags['dry-run']) return { dryRun: true, ...result };
|
|
302
|
+
const src = join(RECIPES, recipe.name, 'files');
|
|
303
|
+
for (const f of await walk(src)) {
|
|
304
|
+
await mkdir(dirname(join(app.dir, target(f))), { recursive: true });
|
|
305
|
+
await writeFile(join(app.dir, target(f)), await readFile(join(src, f)));
|
|
306
|
+
}
|
|
307
|
+
return { added: recipe.name, ...result };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function mobileCommand(sub, args, flags) {
|
|
311
|
+
if (sub === 'list') return { apps: await listApps() };
|
|
312
|
+
if (sub === 'dev') return mobileDev(flags);
|
|
313
|
+
if (sub === 'recipe') return recipeCommand(args[0], args.slice(1), flags);
|
|
314
|
+
fail('USAGE', 'Unknown mobile command.', [], ['acms add mobile [client|editor] | acms mobile list | dev | doctor | recipe list | recipe add <name>']);
|
|
315
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { CORE, exists, fail, readJson } from './common.mjs';
|
|
4
|
+
import { listApps } from './mobile.mjs';
|
|
5
|
+
|
|
6
|
+
// Native twins of editable sections for the site's Expo app (mobile/):
|
|
7
|
+
// • library sections render from @acms-subir/native/sections by default;
|
|
8
|
+
// `section add <name> --native` copies an editable version into the app
|
|
9
|
+
// • `section new <type>` scaffolds a native stub typed from theme.json
|
|
10
|
+
// • `acms check` reports sections that only have a web version
|
|
11
|
+
// Components live in <app>/sections/<Type>.tsx and are registered in
|
|
12
|
+
// <app>/sections/index.ts, which the starter passes to <Sections registry>.
|
|
13
|
+
|
|
14
|
+
const LIBRARY = join(CORE, 'section-library');
|
|
15
|
+
export const pascal = (type) => type.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
16
|
+
const rel = (path) => relative(process.cwd(), path).split(sep).join('/');
|
|
17
|
+
|
|
18
|
+
/** Library section type → native component name (the web component's name). */
|
|
19
|
+
export async function libraryComponents() {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const name of await readdir(LIBRARY).catch(() => [])) {
|
|
22
|
+
const raw = await readJson(join(LIBRARY, name, 'section.json')).catch(() => null);
|
|
23
|
+
if (raw) out[raw.type] = { name, component: raw.definition.component.split('/').pop().replace(/\.astro$/, ''), fields: Object.keys(raw.definition.fields) };
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The app to write into: --dir, or the only app in this site. Null when there is none. */
|
|
29
|
+
export async function targetApp(flags, { required = false } = {}) {
|
|
30
|
+
const apps = await listApps();
|
|
31
|
+
if (flags.dir) {
|
|
32
|
+
const app = apps.find((a) => a.dir === flags.dir.replace(/^\.\/|\/+$/g, ''));
|
|
33
|
+
if (!app) fail('NOT_FOUND', `${flags.dir} is not an ACMS mobile app.`, [], ['acms mobile list']);
|
|
34
|
+
return { app };
|
|
35
|
+
}
|
|
36
|
+
if (!apps.length) {
|
|
37
|
+
if (required) fail('NOT_FOUND', 'No mobile app found in this site; --native writes into one.', [], ['acms add mobile']);
|
|
38
|
+
return { app: null };
|
|
39
|
+
}
|
|
40
|
+
if (apps.length > 1) {
|
|
41
|
+
if (required) fail('USAGE', 'Several mobile apps found; choose one with --dir.', apps.map((a) => a.dir));
|
|
42
|
+
return { app: null, warning: `Several mobile apps found (${apps.map((a) => a.dir).join(', ')}); pass --dir to add a native version.` };
|
|
43
|
+
}
|
|
44
|
+
return { app: apps[0] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Package-relative imports of the packaged sources → public entry points.
|
|
48
|
+
const IMPORTS = {
|
|
49
|
+
'../registry.js': '@acms-subir/native', '../hooks.js': '@acms-subir/native', '../navigation.js': '@acms-subir/native',
|
|
50
|
+
'../layout.js': '@acms-subir/native', '../format.js': '@acms-subir/native', '../components.js': '@acms-subir/native',
|
|
51
|
+
'../focus.js': '@acms-subir/native', '../links.js': '@acms-subir/native',
|
|
52
|
+
'./shared.js': '@acms-subir/native/sections', './types.js': '@acms-subir/native/sections',
|
|
53
|
+
};
|
|
54
|
+
export function toAppSource(text, from, to) {
|
|
55
|
+
let out = text.replace(/from '(\.{1,2}\/[^']+)'/g, (m, path) => {
|
|
56
|
+
if (!IMPORTS[path]) throw new Error(`Unexpected import ${path}`);
|
|
57
|
+
return `from '${IMPORTS[path]}'`;
|
|
58
|
+
});
|
|
59
|
+
if (from !== to) out = out.replace(new RegExp(`export function ${from}\\(`), `export function ${to}(`);
|
|
60
|
+
return `// Editable copy of the packaged ${from} section from @acms-subir/native.\n// It overrides the packaged one through sections/index.ts; change it freely.\n${out}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function packagedSource(app, component) {
|
|
64
|
+
const candidates = [
|
|
65
|
+
join(app.dir, 'node_modules/@acms-subir/native/src/sections', `${component}.tsx`),
|
|
66
|
+
join(CORE, '../native/src/sections', `${component}.tsx`),
|
|
67
|
+
];
|
|
68
|
+
for (const file of candidates) if (await exists(file)) return readFile(file, 'utf8');
|
|
69
|
+
fail('NOT_INSTALLED', `The packaged ${component} source was not found.`, [], [`cd ${app.dir} && npm install`]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const REGISTRY_TEMPLATE = join(CORE, 'mobile-template/client/sections/index.ts');
|
|
73
|
+
|
|
74
|
+
/** Keys registered in <app>/sections/index.ts ("team", "hero-banner:cover"). */
|
|
75
|
+
export function registryKeys(text) {
|
|
76
|
+
const start = text.indexOf('defineSectionRegistry(');
|
|
77
|
+
if (start < 0) return [];
|
|
78
|
+
const end = text.indexOf('});', start);
|
|
79
|
+
const body = text.slice(start, end < 0 ? undefined : end);
|
|
80
|
+
return [...body.matchAll(/^\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*:/gm)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Adds `import { X } from './X'` and `'type': X` to the app's registry. */
|
|
84
|
+
export async function register(app, type, component) {
|
|
85
|
+
const file = join(app.dir, 'sections/index.ts');
|
|
86
|
+
if (!(await exists(file))) { await mkdir(join(app.dir, 'sections'), { recursive: true }); await writeFile(file, await readFile(REGISTRY_TEMPLATE, 'utf8')); }
|
|
87
|
+
let text = await readFile(file, 'utf8');
|
|
88
|
+
if (registryKeys(text).includes(type)) return { registered: false, note: `sections/index.ts already registers “${type}”.` };
|
|
89
|
+
const importLine = `import { ${component} } from './${component}';`;
|
|
90
|
+
const entry = ` ${/^[a-z_$][\w$]*$/i.test(type) ? type : `'${type}'`}: ${component},`;
|
|
91
|
+
if (!text.includes(importLine)) {
|
|
92
|
+
text = text.includes('// acms:imports') ? text.replace(/^(\/\/ acms:imports)/m, `${importLine}\n$1`)
|
|
93
|
+
: text.replace(/((?:^import[^\n]*\n)+)/m, `$1${importLine}\n`);
|
|
94
|
+
}
|
|
95
|
+
if (text.includes('// acms:sections')) text = text.replace(/^(\s*\/\/ acms:sections)/m, `${entry}\n$1`);
|
|
96
|
+
else if (/defineSectionRegistry\(\{/.test(text)) text = text.replace(/defineSectionRegistry\(\{/, `defineSectionRegistry({\n${entry}`);
|
|
97
|
+
else return { registered: false, note: `Add ${entry.trim()} to your registry in sections/index.ts by hand.` };
|
|
98
|
+
await writeFile(file, text);
|
|
99
|
+
return { registered: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Plan (and with write=true, do) the editable copy of a library section. */
|
|
103
|
+
export async function libraryTwin(app, name, type, flags, write) {
|
|
104
|
+
const raw = await readJson(join(LIBRARY, name, 'section.json'));
|
|
105
|
+
const from = raw.definition.component.split('/').pop().replace(/\.astro$/, '');
|
|
106
|
+
const to = pascal(type);
|
|
107
|
+
const file = join(app.dir, 'sections', `${to}.tsx`);
|
|
108
|
+
if ((await exists(file)) && !flags.force) fail('EXISTS', `${rel(file)} already exists.`, [], ['Use --force to replace it.']);
|
|
109
|
+
if (!write) return { app: app.dir, component: rel(file) };
|
|
110
|
+
const source = toAppSource(await packagedSource(app, from), from, to);
|
|
111
|
+
await mkdir(join(app.dir, 'sections'), { recursive: true });
|
|
112
|
+
await writeFile(file, source);
|
|
113
|
+
return { app: app.dir, component: rel(file), ...(await register(app, type, to)) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A native stub for a new coded section, typed from the app's sections/types.ts. */
|
|
117
|
+
export async function stubTwin(app, type, title, themeSlug, flags) {
|
|
118
|
+
const name = pascal(type);
|
|
119
|
+
const file = join(app.dir, 'sections', `${name}.tsx`);
|
|
120
|
+
if ((await exists(file)) && !flags.force) return { app: app.dir, component: rel(file), written: false, note: `${rel(file)} already exists; left unchanged.` };
|
|
121
|
+
await mkdir(join(app.dir, 'sections'), { recursive: true });
|
|
122
|
+
await writeFile(file, `import { Text, View } from 'react-native';
|
|
123
|
+
import { useTheme, type SectionComponentProps } from '@acms-subir/native';
|
|
124
|
+
import { Grid, SectionHeader } from '@acms-subir/native/sections';
|
|
125
|
+
import type { ${name}Data } from './types';
|
|
126
|
+
|
|
127
|
+
// Native version of the “${title}” section (src/themes/${themeSlug}/sections/${name}.astro).
|
|
128
|
+
// Keep it in step with the web component: same fields, same variants. The
|
|
129
|
+
// data arrives exactly as the Astro component receives it; ./types is
|
|
130
|
+
// regenerated by \`npx acms check\` when theme.json changes.
|
|
131
|
+
export function ${name}({ data }: SectionComponentProps<${name}Data>) {
|
|
132
|
+
const t = useTheme();
|
|
133
|
+
return (
|
|
134
|
+
<View>
|
|
135
|
+
<SectionHeader heading={data.heading} style={{ marginBottom: 20 }} />
|
|
136
|
+
<Grid minItem={224} gap={14}>
|
|
137
|
+
{data.items.filter((item) => item.title).map((item, i) => (
|
|
138
|
+
<View key={i} style={[t.styles.card, { flex: 1, gap: 6 }]}>
|
|
139
|
+
<Text style={t.styles.text.heading}>{item.title}</Text>
|
|
140
|
+
{!!item.text && <Text style={t.styles.text.muted}>{item.text}</Text>}
|
|
141
|
+
</View>
|
|
142
|
+
))}
|
|
143
|
+
</Grid>
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
`);
|
|
148
|
+
return { app: app.dir, component: rel(file), written: true, ...(await register(app, type, name)) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Which project sections the app renders natively; the rest show as a web view. */
|
|
152
|
+
export async function nativeCoverage(manifest, library) {
|
|
153
|
+
const { app } = await targetApp({});
|
|
154
|
+
if (!app) return null;
|
|
155
|
+
const text = await readFile(join(app.dir, 'sections/index.ts'), 'utf8').catch(() => '');
|
|
156
|
+
const own = new Set(registryKeys(text).map((k) => k.split(':')[0]));
|
|
157
|
+
const out = { app: app.dir, native: [], library: [], webView: [], libraryFieldsNotShown: [] };
|
|
158
|
+
for (const [type, def] of Object.entries(manifest.sections ?? {})) {
|
|
159
|
+
if (own.has(type)) out.native.push(type);
|
|
160
|
+
else if (library[type]) {
|
|
161
|
+
out.library.push(type);
|
|
162
|
+
const extra = Object.keys(def.fields).filter((k) => !library[type].fields.includes(k));
|
|
163
|
+
if (extra.length) out.libraryFieldsNotShown.push({ type, fields: extra, hint: `The packaged ${library[type].component} ignores these; run acms section add ${library[type].name} --native --force to edit a copy.` });
|
|
164
|
+
} else out.webView.push(type);
|
|
165
|
+
}
|
|
166
|
+
if (out.webView.length) out.info = `${out.webView.length} section${out.webView.length > 1 ? 's have' : ' has'} no native version (${out.webView.join(', ')}); the app shows ${out.webView.length > 1 ? 'them' : 'it'} as the website renders ${out.webView.length > 1 ? 'them' : 'it'}. Write ${app.dir}/sections/<Type>.tsx and register it in ${app.dir}/sections/index.ts for a native look.`;
|
|
167
|
+
return out;
|
|
168
|
+
}
|
package/cli/preview.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, writeFile, cp, symlink, mkdtemp, readFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile, cp, symlink, mkdtemp, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, resolve, dirname } from 'node:path';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -10,6 +10,8 @@ export async function prepareFixture(p,dir,slug){
|
|
|
10
10
|
const req=createRequire(join(p.cwd,'package.json'));const astro=dirname(req.resolve('astro/package.json'));
|
|
11
11
|
await symlink(dirname(astro),join(root,'node_modules'),'dir');
|
|
12
12
|
await mkdir(join(root,'src/themes'),{recursive:true});await symlink(dir,join(root,'src/themes',slug),'dir');
|
|
13
|
+
// the project's public/ (videos, posters, fonts the theme links to by URL) serves as-is
|
|
14
|
+
const pub=join(p.cwd,'public');if(await stat(pub).then(s=>s.isDirectory(),()=>false))await symlink(pub,join(root,'public'),'dir');
|
|
13
15
|
await mkdir(join(root,'src/pages/acms-theme-preview'),{recursive:true});await mkdir(join(root,'src/actions'),{recursive:true});
|
|
14
16
|
await writeFile(join(root,'src/actions/index.ts'),"export { server } from '@acms-subir/core/actions';\n");
|
|
15
17
|
await writeFile(join(root,'package.json'),JSON.stringify({name:'acms-isolated-preview',type:'module',private:true}));
|
|
@@ -31,8 +33,9 @@ await repo.updateSettings({activeTheme:${JSON.stringify(slug)}});
|
|
|
31
33
|
return Astro.redirect('/');
|
|
32
34
|
---`);
|
|
33
35
|
await writeFile(join(root,'src/pages/acms-theme-preview/index.astro'),`---\nconst routes=['/','/preview-about','/blog','/blog?page=2','/blog/preview-post-0','/events/preview-event','/calendar','/book','/search?q=Fixture','/404'];\n---\n<html><head><title>ACMS theme fixture controls</title></head><body><h1>Isolated theme preview</h1><p>Initialize synthetic data once, then inspect real ACMS routes. Empty lists and missing covers are intentional. Check phone, tablet and desktop layouts using the controls below.</p><a href='/acms-theme-preview/setup'>Initialize fixtures</a><nav>{routes.map(route=><p><a href={route} target='fixture'>{route}</a></p>)}</nav><p><a href='/?scheme=light' target='fixture'>Light</a> · <a href='/?scheme=dark' target='fixture'>Dark</a></p><p aria-label='Preview width'><button data-width='390px'>Phone</button> <button data-width='768px'>Tablet</button> <button data-width='100%'>Desktop</button></p><iframe id='fixture' name='fixture' title='Theme preview' src='/' style='width:100%;height:70vh;border:1px solid #999;max-width:100%'></iframe><script>document.querySelectorAll('button[data-width]').forEach(button=>button.addEventListener('click',()=>{const frame=document.getElementById('fixture');if(frame)frame.style.width=button.getAttribute('data-width')||'100%';}));</script></body></html>`);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
+
const password=randomBytes(12).toString('hex');
|
|
37
|
+
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,NODE_ENV:'development',SQLITE_URL:`file:${join(root,'fixture.db')}`,SESSION_SECRET:randomBytes(32).toString('hex'),ADMIN_PASSWORD:password,ACMS_NO_UPDATE_CHECK:'1'};
|
|
38
|
+
return {root,astro:join(astro,'astro.js'),env,password};
|
|
36
39
|
}
|
|
37
40
|
export async function buildFixture(p,dir,slug){
|
|
38
41
|
const fixture=await prepareFixture(p,dir,slug);
|
|
@@ -42,7 +45,8 @@ export async function buildFixture(p,dir,slug){
|
|
|
42
45
|
}
|
|
43
46
|
export async function devFixture(p,dir,slug,flags){
|
|
44
47
|
const f=await prepareFixture(p,dir,slug);const port=Number(flags.port??4322);if(!Number.isInteger(port)||port<1024||port>65535)fail('USAGE','Port must be 1024–65535.');
|
|
45
|
-
|
|
48
|
+
// a throwaway local-only server with synthetic data, so the password is safe to show
|
|
49
|
+
console.error(`Isolated preview: http://localhost:${port}/acms-theme-preview\nAdmin: http://localhost:${port}/admin · password: ${f.password}\nWorkspace: ${f.root}\nTheme sources and public/ are linked for live reload; fixture data stays isolated.`);
|
|
46
50
|
const child=spawn(process.execPath,[f.astro,'dev','--host','127.0.0.1','--port',String(port),...(!flags.json&&process.stdout.isTTY?['--open','/acms-theme-preview']:[])],{cwd:f.root,env:f.env,stdio:['ignore','pipe','pipe']});child.stdout.pipe(process.stderr);child.stderr.pipe(process.stderr);
|
|
47
51
|
const stop=()=>child.kill('SIGTERM');process.once('SIGINT',stop);process.once('SIGTERM',stop);
|
|
48
52
|
const exit=await new Promise((done,reject)=>{child.once('exit',done);child.once('error',reject);});process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);
|