@stonecrop/nuxt 0.8.11 → 0.8.12

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/README.md CHANGED
@@ -19,12 +19,11 @@ Stonecrop is a **schema-driven UI framework** that generates forms, tables, and
19
19
 
20
20
  ## Module Features
21
21
 
22
- - **Automatic Page Generation**: Creates routes from DocType schemas in your `/doctypes` folder
23
- - **Form & Table Components**: Pre-configured AForm and ATable components with HST integration
22
+ - **Route Generation**: Scans your `/doctypes` folder and registers routes using your own page components
24
23
  - **Plugin System**: Auto-registers Stonecrop composables and utilities
25
24
  - **Theme Support**: Import and customize Stonecrop themes
26
25
  - **TypeScript First**: Full type safety and IntelliSense support
27
- - **Zero Config**: Works out of the box with sensible defaults
26
+ - **Thin Wrapper**: The module is intentionally opinion-free page rendering, queries, and navigation stay in your application
28
27
 
29
28
  ## Quick Setup
30
29
 
@@ -99,7 +98,7 @@ Create a JSON schema in `/doctypes/task.json`:
99
98
  }
100
99
  ```
101
100
 
102
- The module automatically generates routes at `/task` for this DocType.
101
+ The module picks up this file and, if `pageComponent` is configured, registers a route at the doctype's `slug` value (or `task` if no slug is set), passing the parsed schema into `route.meta`.
103
102
 
104
103
  ### Use the Stonecrop Composable
105
104
 
@@ -184,13 +183,14 @@ export default defineNuxtConfig({
184
183
  modules: ['@stonecrop/nuxt'],
185
184
 
186
185
  stonecrop: {
187
- // Enable DocBuilder for visual schema editing
188
- docbuilder: true,
186
+ // Point to your own page component for slug-based routing (one route per doctype)
187
+ pageComponent: 'pages/StonecropPage.vue',
189
188
 
190
- // Custom router configuration
191
- router: {
192
- // Router options
193
- }
189
+ // Or supply a custom strategy for full control
190
+ // routeStrategy: (doctypes) => [...],
191
+
192
+ // Enable DocBuilder for visual schema editing
193
+ docbuilder: false,
194
194
  },
195
195
 
196
196
  // Import Stonecrop theme
@@ -201,24 +201,77 @@ export default defineNuxtConfig({
201
201
  })
202
202
  ```
203
203
 
204
- ## Module Behavior
204
+ ### Module Options
205
+
206
+ | Option | Type | Description |
207
+ |--------|------|-------------|
208
+ | `pageComponent` | `string` | Path (relative to `srcDir`) to your page component. The module registers one route per doctype at `/<slug>`, passing `schema` and `doctype` in `route.meta`. |
209
+ | `routeStrategy` | `RouteStrategyFn` | Custom function receiving all parsed doctypes; returns a `NuxtPage[]`. Takes priority over `pageComponent`. |
210
+ | `docbuilder` | `boolean` | Enable the DocBuilder feature at `/docbuilder`. Defaults to `false`. |
211
+ | `doctypesDir` | `string` | Override the doctypes directory path. Defaults to `doctypes/` inside `srcDir`. |
212
+
213
+ If neither `pageComponent` nor `routeStrategy` is configured the module logs a warning and skips doctype route registration.
205
214
 
206
- ### Automatic Page Generation
215
+ ## Route Generation
207
216
 
208
- The module scans your `/doctypes` folder and creates routes automatically:
217
+ ### Default: slug-based routing
218
+
219
+ The module scans your `doctypes/` folder and registers one route per JSON file:
209
220
 
210
221
  ```
211
222
  doctypes/
212
- ├── task.json → /task
213
- ├── user.json → /user
214
- └── project.json → /project
223
+ ├── task.json → /task (if no slug field)
224
+ ├── user.json → /user/:id (if slug is "user/:id")
225
+ └── project.json → /project
226
+ ```
227
+
228
+ Each route's `meta` contains the parsed doctype:
229
+
230
+ ```typescript
231
+ route.meta.schema // ParsedDoctype['fields']
232
+ route.meta.doctype // ParsedDoctype['data']
233
+ ```
234
+
235
+ Your page component receives these via `useRoute()`:
236
+
237
+ ```vue
238
+ <script setup lang="ts">
239
+ const route = useRoute()
240
+ const schema = route.meta.schema // array of field definitions
241
+ const doctype = route.meta.doctype // full doctype JSON object
242
+ </script>
215
243
  ```
