@cavulsqa/create 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +63 -0
  2. package/bin/create.mjs +142 -0
  3. package/lib/scaffold.mjs +124 -0
  4. package/package.json +60 -0
  5. package/templates/f7-app/.claude/rules/data-fetching.md +67 -0
  6. package/templates/f7-app/.claude/rules/database.md +65 -0
  7. package/templates/f7-app/.claude/rules/framework7-ui.md +79 -0
  8. package/templates/f7-app/.claude/rules/modules.md +43 -0
  9. package/templates/f7-app/.claude/rules/native.md +47 -0
  10. package/templates/f7-app/.claude/skills/f7-design/SKILL.md +67 -0
  11. package/templates/f7-app/.claude/skills/f7-design/components.md +49 -0
  12. package/templates/f7-app/.claude/skills/f7-design/icons.md +52 -0
  13. package/templates/f7-app/.claude/skills/module-architecture/SKILL.md +65 -0
  14. package/templates/f7-app/.claude/skills/module-architecture/file-templates.md +183 -0
  15. package/templates/f7-app/.claude/skills/reactive-data/SKILL.md +89 -0
  16. package/templates/f7-app/.claude/skills/reactive-data/testing.md +55 -0
  17. package/templates/f7-app/CLAUDE.md +105 -0
  18. package/templates/f7-app/auto-imports.d.ts +667 -0
  19. package/templates/f7-app/capacitor.config.ts +14 -0
  20. package/templates/f7-app/components.d.ts +59 -0
  21. package/templates/f7-app/index.html +15 -0
  22. package/templates/f7-app/package.json +51 -0
  23. package/templates/f7-app/src/App.vue +72 -0
  24. package/templates/f7-app/src/app/tabs.ts +25 -0
  25. package/templates/f7-app/src/assets/css/app.css +1 -0
  26. package/templates/f7-app/src/assets/css/icons.css +50 -0
  27. package/templates/f7-app/src/assets/fonts/material-icons-outlined.woff2 +0 -0
  28. package/templates/f7-app/src/assets/fonts/material-icons-round.woff2 +0 -0
  29. package/templates/f7-app/src/domains/sales/sales.repository.ts +458 -0
  30. package/templates/f7-app/src/env.d.ts +23 -0
  31. package/templates/f7-app/src/locales/en.json +179 -0
  32. package/templates/f7-app/src/locales/fr.json +179 -0
  33. package/templates/f7-app/src/main.ts +50 -0
  34. package/templates/f7-app/src/modules/demo/components/DemoBusLog.vue +26 -0
  35. package/templates/f7-app/src/modules/demo/components/DemoCreateOrderSheet.vue +160 -0
  36. package/templates/f7-app/src/modules/demo/components/DemoOrderList.vue +73 -0
  37. package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +52 -0
  38. package/templates/f7-app/src/modules/demo/components/DemoStatCards.vue +63 -0
  39. package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +168 -0
  40. package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +34 -0
  41. package/templates/f7-app/src/modules/demo/views/DemoView.vue +126 -0
  42. package/templates/f7-app/src/modules/demo/views/OrderDetailView.vue +131 -0
  43. package/templates/f7-app/src/modules/demo/views/OrderSearchView.vue +106 -0
  44. package/templates/f7-app/src/modules/home/composables/useHomeFeatures.ts +133 -0
  45. package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +29 -0
  46. package/templates/f7-app/src/modules/home/views/FeatureDetailView.vue +108 -0
  47. package/templates/f7-app/src/modules/home/views/HomeView.vue +53 -0
  48. package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +16 -0
  49. package/templates/f7-app/src/modules/settings/views/SettingsView.vue +126 -0
  50. package/templates/f7-app/src/plugins/capacitor/index.ts +14 -0
  51. package/templates/f7-app/src/plugins/capacitor/useAndroidBackButton.ts +68 -0
  52. package/templates/f7-app/src/plugins/capacitor/useKeyboard.ts +61 -0
  53. package/templates/f7-app/src/plugins/capacitor/useSplashScreen.ts +11 -0
  54. package/templates/f7-app/src/plugins/capacitor/useStatusBar.ts +15 -0
  55. package/templates/f7-app/src/plugins/framework7.plugin.ts +41 -0
  56. package/templates/f7-app/src/plugins/i18n.plugin.ts +10 -0
  57. package/templates/f7-app/src/plugins/seed.plugin.ts +17 -0
  58. package/templates/f7-app/src/plugins/sqlite.plugin.ts +21 -0
  59. package/templates/f7-app/src/router/global/global.routes.ts +16 -0
  60. package/templates/f7-app/src/router/index.ts +18 -0
  61. package/templates/f7-app/src/shared/components/error/404.vue +12 -0
  62. package/templates/f7-app/src/shared/components/metrics/MetricsPanel.vue +79 -0
  63. package/templates/f7-app/src/shared/composables/theme/useAppTheme.ts +68 -0
  64. package/templates/f7-app/src/shared/composables/useTabbarVisibility.ts +50 -0
  65. package/templates/f7-app/src/shared/database/database.ts +86 -0
  66. package/templates/f7-app/src/shared/database/index.ts +3 -0
  67. package/templates/f7-app/src/shared/database/migrations.ts +81 -0
  68. package/templates/f7-app/src/shared/database/queries.ts +22 -0
  69. package/templates/f7-app/src/shared/database/schema.ts +59 -0
  70. package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +152 -0
  71. package/templates/f7-app/tests/icons.test.ts +81 -0
  72. package/templates/f7-app/tests/sales.repository.test.ts +329 -0
  73. package/templates/f7-app/tsconfig.json +21 -0
  74. package/templates/f7-app/tsconfig.node.json +14 -0
  75. package/templates/f7-app/vite.config.ts +95 -0
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <F7Page>
3
+ <F7Navbar :title="t('metrics.title')">
4
+ <F7NavRight>
5
+ <F7Link panel-close icon-f7="xmark" />
6
+ </F7NavRight>
7
+ </F7Navbar>
8
+
9
+ <F7BlockTitle>{{ t("metrics.summary") }}</F7BlockTitle>
10
+ <F7List strong inset dividers class="rounded-2xl!">
11
+ <F7ListItem :title="t('metrics.totalQueries')" :after="String(totalQueries)">
12
+ <template #media><F7Icon f7="number_circle_fill" color="blue" /></template>
13
+ </F7ListItem>
14
+ <F7ListItem :title="t('metrics.avgQueryTime')" :after="`${avgQueryTime.toFixed(1)} ms`">
15
+ <template #media><F7Icon f7="timer_fill" color="orange" /></template>
16
+ </F7ListItem>
17
+ <F7ListItem :title="t('metrics.cacheHitRate')" :after="`${cacheHitRate.toFixed(0)} %`">
18
+ <template #media><F7Icon f7="bolt_circle_fill" color="green" /></template>
19
+ </F7ListItem>
20
+ <F7ListItem :title="t('metrics.activeListeners')" :after="String(activeListeners)">
21
+ <template #media><F7Icon f7="antenna_radiowaves_left_right" color="purple" /></template>
22
+ </F7ListItem>
23
+ </F7List>
24
+
25
+ <F7BlockTitle>{{ t("metrics.refetchesTitle") }}</F7BlockTitle>
26
+ <F7List v-if="refetches.length" strong inset dividers class="rounded-2xl!">
27
+ <F7ListItem
28
+ v-for="[table, count] in refetches"
29
+ :key="table"
30
+ :title="table"
31
+ :after="String(count)"
32
+ >
33
+ <template #media><F7Icon f7="arrow_2_circlepath" color="teal" /></template>
34
+ </F7ListItem>
35
+ </F7List>
36
+ <F7Block v-else strong inset class="rounded-2xl!">
37
+ <p class="m-0 text-sm">{{ t("metrics.noRefetches") }}</p>
38
+ </F7Block>
39
+
40
+ <F7BlockTitle>{{ t("metrics.slowest") }}</F7BlockTitle>
41
+ <!-- media-list, because a query key is long and the panel is narrow. -->
42
+ <F7List v-if="slowestQueries.length" media-list strong inset dividers class="rounded-2xl!">
43
+ <F7ListItem
44
+ v-for="entry in slowestQueries"
45
+ :key="entry.key"
46
+ :title="`${entry.avgTime.toFixed(1)} ms`"
47
+ :subtitle="t('metrics.calls', { count: entry.count })"
48
+ >
49
+ <template #media><F7Icon f7="speedometer" color="red" /></template>
50
+ <template #text>{{ entry.key }}</template>
51
+ </F7ListItem>
52
+ </F7List>
53
+ <F7Block v-else strong inset class="rounded-2xl!">
54
+ <p class="m-0 text-sm">{{ t("metrics.noQueries") }}</p>
55
+ </F7Block>
56
+
57
+ <F7Block>
58
+ <F7Button large outline @click="reset">{{ t("metrics.reset") }}</F7Button>
59
+ </F7Block>
60
+ </F7Page>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { useQueryMetrics } from "@/shared/database/queries";
65
+
66
+ const { t } = useI18n();
67
+
68
+ const {
69
+ totalQueries,
70
+ avgQueryTime,
71
+ cacheHitRate,
72
+ activeListeners,
73
+ refetchesByTable,
74
+ slowestQueries,
75
+ reset,
76
+ } = useQueryMetrics();
77
+
78
+ const refetches = computed(() => Object.entries(refetchesByTable.value));
79
+ </script>
@@ -0,0 +1,68 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+ import { StatusBar, Style } from "@capacitor/status-bar";
3
+ import type { ComputedRef, InjectionKey } from "vue";
4
+
5
+ export type AppTheme = "ios" | "md" | "auto";
6
+ export type AppMode = "light" | "dark";
7
+
8
+ export interface AppContext {
9
+ theme: AppTheme;
10
+ darkMode: AppMode;
11
+ setTheme: (theme: AppTheme) => void;
12
+ setDarkMode: (mode: AppMode) => Promise<void>;
13
+ }
14
+
15
+ export const AppContextKey: InjectionKey<ComputedRef<AppContext>> = Symbol("AppContext");
16
+
17
+ /**
18
+ * Called once, by the shell. Both choices persist to local storage so the app opens the way it was
19
+ * left.
20
+ *
21
+ * Switching iOS/Material reloads the page on purpose: Framework7 resolves theme-specific components
22
+ * and CSS variables when it initialises, so flipping `f7.theme` afterwards leaves a half-converted
23
+ * UI. Dark mode needs no reload - it is a class on the root plus the native status bar style.
24
+ */
25
+ export const useAppThemeProvider = (): ComputedRef<AppContext> => {
26
+ const theme = useLocalStorage<AppTheme>("app-theme", "auto");
27
+ const darkMode = useLocalStorage<AppMode>(
28
+ "app-dark-mode",
29
+ window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
30
+ );
31
+
32
+ const setTheme = (next: AppTheme) => {
33
+ theme.value = next;
34
+ window.location.reload();
35
+ };
36
+
37
+ const setDarkMode = async (next: AppMode) => {
38
+ darkMode.value = next;
39
+ f7.setDarkMode(next === "dark");
40
+
41
+ if (Capacitor.isNativePlatform()) {
42
+ await StatusBar.setStyle({ style: next === "dark" ? Style.Dark : Style.Light });
43
+ }
44
+ };
45
+
46
+ const appContext = computed<AppContext>(() => ({
47
+ theme: theme.value,
48
+ darkMode: darkMode.value,
49
+ setTheme,
50
+ setDarkMode,
51
+ }));
52
+
53
+ provide(AppContextKey, appContext);
54
+
55
+ onMounted(() => {
56
+ void setDarkMode(darkMode.value);
57
+ });
58
+
59
+ return appContext;
60
+ };
61
+
62
+ export const useAppTheme = (): ComputedRef<AppContext> => {
63
+ const context = inject(AppContextKey);
64
+ if (!context) {
65
+ throw new Error("useAppTheme needs useAppThemeProvider() called in a parent component");
66
+ }
67
+ return context;
68
+ };
@@ -0,0 +1,50 @@
1
+ import type { ComputedRef } from "vue";
2
+
3
+ // Shell-level state: the tab bar is rendered by the shell, but what hides it - a keyboard, a pushed
4
+ // page - happens elsewhere in the tree.
5
+ const keyboardOpen = ref(false);
6
+ const hiddenRequests = ref(0);
7
+
8
+ export interface TabbarVisibility {
9
+ isVisible: ComputedRef<boolean>;
10
+ setKeyboardOpen: (open: boolean) => void;
11
+ hideTabbar: () => () => void;
12
+ }
13
+
14
+ export function useTabbarVisibility(): TabbarVisibility {
15
+ return {
16
+ isVisible: computed(() => !keyboardOpen.value && hiddenRequests.value === 0),
17
+ setKeyboardOpen: (open: boolean) => {
18
+ keyboardOpen.value = open;
19
+ },
20
+ /** Hide the bar for as long as the caller needs it; the returned function restores it. */
21
+ hideTabbar: () => {
22
+ hiddenRequests.value += 1;
23
+ let released = false;
24
+ return () => {
25
+ if (released) return;
26
+ released = true;
27
+ hiddenRequests.value -= 1;
28
+ };
29
+ },
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Drop this into any pushed page and the tab bar gets out of the way for as long as that page is
35
+ * mounted. The bar belongs to the tab roots: on a detail or a search screen it is navigation to
36
+ * somewhere you are not, and it steals a row from content.
37
+ */
38
+ export function useHiddenTabbar(): void {
39
+ const { hideTabbar } = useTabbarVisibility();
40
+ let release: (() => void) | null = null;
41
+
42
+ onMounted(() => {
43
+ release = hideTabbar();
44
+ });
45
+
46
+ onUnmounted(() => {
47
+ release?.();
48
+ release = null;
49
+ });
50
+ }
@@ -0,0 +1,86 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+ import { Kysely } from "kysely";
3
+ import { Migrator } from "kysely/migration";
4
+ import { createMobileDatabase, runWrite, type MobileDatabase } from "@cavulsqa/mobile-db";
5
+ import { createChangeBus, createReactiveDb } from "@cavulsqa/reactive-db";
6
+ import { migrations } from "./migrations";
7
+ import type { Database } from "./schema";
8
+
9
+ /**
10
+ * One bus for the whole app. Writes announce the tables they touched on it and reactive queries
11
+ * listen to it, so both sides have to be the same instance - which is why it is created here and
12
+ * passed outward rather than constructed wherever it is needed.
13
+ */
14
+ export const changeBus = createChangeBus();
15
+
16
+ let database: MobileDatabase<Database> | null = null;
17
+
18
+ /**
19
+ * On a device the database is a real SQLite file behind the Capacitor plugin.
20
+ *
21
+ * In a browser it is sql.js in memory, via the dialect `@cavulsqa/mobile-db` already ships for its
22
+ * own tests. That is deliberate: the plugin's web mode needs a `jeep-sqlite` element plus a
23
+ * `sql-wasm.wasm` whose build must match the glue jeep-sqlite bundles - a pairing outside this
24
+ * template's control that breaks on any upstream bump. This path has no assets to serve and no
25
+ * version to keep in step; the cost is that browser data does not survive a reload, which for a
26
+ * template running `vp dev` is the honest trade.
27
+ */
28
+ async function openWebDatabase(): Promise<MobileDatabase<Database>> {
29
+ const { createSqlJsDialect } = await import("@cavulsqa/mobile-db/testing");
30
+ const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
31
+
32
+ await new Migrator({
33
+ db,
34
+ provider: { getMigrations: () => Promise.resolve(migrations) },
35
+ }).migrateToLatest();
36
+
37
+ return {
38
+ db,
39
+ write: (ctx, work) =>
40
+ runWrite(ctx, () => db.transaction().execute((trx) => work(trx)), {
41
+ runInTransaction: <R>(task: () => Promise<R>) => task(),
42
+ emitTableChange: (table) => changeBus.emit(table, "bulk"),
43
+ }),
44
+ getRawConnection: () => {
45
+ throw new Error("no native connection in a browser");
46
+ },
47
+ close: () => db.destroy(),
48
+ };
49
+ }
50
+
51
+ export async function openDatabase(): Promise<MobileDatabase<Database>> {
52
+ if (database) return database;
53
+
54
+ database =
55
+ Capacitor.getPlatform() === "web"
56
+ ? await openWebDatabase()
57
+ : await createMobileDatabase<Database>({
58
+ name: "app",
59
+ migrations,
60
+ emitTableChange: (table) => changeBus.emit(table, "bulk"),
61
+ /**
62
+ * One native connection cannot serve two writers. This serialises writes and transactions
63
+ * while leaving reads parallel, because the native bridge pipelines concurrent calls and
64
+ * queueing reads costs several times the latency on a screen loading with `Promise.all`.
65
+ */
66
+ serializeAccess: true,
67
+ });
68
+
69
+ return database;
70
+ }
71
+
72
+ export function getDatabase(): MobileDatabase<Database> {
73
+ if (!database) throw new Error("openDatabase() must be awaited before the database is used");
74
+ return database;
75
+ }
76
+
77
+ /** Writes go through this so every mutation announces its tables without the caller remembering. */
78
+ export const rdb = createReactiveDb<Database>({
79
+ getDb: () => getDatabase().db,
80
+ emitChange: changeBus.emit,
81
+ });
82
+
83
+ export async function closeDatabase(): Promise<void> {
84
+ await database?.close();
85
+ database = null;
86
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./database";
2
+ export * from "./queries";
3
+ export type { Database } from "./schema";
@@ -0,0 +1,81 @@
1
+ import { createTableWithDefaults, type MigrationSet } from "@cavulsqa/mobile-db";
2
+ import { sql } from "kysely";
3
+
4
+ /**
5
+ * Keys are ordered lexically and recorded once applied, so they are numbered and never renamed:
6
+ * renaming one makes it run again on a database that already has it.
7
+ *
8
+ * Foreign keys are declared for the shape, and indexed on the columns the screens actually filter
9
+ * and join by - without them every dashboard aggregate is a full scan.
10
+ */
11
+ export const migrations: MigrationSet = {
12
+ "001_sales": {
13
+ up: async (db) => {
14
+ await createTableWithDefaults(db, "customer")
15
+ .addColumn("name", "text", (col) => col.notNull())
16
+ .addColumn("city", "text", (col) => col.notNull().defaultTo(""))
17
+ .execute();
18
+
19
+ await createTableWithDefaults(db, "product")
20
+ .addColumn("name", "text", (col) => col.notNull())
21
+ .addColumn("price_cents", "integer", (col) => col.notNull().defaultTo(0))
22
+ .execute();
23
+
24
+ await createTableWithDefaults(db, "sales_order")
25
+ .addColumn("customer_id", "integer", (col) =>
26
+ col.notNull().references("customer.id").onDelete("cascade"),
27
+ )
28
+ .addColumn("reference", "text", (col) => col.notNull())
29
+ .addColumn("status", "text", (col) => col.notNull().defaultTo("draft"))
30
+ .execute();
31
+
32
+ await db.schema
33
+ .createTable("order_line")
34
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
35
+ .addColumn("order_id", "integer", (col) =>
36
+ col.notNull().references("sales_order.id").onDelete("cascade"),
37
+ )
38
+ .addColumn("product_id", "integer", (col) => col.notNull().references("product.id"))
39
+ .addColumn("quantity", "integer", (col) => col.notNull().defaultTo(1))
40
+ .addColumn("unit_price_cents", "integer", (col) => col.notNull().defaultTo(0))
41
+ .execute();
42
+
43
+ await db.schema
44
+ .createTable("tag")
45
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
46
+ .addColumn("label", "text", (col) => col.notNull().unique())
47
+ .execute();
48
+
49
+ await db.schema
50
+ .createTable("customer_tag")
51
+ .addColumn("customer_id", "integer", (col) =>
52
+ col.notNull().references("customer.id").onDelete("cascade"),
53
+ )
54
+ .addColumn("tag_id", "integer", (col) =>
55
+ col.notNull().references("tag.id").onDelete("cascade"),
56
+ )
57
+ .addPrimaryKeyConstraint("customer_tag_pk", ["customer_id", "tag_id"])
58
+ .execute();
59
+
60
+ await db.schema
61
+ .createIndex("idx_sales_order_customer")
62
+ .on("sales_order")
63
+ .column("customer_id")
64
+ .execute();
65
+ await db.schema
66
+ .createIndex("idx_order_line_order")
67
+ .on("order_line")
68
+ .column("order_id")
69
+ .execute();
70
+ await db.schema
71
+ .createIndex("idx_order_line_product")
72
+ .on("order_line")
73
+ .column("product_id")
74
+ .execute();
75
+ await db.schema.createIndex("idx_customer_city").on("customer").column("city").execute();
76
+
77
+ // SQLite ignores foreign keys unless asked, and the plugin opens with them off.
78
+ await sql`PRAGMA foreign_keys = ON`.execute(db);
79
+ },
80
+ },
81
+ };
@@ -0,0 +1,22 @@
1
+ import { createReactiveQuery, createVueQueryMetrics } from "@cavulsqa/reactive-vue";
2
+ import { usePageVisibility } from "@cavulsqa/reactive-vue/framework7";
3
+ import { changeBus } from "./database";
4
+
5
+ const metrics = createVueQueryMetrics({
6
+ exposeOnWindowAs: import.meta.env.DEV ? "__appMetrics" : undefined,
7
+ });
8
+
9
+ /**
10
+ * The composables every screen uses. They are built once, here, because the change bus and the
11
+ * metrics recorder are app-wide singletons - a query created with a different bus would never hear
12
+ * about a write.
13
+ */
14
+ export const { useReactiveQuery, useStructuralQuery, useStaticQuery } = createReactiveQuery({
15
+ onTableChange: changeBus.on,
16
+ metrics: metrics.recorder,
17
+ // Without this a tab the user cannot see keeps refetching in the background.
18
+ useVisibility: usePageVisibility,
19
+ });
20
+
21
+ export const { useQueryMetrics } = metrics;
22
+ export { uniqueQueryKey } from "@cavulsqa/reactive-vue";
@@ -0,0 +1,59 @@
1
+ import type { Generated } from "kysely";
2
+
3
+ /**
4
+ * A small relational schema rather than one flat table, so the demo can show what the reactive
5
+ * layer is actually for: a write to one table refreshing a screen that reads three others through
6
+ * joins and aggregates.
7
+ *
8
+ * customer 1-N sales_order 1-N order_line N-1 product, plus customer N-N tag through customer_tag.
9
+ */
10
+ export interface CustomerTable {
11
+ id: Generated<number>;
12
+ created_at: string;
13
+ name: string;
14
+ city: string;
15
+ }
16
+
17
+ export interface ProductTable {
18
+ id: Generated<number>;
19
+ created_at: string;
20
+ name: string;
21
+ /** Integer cents. Money in a float is a rounding bug waiting for a total. */
22
+ price_cents: number;
23
+ }
24
+
25
+ export interface SalesOrderTable {
26
+ id: Generated<number>;
27
+ created_at: string;
28
+ customer_id: number;
29
+ reference: string;
30
+ status: "draft" | "confirmed" | "delivered";
31
+ }
32
+
33
+ export interface OrderLineTable {
34
+ id: Generated<number>;
35
+ order_id: number;
36
+ product_id: number;
37
+ quantity: number;
38
+ /** Copied from the product at the time of sale, so later price changes do not rewrite history. */
39
+ unit_price_cents: number;
40
+ }
41
+
42
+ export interface TagTable {
43
+ id: Generated<number>;
44
+ label: string;
45
+ }
46
+
47
+ export interface CustomerTagTable {
48
+ customer_id: number;
49
+ tag_id: number;
50
+ }
51
+
52
+ export interface Database {
53
+ customer: CustomerTable;
54
+ product: ProductTable;
55
+ sales_order: SalesOrderTable;
56
+ order_line: OrderLineTable;
57
+ tag: TagTable;
58
+ customer_tag: CustomerTagTable;
59
+ }
@@ -0,0 +1,152 @@
1
+ import type { ComponentResolver } from "unplugin-vue-components/types";
2
+
3
+ export function Framework7VueResolver(): ComponentResolver {
4
+ const framework7Components = [
5
+ "f7-app",
6
+ "f7-view",
7
+ "f7-views",
8
+ "f7-page",
9
+ "f7-page-content",
10
+
11
+ "f7-navbar",
12
+ "f7-nav-left",
13
+ "f7-nav-right",
14
+ "f7-nav-title",
15
+ "f7-nav-title-large",
16
+ "f7-toolbar",
17
+ // No "f7-toolbar-pane": Framework7 9 ships the CSS class but framework7-vue 8 exports no
18
+ // such component, and resolving it fails at runtime instead of warning. Use the class.
19
+ "f7-subnavbar",
20
+ "f7-searchbar",
21
+
22
+ "f7-row",
23
+ "f7-col",
24
+
25
+ "f7-list",
26
+ "f7-list-group",
27
+ "f7-list-item",
28
+ "f7-list-item-row",
29
+ "f7-list-item-cell",
30
+ "f7-list-item-content",
31
+ "f7-list-button",
32
+ "f7-list-input",
33
+
34
+ "f7-checkbox",
35
+ "f7-toggle",
36
+ "f7-stepper",
37
+
38
+ "f7-button",
39
+ "f7-segmented",
40
+ "f7-fab",
41
+ "f7-fab-button",
42
+ "f7-fab-buttons",
43
+
44
+ "f7-card",
45
+ "f7-card-header",
46
+ "f7-card-content",
47
+ "f7-card-footer",
48
+
49
+ "f7-popup",
50
+ "f7-popover",
51
+ "f7-actions",
52
+ "f7-actions-group",
53
+ "f7-actions-button",
54
+ "f7-actions-label",
55
+ "f7-sheet",
56
+
57
+ "f7-login-screen",
58
+ "f7-login-screen-title",
59
+
60
+ "f7-panel",
61
+ "f7-tabs",
62
+ "f7-tab",
63
+ "f7-link",
64
+
65
+ "f7-photo-browser",
66
+
67
+ "f7-swipeout",
68
+ "f7-swipeout-actions",
69
+ "f7-swipeout-button",
70
+ "f7-swipeout-content",
71
+
72
+ "f7-accordion",
73
+ "f7-accordion-item",
74
+ "f7-accordion-toggle",
75
+ "f7-accordion-content",
76
+ "f7-contacts-list",
77
+ "f7-virtual-list",
78
+ "f7-list-index",
79
+
80
+ "f7-chip",
81
+ "f7-badge",
82
+
83
+ "f7-preloader",
84
+ "f7-progressbar",
85
+ "f7-skeleton-block",
86
+ "f7-skeleton-text",
87
+
88
+ "f7-messages",
89
+ "f7-messages-title",
90
+ "f7-message",
91
+ "f7-messagebar",
92
+ "f7-messagebar-attachment",
93
+ "f7-messagebar-attachments",
94
+
95
+ "f7-icon",
96
+
97
+ "f7-block",
98
+ "f7-block-title",
99
+ "f7-block-header",
100
+ "f7-block-footer",
101
+ ];
102
+
103
+ const toCamelCase = (str: string) => {
104
+ return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
105
+ };
106
+
107
+ return {
108
+ type: "component",
109
+ resolve: (name: string) => {
110
+ if (name.startsWith("F7") && name.length > 2) {
111
+ const kebabName = name
112
+ .replace(/^F7/, "f7-")
113
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
114
+ .toLowerCase();
115
+
116
+ if (framework7Components.includes(kebabName)) {
117
+ return {
118
+ name: toCamelCase(kebabName),
119
+ from: "framework7-vue",
120
+ };
121
+ }
122
+ }
123
+
124
+ if (name.startsWith("f7-") && framework7Components.includes(name)) {
125
+ return {
126
+ name: toCamelCase(name),
127
+ from: "framework7-vue",
128
+ };
129
+ }
130
+
131
+ if (name.match(/^f7[A-Z]/)) {
132
+ const kebabName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
133
+
134
+ if (framework7Components.includes(kebabName)) {
135
+ return {
136
+ name: toCamelCase(kebabName),
137
+ from: "framework7-vue",
138
+ };
139
+ }
140
+ }
141
+ },
142
+ };
143
+ }
144
+
145
+ export function getFramework7AutoImports() {
146
+ return {
147
+ "framework7/lite": ["utils", "getDevice", "createStore", "Dom7", "request"],
148
+ // Only what framework7-vue actually exports. `f7route` and `f7router` are passed to a
149
+ // route component as props - see FeatureDetailView - and importing them fails at runtime.
150
+ "framework7-vue": ["f7ready", "f7", "theme"],
151
+ };
152
+ }
@@ -0,0 +1,81 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { expect, test } from "vite-plus/test";
5
+
6
+ /**
7
+ * framework7-icons is a ligature font: an unknown name renders nothing at all - no warning, no
8
+ * fallback, just empty space where an icon should be. This template shipped seven wrong names
9
+ * before anyone noticed, and then a "fix" derived from the package's React component filenames
10
+ * broke four that had been correct, because those files are SVG wrappers and say nothing about the
11
+ * font's ligatures.
12
+ *
13
+ * So the font itself is the authority. Ligature names appear as plain ASCII in the ttf, which is
14
+ * enough to tell a real name from an invented one.
15
+ */
16
+ const require = createRequire(import.meta.url);
17
+
18
+ function iconFontText(): string {
19
+ const cssPath = require.resolve("framework7-icons/css/framework7-icons.css");
20
+ const ttf = join(dirname(dirname(cssPath)), "fonts", "Framework7Icons-Regular.ttf");
21
+ return readFileSync(ttf, "latin1");
22
+ }
23
+
24
+ function sourceFiles(dir: string, out: string[] = []): string[] {
25
+ for (const entry of readdirSync(dir)) {
26
+ const full = join(dir, entry);
27
+ if (statSync(full).isDirectory()) sourceFiles(full, out);
28
+ else if (/\.(vue|ts)$/.test(entry)) out.push(full);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** Matches `f7="name"`, `icon-f7="name"` and the `f7:name` form used for per-theme icons. */
34
+ function usedIconNames(): string[] {
35
+ const names = new Set<string>();
36
+ for (const file of sourceFiles(join(import.meta.dirname, "..", "src"))) {
37
+ const source = readFileSync(file, "utf8");
38
+ for (const match of source.matchAll(/(?:icon-)?f7="([a-z][a-z0-9_]*)"/g)) names.add(match[1]!);
39
+ for (const match of source.matchAll(/f7:([a-z][a-z0-9_]*)/g)) names.add(match[1]!);
40
+ }
41
+ return [...names].sort();
42
+ }
43
+
44
+ test("every Framework7 icon name used in the app exists in the font", () => {
45
+ const font = iconFontText();
46
+ const used = usedIconNames();
47
+
48
+ // A guard that never looks at anything is worse than no guard.
49
+ expect(used.length).toBeGreaterThan(10);
50
+
51
+ const missing = used.filter((name) => !font.includes(name));
52
+ expect(missing, `not ligatures in Framework7Icons-Regular.ttf: ${missing.join(", ")}`).toEqual(
53
+ [],
54
+ );
55
+ });
56
+
57
+ test("the names this template previously got wrong stay caught", () => {
58
+ const font = iconFontText();
59
+
60
+ // Underscores before digits are part of the name; dropping them was the regression.
61
+ for (const wrong of [
62
+ "arrow2_circlepath",
63
+ "square_grid2x2_fill",
64
+ "square_stack3d_down_right_fill",
65
+ "rectangle3_offgrid_fill",
66
+ // An SF Symbols name this font does not carry - the search icon was invisible because of it.
67
+ "magnifyingglass",
68
+ ]) {
69
+ expect(font.includes(wrong), `${wrong} should not resolve`).toBe(false);
70
+ }
71
+
72
+ for (const right of [
73
+ "arrow_2_circlepath",
74
+ "square_grid_2x2_fill",
75
+ "square_stack_3d_down_right_fill",
76
+ "rectangle_3_offgrid_fill",
77
+ "search",
78
+ ]) {
79
+ expect(font.includes(right), `${right} should resolve`).toBe(true);
80
+ }
81
+ });