216
244
 
217
- Each route uses the `StonecropPage.vue` layout that provides:
218
- - List view with ATable component
219
- - Detail view with AForm component
220
- - HST state management
221
- - Router integration for navigation
245
+ ### Custom route strategy
246
+
247
+ For full control multiple routes per doctype, conditional skipping, custom meta — provide a `RouteStrategyFn`:
248
+
249
+ ```typescript
250
+ import type { RouteStrategyFn } from '@stonecrop/nuxt'
251
+ import { resolve } from 'path'
252
+
253
+ const myStrategy: RouteStrategyFn = (doctypes) =>
254
+ doctypes
255
+ .filter(({ data }) => !data.parentDoctype) // skip child tables
256
+ .flatMap(({ fileName, data, fields }) => [
257
+ {
258
+ name: `${fileName}-list`,
259
+ path: `/${data.slug ?? fileName.toLowerCase()}`,
260
+ file: resolve('./pages/ListPage.vue'),
261
+ meta: { schema: fields, doctype: data, viewMode: 'list' },
262
+ },
263
+ {
264
+ name: `${fileName}-detail`,
265
+ path: `/${data.slug ?? fileName.toLowerCase()}/:id`,
266
+ file: resolve('./pages/DetailPage.vue'),
267
+ meta: { schema: fields, doctype: data, viewMode: 'detail' },
268
+ },
269
+ ])
270
+
271
+ export default defineNuxtConfig({
272
+ stonecrop: { routeStrategy: myStrategy },
273
+ })
274
+ ```
222
275
 
223
276
  ### Plugin Registration
224
277
 
package/dist/module.d.mts CHANGED
@@ -1,4 +1,49 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
+ import { NuxtPage } from '@nuxt/schema';
3
+
4
+ /**
5
+ * Parsed doctype data read from a JSON file in the doctypes directory.
6
+ * @public
7
+ */
8
+ interface ParsedDoctype {
9
+ /** Original filename without extension (e.g., 'user-table', 'User') */
10
+ fileName: string;
11
+ /** Parsed JSON content of the doctype file */
12
+ data: Record<string, unknown>;
13
+ /** Schema fields array (from `schema` or `fields` property) */
14
+ fields: Record<string, unknown>[];
15
+ }
16
+ /**
17
+ * Route strategy function signature.
18
+ *
19
+ * Receives all parsed doctypes from the `doctypes/` directory and returns
20
+ * an array of NuxtPage definitions to register. The user is responsible
21
+ * for providing the `file` property on each page (pointing to their own
22
+ * page component).
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const myStrategy: RouteStrategyFn = (doctypes) => {
27
+ * return doctypes.flatMap(({ fileName, data, fields }) => [
28
+ * {
29
+ * name: `${fileName}-list`,
30
+ * path: `/${data.slug || fileName.toLowerCase()}`,
31
+ * file: resolve('./pages/MyListPage.vue'),
32
+ * meta: { schema: fields, doctype: data, viewMode: 'list' },
33
+ * },
34
+ * {
35
+ * name: `${fileName}-detail`,
36
+ * path: `/${data.slug || fileName.toLowerCase()}/:id`,
37
+ * file: resolve('./pages/MyDetailPage.vue'),
38
+ * meta: { schema: fields, doctype: data, viewMode: 'detail' },
39
+ * },
40
+ * ])
41
+ * }
42
+ * ```
43
+ *
44
+ * @public
45
+ */
46
+ type RouteStrategyFn = (doctypes: ParsedDoctype[]) => NuxtPage[];
2
47
 
3
48
  interface ModuleOptions {
4
49
  router?: Record<string, unknown>;
@@ -6,8 +51,34 @@ interface ModuleOptions {
6
51
  docbuilder?: boolean;
7
52
  /** Path to doctypes folder (defaults to 'doctypes' in srcDir) */
8
53
  doctypesDir?: string;
54
+ /**
55
+ * Path to the page component used for default slug-based routing.
56
+ * When `routeStrategy` is not set, one route per doctype is registered
57
+ * at `/<slug>` (or `/<fileName>` if no slug) using this component.
58
+ *
59
+ * The path is resolved relative to the application's `srcDir`.
60
+ *
61
+ * @example `'pages/StonecropPage.vue'`
62
+ */
63
+ pageComponent?: string;
64
+ /**
65
+ * Custom route strategy function for full control over route generation.
66
+ * When provided, `pageComponent` is ignored and this function is called
67
+ * with all parsed doctypes to produce the NuxtPage array.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * routeStrategy: (doctypes) => doctypes.map(({ fileName, data, fields }) => ({
72
+ * name: `stonecrop-${fileName}`,
73
+ * path: `/${data.slug || fileName.toLowerCase()}`,
74
+ * file: resolve('./pages/MyPage.vue'),
75
+ * meta: { schema: fields, doctype: data },
76
+ * }))
77
+ * ```
78
+ */
79
+ routeStrategy?: RouteStrategyFn;
9
80
  }
10
81
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
11
82
 
12
83
  export { _default as default };
13
- export type { ModuleOptions };
84
+ export type { ModuleOptions, ParsedDoctype, RouteStrategyFn };
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stonecrop/nuxt",
3
3
  "configKey": "stonecrop",
4
- "version": "0.8.11",
4
+ "version": "0.8.12",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -106,63 +106,71 @@ const module$1 = defineNuxtModule({
106
106
  try {
107
107
  const dirContents = await readdir(doctypesDir);
108
108
  const schemas = dirContents.filter((file) => extname(file) === ".json");
109
- const pagesDir = resolve("runtime/pages");
110
- const stonecropPage = resolve(pagesDir, "StonecropPage.vue");
111
- extendPages(async (pages) => {
109
+ const doctypes = [];
110
+ for (const schema of schemas) {
112
111
  try {
113
- const pagePaths = pages.map((page) => page.path);
114
- if (!pagePaths.includes("/")) {
115
- pages.unshift({
116
- name: "stonecrop-home",
117
- path: "/",
118
- file: homepage
119
- });
120
- logger.log("Added Stonecrop home page at /");
121
- } else {
122
- logger.log("Skipping Stonecrop home page: root page already exists");
112
+ const schemaPath = resolve(doctypesDir, schema);
113
+ const fileContents = await readFile(schemaPath, "utf-8");
114
+ let schemaData;
115
+ try {
116
+ schemaData = JSON.parse(fileContents);
117
+ } catch (parseError) {
118
+ logger.error(`Failed to parse schema file '${schema}':`, parseError);
119
+ continue;
123
120
  }
124
- for (const schema of schemas) {
125
- try {
126
- const schemaName = schema.replace(".json", "");
127
- const schemaPath = resolve(doctypesDir, schema);
128
- const fileContents = await readFile(schemaPath, "utf-8");
129
- let schemaData;
130
- try {
131
- schemaData = JSON.parse(fileContents);
132
- } catch (parseError) {
133
- logger.error(`Failed to parse schema file '${schema}':`, parseError);
134
- continue;
135
- }
136
- const schemaFields = schemaData.schema || schemaData.fields;
137
- if (!schemaFields) {
138
- logger.warn(`Schema file '${schema}' missing 'schema' or 'fields' property, skipping`);
139
- continue;
140
- }
141
- const routePath = schemaData.slug || schemaName.toLowerCase();
142
- if (!pagePaths.includes(`/${routePath}`)) {
143
- pages.unshift({
144
- name: `stonecrop-${schemaName}`,
145
- path: `/${routePath}`,
146
- file: stonecropPage,
147
- meta: {
148
- schema: schemaFields,
149
- doctype: schemaData
150
- }
151
- });
152
- logger.log(`Added route: /${routePath} (${schemaName})`);
153
- } else {
154
- logger.warn(`Route /${routePath} already exists, skipping ${schemaName}`);
155
- }
156
- } catch (schemaError) {
157
- logger.error(`Error processing schema '${schema}':`, schemaError);
158
- }
121
+ const schemaFields = schemaData.schema || schemaData.fields;
122
+ if (!schemaFields) {
123
+ logger.warn(`Schema file '${schema}' missing 'schema' or 'fields' property, skipping`);
124
+ continue;
159
125
  }
160
- } catch (pagesError) {
161
- logger.error("Failed to setup doctype pages:", pagesError);
162
- throw new Error(
163
- `[@stonecrop/nuxt] Failed to setup pages: ${pagesError instanceof Error ? pagesError.message : String(pagesError)}`
126
+ doctypes.push({
127
+ fileName: schema.replace(".json", ""),
128
+ data: schemaData,
129
+ fields: schemaFields
130
+ });
131
+ } catch (schemaError) {
132
+ logger.error(`Error processing schema '${schema}':`, schemaError);
133
+ }
134
+ }
135
+ extendPages((pages) => {
136
+ const pagePaths = pages.map((page) => page.path);
137
+ if (!pagePaths.includes("/")) {
138
+ pages.unshift({
139
+ name: "stonecrop-home",
140
+ path: "/",
141
+ file: homepage
142
+ });
143
+ logger.log("Added Stonecrop home page at /");
144
+ } else {
145
+ logger.log("Skipping Stonecrop home page: root page already exists");
146
+ }
147
+ let generatedPages = [];
148
+ if (options.routeStrategy) {
149
+ generatedPages = options.routeStrategy(doctypes);
150
+ } else if (options.pageComponent) {
151
+ const componentPath = resolve(appDir, options.pageComponent);
152
+ generatedPages = doctypes.map(({ fileName, data, fields }) => {
153
+ const slug = data.slug || fileName.toLowerCase();
154
+ return {
155
+ name: `stonecrop-${fileName}`,
156
+ path: `/${slug}`,
157
+ file: componentPath,
158
+ meta: { schema: fields, doctype: data }
159
+ };
160
+ });
161
+ } else {
162
+ logger.warn(
163
+ "No routeStrategy or pageComponent configured \u2014 doctype routes will not be registered. Set pageComponent to a page path or provide a routeStrategy function."
164
164
  );
165
165
  }
166
+ for (const page of generatedPages) {
167
+ if (!pagePaths.includes(page.path)) {
168
+ pages.unshift(page);
169
+ logger.log(`Added route: ${page.path} (${page.name})`);
170
+ } else {
171
+ logger.warn(`Route ${page.path} already exists, skipping ${page.name}`);
172
+ }
173
+ }
166
174
  });
167
175
  } catch (doctypeError) {
168
176
  logger.error("Error setting up doctype pages:", doctypeError);
package/dist/types.d.mts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { default } from './module.mjs'
2
2
 
3
- export { type ModuleOptions } from './module.mjs'
3
+ export { type ModuleOptions, type ParsedDoctype, type RouteStrategyFn } from './module.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonecrop/nuxt",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
4
4
  "description": "Nuxt module for Stonecrop",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,14 +44,14 @@
44
44
  "jiti": "^2.4.2",
45
45
  "pathe": "^2.0.3",
46
46
  "prompts": "^2.4.2",
47
- "@stonecrop/aform": "0.8.11",
48
- "@stonecrop/graphql-middleware": "0.8.11",
49
- "@stonecrop/node-editor": "0.8.11",
50
- "@stonecrop/atable": "0.8.11",
51
- "@stonecrop/nuxt-grafserv": "0.8.11",
52
- "@stonecrop/schema": "0.8.11",
53
- "@stonecrop/casl-middleware": "0.8.11",
54
- "@stonecrop/stonecrop": "0.8.11"
47
+ "@stonecrop/aform": "0.8.12",
48
+ "@stonecrop/atable": "0.8.12",
49
+ "@stonecrop/casl-middleware": "0.8.12",
50
+ "@stonecrop/node-editor": "0.8.12",
51
+ "@stonecrop/graphql-middleware": "0.8.12",
52
+ "@stonecrop/nuxt-grafserv": "0.8.12",
53
+ "@stonecrop/stonecrop": "0.8.12",
54
+ "@stonecrop/schema": "0.8.12"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@eslint/js": "^9.39.2",
@@ -96,7 +96,7 @@
96
96
  "lint": "eslint .",
97
97
  "test": "vitest run",
98
98
  "test:ui": "vitest --ui",
99
- "test:coverage": "vitest run --coverage",
99
+ "test:coverage": "vitest run --coverage.enabled --coverage.provider=istanbul",
100
100
  "test:watch": "vitest watch",
101
101
  "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
102
102
  }
@@ -1,3 +0,0 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
- declare const _default: typeof __VLS_export;
3
- export default _default;
@@ -1,139 +0,0 @@
1
- <template>
2
- <div class="stonecrop-page">
3
- <ClientOnly>
4
- <component v-if="!loading" :is="rootComponent" v-bind="componentProps" @row-click="handleRowClick" />
5
- <div v-else class="loading-state">Loading...</div>
6
- <template #fallback>
7
- <div class="loading-state">Loading...</div>
8
- </template>
9
- </ClientOnly>
10
- </div>
11
- </template>
12
-
13
- <script setup>
14
- import { useRoute, useRouter } from "nuxt/app";
15
- import { onMounted, ref, computed, watch, markRaw } from "vue";
16
- import { getDefaultComponent } from "@stonecrop/schema";
17
- const route = useRoute();
18
- const router = useRouter();
19
- const loading = ref(true);
20
- const data = ref(null);
21
- const doctype = computed(() => route.meta.doctype);
22
- const schemaFields = computed(() => route.meta.schema);
23
- const rootComponent = computed(() => {
24
- const fields = schemaFields.value || [];
25
- const rootField = fields.find((f) => f.fieldtype === "Doctype" && f.component === "ATable");
26
- if (rootField?.component) {
27
- return rootField.component;
28
- }
29
- return "AForm";
30
- });
31
- const componentProps = computed(() => {
32
- const fields = schemaFields.value || [];
33
- const rootField = fields.find((f) => f.fieldtype === "Doctype" && f.component === "ATable");
34
- if (rootField?.component === "ATable") {
35
- return {
36
- columns: rootField.columns || buildColumnsFromFields(fields),
37
- rows: data.value || [],
38
- config: rootField.config || { view: "list" }
39
- };
40
- }
41
- return {
42
- modelValue: buildFormSchema(fields),
43
- data: data.value || {}
44
- };
45
- });
46
- function buildColumnsFromFields(fields) {
47
- const excludeTypes = ["Text", "Attach", "JSON", "Table", "Doctype", "Link"];
48
- return fields.filter((f) => !excludeTypes.includes(f.fieldtype)).slice(0, 8).map((f) => ({
49
- name: f.fieldname,
50
- label: f.label || f.fieldname,
51
- fieldtype: f.fieldtype,
52
- width: f.width || "15ch"
53
- }));
54
- }
55
- function buildFormSchema(fields) {
56
- return fields.filter((f) => f.fieldtype !== "Doctype").map((f) => ({
57
- fieldname: f.fieldname,
58
- label: f.label || f.fieldname,
59
- component: f.component || getDefaultComponent(f.fieldtype),
60
- fieldtype: f.fieldtype,
61
- required: f.required,
62
- readOnly: f.readOnly,
63
- options: f.options,
64
- default: f.default
65
- }));
66
- }
67
- const isListView = computed(() => {
68
- const fields = schemaFields.value || [];
69
- const rootField = fields.find((f) => f.fieldtype === "Doctype" && f.component === "ATable");
70
- return rootField?.component === "ATable";
71
- });
72
- async function fetchData() {
73
- loading.value = true;
74
- const doctypeName = doctype.value?.name;
75
- if (!doctypeName) {
76
- loading.value = false;
77
- return;
78
- }
79
- try {
80
- if (isListView.value) {
81
- const query = `
82
- query GetRecords($doctype: String!) {
83
- stonecropRecords(doctype: $doctype) {
84
- data
85
- count
86
- }
87
- }
88
- `;
89
- const response = await $fetch("/graphql/", {
90
- method: "POST",
91
- body: {
92
- query,
93
- variables: { doctype: doctypeName }
94
- }
95
- });
96
- data.value = response.data?.stonecropRecords?.data || [];
97
- } else {
98
- const recordId = route.params.id;
99
- if (!recordId || recordId === "new") {
100
- data.value = {};
101
- loading.value = false;
102
- return;
103
- }
104
- const query = `
105
- query GetRecord($doctype: String!, $id: String!) {
106
- stonecropRecord(doctype: $doctype, id: $id) {
107
- data
108
- }
109
- }
110
- `;
111
- const response = await $fetch("/graphql/", {
112
- method: "POST",
113
- body: {
114
- query,
115
- variables: { doctype: doctypeName, id: recordId }
116
- }
117
- });
118
- data.value = response.data?.stonecropRecord?.data || {};
119
- }
120
- } catch (e) {
121
- console.warn("[@stonecrop/nuxt] Could not fetch data:", e);
122
- data.value = isListView.value ? [] : {};
123
- }
124
- loading.value = false;
125
- }
126
- function handleRowClick(row) {
127
- const id = row.id || row.name || row.slug;
128
- if (id && isListView.value) {
129
- const basePath = route.path.replace(/\/$/, "");
130
- router.push(`${basePath}/${id}`);
131
- }
132
- }
133
- onMounted(fetchData);
134
- watch(() => route.params, fetchData, { deep: true });
135
- </script>
136
-
137
- <style scoped>
138
- .stonecrop-page{width:100%}.loading-state{color:var(--sc-gray-50);padding:2rem;text-align:center}
139
- </style>
@@ -1,3 +0,0 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
- declare const _default: typeof __VLS_export;
3
- export default _default;