@stonecrop/nuxt 0.13.14 → 0.15.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 (33) hide show
  1. package/README.md +12 -19
  2. package/bin/init.mjs +4 -1
  3. package/dist/module.d.mts +10 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +91 -50
  6. package/dist/runtime/app/components/DocBuilderActionsPanel.d.vue.ts +59 -0
  7. package/dist/runtime/app/components/DocBuilderActionsPanel.vue +175 -0
  8. package/dist/runtime/app/components/DocBuilderActionsPanel.vue.d.ts +59 -0
  9. package/dist/runtime/app/components/DocBuilderFieldsPanel.d.vue.ts +11 -0
  10. package/dist/runtime/app/components/DocBuilderFieldsPanel.vue +353 -0
  11. package/dist/runtime/app/components/DocBuilderFieldsPanel.vue.d.ts +11 -0
  12. package/dist/runtime/app/components/docbuilderActions.d.ts +62 -0
  13. package/dist/runtime/app/components/docbuilderActions.js +87 -0
  14. package/dist/runtime/app/composables/useClientAction.d.ts +30 -0
  15. package/dist/runtime/app/composables/useClientAction.js +51 -0
  16. package/dist/runtime/app/pages/DocBuilderDetail.vue +116 -71
  17. package/dist/runtime/app/pages/DocBuilderIndex.vue +83 -22
  18. package/dist/runtime/server/api/docbuilder/[doctype].get.d.ts +6 -1
  19. package/dist/runtime/server/api/docbuilder/[doctype].get.js +15 -5
  20. package/dist/runtime/server/api/docbuilder/doctypes.get.d.ts +5 -1
  21. package/dist/runtime/server/api/docbuilder/doctypes.get.js +5 -3
  22. package/dist/runtime/server/api/docbuilder/mergeDoctype.d.ts +11 -0
  23. package/dist/runtime/server/api/docbuilder/mergeDoctype.js +12 -0
  24. package/dist/runtime/server/api/docbuilder/save.post.d.ts +4 -1
  25. package/dist/runtime/server/api/docbuilder/save.post.js +48 -11
  26. package/dist/runtime/server/api/docbuilder/validate.post.d.ts +2 -1
  27. package/dist/runtime/server/api/docbuilder/validate.post.js +1 -1
  28. package/package.json +46 -43
  29. package/templates/Project.json +4 -10
  30. package/templates/Task.json +10 -17
  31. package/templates/resolvers.ts +49 -44
  32. package/templates/schema.graphql +23 -15
  33. package/templates/stonecrop.ts +3 -69
@@ -1,114 +1,159 @@
1
1
  <template>
2
- <div class="docbuilder-container">
3
- <div v-if="loading" class="loading">Loading...</div>
4
- <div v-else class="docbuilder-wrapper">
5
- <div class="docbuilder-header">
6
- <h1>{{ doctypeName }}</h1>
7
- <div class="docbuilder-actions">
8
- <button class="btn-secondary" :disabled="validating" @click="validateSchema">
9
- {{ validating ? "Validating..." : "Validate Schema" }}
10
- </button>
11
- <button class="btn-primary" :disabled="saving" @click="saveToDisk">
12
- {{ saving ? "Saving..." : "Save to Disk" }}
13
- </button>
2
+ <div class="docbuilder-page">
3
+ <div v-if="loading" style="padding: 2rem; text-align: center">Loading...</div>
4
+
5
+ <div v-else>
6
+ <!-- Validation Panel -->
7
+ <div v-if="validationIssues.length > 0 && !warningsDismissed" class="validation-panel">
8
+ <div v-if="errorCount > 0" class="validation-errors">
9
+ <strong>⚠️ {{ errorCount }} Error(s) — Cannot Save</strong>
10
+ <ul>
11
+ <li v-for="(issue, idx) in validationIssues.filter((i) => i.severity === 'error')" :key="`err-${idx}`">
12
+ <code v-if="issue.fieldname">{{ issue.fieldname }}:</code> {{ issue.message }}
13
+ </li>
14
+ </ul>
15
+ </div>
16
+ <div v-if="warningCount > 0" class="validation-warnings">
17
+ <strong>⚡ {{ warningCount }} Warning(s)</strong>
18
+ <button class="dismiss-button" @click="warningsDismissed = true">Dismiss</button>
14
19
  </div>
15
20
  </div>
16
21
 
17
- <!-- Validation Result -->
18
- <div v-if="validationResult" class="message-box" :class="validationResult.success ? 'success' : 'error'">
19
- <div class="message-header">
20
- <span>{{ validationResult.success ? "\u2713 Schema is valid!" : "\u2717 Validation failed" }}</span>
21
- <button class="dismiss-btn" @click="validationResult = null">×</button>
22
+ <AFieldset label="Workflow" :collapsible="true">
23
+ <div class="builder-workflow">
24
+ <StateEditor
25
+ v-if="workflowConfig && workflowConfig.states && workflowConfig.states.length > 0"
26
+ v-model="workflowConfig"
27
+ v-model:layout="layout"
28
+ node-container-class="node-editor" />
29
+ <div v-else class="empty-workflow">
30
+ <p class="empty-workflow-hint">No workflow yet. Name the first state to start building the workflow.</p>
31
+ <div class="empty-workflow-form">
32
+ <input v-model="newStateName" type="text" placeholder="e.g. Draft" @keyup.enter="seedWorkflow" />
33
+ <button class="btn-seed" type="button" :disabled="!newStateName.trim()" @click="seedWorkflow">
34
+ Add first state
35
+ </button>
36
+ </div>
37
+ </div>
22
38
  </div>
23
- <ul v-if="!validationResult.success" class="error-list">
24
- <li v-for="(error, idx) in validationResult.errors" :key="idx">
25
- <code>{{ error.path.join(".") || "root" }}</code
26
- >: {{ error.message }}
27
- </li>
28
- </ul>
29
- </div>
39
+ </AFieldset>
30
40
 
31
- <!-- Save Message -->
32
- <div v-if="saveMessage" class="message-box" :class="saveMessage.type">
33
- <span>{{ saveMessage.text }}</span>
34
- <button class="dismiss-btn" @click="saveMessage = null">×</button>
41
+ <AFieldset label="Actions" :collapsible="true">
42
+ <DocBuilderActionsPanel v-model="workflowConfig" />
43
+ </AFieldset>
44
+
45
+ <AFieldset label="Schema" :collapsible="true">
46
+ <DocBuilderFieldsPanel v-model="fields" />
47
+ </AFieldset>
48
+
49
+ <div v-if="saveMessage" class="builder-actions">
50
+ <span class="save-message" :class="saveMessage.type">{{ saveMessage.text }}</span>
35
51
  </div>
36
52
 
37
- <!-- Fields -->
38
- <section class="fields-section">
39
- <h2>Fields ({{ doctype?.schema?.length || 0 }})</h2>
40
- <div v-for="field in doctype?.schema" :key="field.fieldname" class="field-item">
41
- <div class="field-header">
42
- <span class="field-name">{{ field.fieldname }}</span>
43
- <span class="field-type">{{ field.fieldtype }}</span>
44
- </div>
45
- <div class="field-label">{{ field.label }}</div>
46
- <div v-if="field.required || field.readOnly" class="field-badges">
47
- <span v-if="field.required" class="badge">Required</span>
48
- <span v-if="field.readOnly" class="badge">Read Only</span>
49
- </div>
50
- </div>
51
- </section>
53
+ <ActionSet :elements="docbuilderActions" @action-click="handleAction" />
52
54
  </div>
53
55
  </div>
54
56
  </template>
55
57
 
56
58
  <script setup>
57
- import { ref, computed, onMounted } from "vue";
58
- import { useRoute } from "nuxt/app";
59
+ import { AFieldset } from "@stonecrop/aform";
60
+ import { StateEditor } from "@stonecrop/node-editor";
61
+ import { ActionSet } from "@stonecrop/desktop";
62
+ import { WorkflowMeta } from "@stonecrop/schema";
63
+ import { ref, watch, computed, onMounted } from "vue";
64
+ import { useRoute, useRouter } from "nuxt/app";
65
+ import DocBuilderActionsPanel from "../components/DocBuilderActionsPanel.vue";
66
+ import DocBuilderFieldsPanel from "../components/DocBuilderFieldsPanel.vue";
59
67
  const route = useRoute();
68
+ const router = useRouter();
60
69
  const doctypeName = computed(() => route.params.doctype);
61
- const doctype = ref(null);
62
70
  const loading = ref(true);
63
- const validating = ref(false);
64
71
  const saving = ref(false);
65
- const validationResult = ref(null);
72
+ const warningsDismissed = ref(false);
66
73
  const saveMessage = ref(null);
74
+ const fields = ref([]);
75
+ const workflowConfig = ref();
76
+ const layout = ref({});
77
+ const validationIssues = ref([]);
78
+ const errorCount = computed(() => validationIssues.value.filter((i) => i.severity === "error").length);
79
+ const warningCount = computed(() => validationIssues.value.filter((i) => i.severity === "warning").length);
67
80
  onMounted(async () => {
68
81
  try {
69
- const data = await $fetch(`/api/docbuilder/${doctypeName.value}`);
70
- doctype.value = data;
82
+ const data = await $fetch(`/api/_stonecrop/docbuilder/${doctypeName.value}`);
83
+ fields.value = data.fields ?? [];
84
+ if (data.workflow) {
85
+ const { layout: savedLayout, ...topology } = data.workflow;
86
+ workflowConfig.value = topology;
87
+ if (savedLayout) layout.value = savedLayout;
88
+ }
71
89
  } catch (error) {
72
90
  console.error("Error loading doctype:", error);
73
91
  } finally {
74
92
  loading.value = false;
75
93
  }
76
94
  });
77
- async function validateSchema() {
78
- validating.value = true;
79
- validationResult.value = null;
80
- try {
81
- const result = await $fetch("/api/docbuilder/validate", {
82
- method: "POST",
83
- body: { fields: doctype.value?.schema || [] }
84
- });
85
- validationResult.value = result;
86
- } catch (error) {
87
- validationResult.value = {
88
- success: false,
89
- errors: [{ path: [], message: error.message || "Validation failed" }]
90
- };
91
- } finally {
92
- validating.value = false;
95
+ const newStateName = ref("Draft");
96
+ function seedWorkflow() {
97
+ const name = newStateName.value.trim();
98
+ if (!name) return;
99
+ workflowConfig.value = { states: [name], actions: {} };
100
+ }
101
+ function revalidate() {
102
+ const issues = [];
103
+ if (workflowConfig.value) {
104
+ const result = WorkflowMeta.safeParse(workflowConfig.value);
105
+ if (!result.success) {
106
+ for (const issue of result.error.issues) {
107
+ issues.push({
108
+ severity: "error",
109
+ message: issue.message,
110
+ fieldname: issue.path.length > 0 ? issue.path.join(".") : void 0
111
+ });
112
+ }
113
+ }
93
114
  }
115
+ validationIssues.value = issues;
116
+ if (issues.length > 0) warningsDismissed.value = false;
94
117
  }
118
+ watch(workflowConfig, revalidate, { deep: true });
95
119
  async function saveToDisk() {
120
+ if (errorCount.value > 0) return;
96
121
  saving.value = true;
97
122
  saveMessage.value = null;
98
123
  try {
99
- await $fetch("/api/docbuilder/save", {
124
+ await $fetch("/api/_stonecrop/docbuilder/save", {
100
125
  method: "POST",
101
- body: { doctype: doctypeName.value, schema: doctype.value?.schema || [] }
126
+ body: {
127
+ doctype: doctypeName.value,
128
+ fields: fields.value,
129
+ // Merge the author's node arrangement back into the workflow (WorkflowMeta.layout) so it
130
+ // persists. The layout ref is kept separate in memory (topology-only workflowConfig) and
131
+ // only rejoined here at the I/O boundary; omitted when empty to avoid churn on doctypes
132
+ // that were never manually arranged.
133
+ workflow: workflowConfig.value ? { ...workflowConfig.value, ...Object.keys(layout.value).length > 0 && { layout: layout.value } } : null
134
+ }
102
135
  });
103
- saveMessage.value = { type: "success", text: "Schema saved successfully!" };
136
+ saveMessage.value = { type: "success", text: "Saved." };
104
137
  } catch (error) {
105
- saveMessage.value = { type: "error", text: error.message || "Failed to save" };
138
+ saveMessage.value = { type: "error", text: error.message || "Save failed." };
106
139
  } finally {
107
140
  saving.value = false;
108
141
  }
109
142
  }
143
+ const docbuilderActions = computed(() => [
144
+ {
145
+ type: "button",
146
+ label: saving.value ? "Saving\u2026" : "Save",
147
+ action: saveToDisk,
148
+ disabled: saving.value || errorCount.value > 0
149
+ },
150
+ { type: "button", label: "Back", action: () => void router.push("/docbuilder") }
151
+ ]);
152
+ function handleAction(_label, action) {
153
+ if (action) void action();
154
+ }
110
155
  </script>
111
156
 
112
157
  <style scoped>
113
- .docbuilder-container{min-height:100vh}.docbuilder-wrapper{margin:0 auto;max-width:1200px;padding:2rem}.docbuilder-header{margin-bottom:2rem;text-align:center}.docbuilder-header h1{font-size:2rem;font-weight:700;margin:0 0 1rem}.docbuilder-actions{display:flex;gap:1rem;justify-content:center}.btn-primary,.btn-secondary{border-radius:.5rem;cursor:pointer;font-weight:500;padding:.5rem 1rem;transition:all .2s}.btn-primary{background:#3b82f6;border:none;color:#fff}.btn-primary:hover:not(:disabled){background:#2563eb}.btn-secondary{background:#e5e7eb;border:1px solid #d1d5db;color:#374151}.btn-secondary:hover:not(:disabled){background:#d1d5db}.btn-primary:disabled,.btn-secondary:disabled{cursor:not-allowed;opacity:.6}.message-box{border-radius:.5rem;margin-bottom:1rem;padding:1rem}.message-box.success{background:#d1fae5;border:1px solid #10b981;color:#065f46}.message-box.error{background:#fee2e2;border:1px solid #ef4444;color:#991b1b}.message-header{align-items:center;display:flex;justify-content:space-between}.dismiss-btn{background:none;border:none;cursor:pointer;font-size:1.5rem;opacity:.6}.dismiss-btn:hover{opacity:1}.error-list{margin:.5rem 0 0;padding-left:1.5rem}.error-list code{background:rgba(0,0,0,.1);border-radius:.25rem;padding:.125rem .25rem}.loading{color:#6b7280;padding:4rem;text-align:center}.fields-section{background:#fff;border-radius:.75rem;box-shadow:0 4px 12px rgba(0,0,0,.1);padding:1.5rem}.fields-section h2{font-size:1.25rem;margin:0 0 1rem}.field-item{background:#f9fafb;border:1px solid #e5e7eb;border-radius:.5rem;margin-bottom:.75rem;padding:1rem}.field-header{align-items:center;display:flex;justify-content:space-between;margin-bottom:.25rem}.field-name{font-family:monospace;font-weight:600}.field-type{background:#dbeafe;border-radius:.25rem;color:#1e40af;font-size:.75rem;padding:.25rem .5rem}.field-label{color:#6b7280;font-size:.875rem}.field-badges{display:flex;gap:.5rem;margin-top:.5rem}.badge{background:#e5e7eb;border-radius:.25rem;font-size:.75rem;padding:.125rem .5rem}
158
+ .docbuilder-page{background:var(--sc-form-background,#fff);box-sizing:border-box;min-height:100vh;padding:2rem}.builder-workflow{min-height:8rem;padding:.5em 1em}:deep(.node-editor){height:40vh;overflow:hidden;width:100%}.empty-workflow{padding:1rem 0}.empty-workflow-hint{color:#9ca3af;font-style:italic;margin:0 0 .75rem}.empty-workflow-form{align-items:center;display:flex;gap:.5rem}.empty-workflow-form input{border:1px solid var(--sc-gray-20,#d1d5db);border-radius:4px;font-family:inherit;font-size:.875rem;padding:.4em .6em}.btn-seed{background:var(--sc-blue-40,#3b82f6);border:none;border-radius:.4rem;color:#fff;cursor:pointer;font-size:.875rem;font-weight:500;padding:.45em 1em}.btn-seed:disabled{cursor:not-allowed;opacity:.5}.validation-panel{margin-bottom:1rem}.validation-errors{background:#fee2e2;border:1px solid #ef4444;border-radius:6px;color:#991b1b;margin-bottom:.5rem;padding:1rem}.validation-warnings{background:#fef9c3;border:1px solid #eab308;border-radius:6px;color:#713f12;padding:1rem}.dismiss-button{background:none;border:1px solid;border-radius:3px;cursor:pointer;font-size:.75rem;margin-left:1rem;padding:.125em .5em}.builder-actions{align-items:center;display:flex;gap:1rem;padding:1rem}.btn-primary{background:var(--sc-blue-40,#3b82f6);border:none;border-radius:.5rem;color:#fff;cursor:pointer;font-weight:500;padding:.5rem 1.5rem}.btn-primary:disabled{cursor:not-allowed;opacity:.6}.save-message{font-size:.875rem}.save-message.success{color:#065f46}.save-message.error{color:#991b1b}
114
159
  </style>
@@ -1,44 +1,105 @@
1
1
  <template>
2
- <div class="docbuilder-index-container">
3
- <div class="docbuilder-header">
4
- <h1>DocType Builder</h1>
5
- <p class="subtitle">Select a DocType to view and edit its schema</p>
2
+ <div class="docbuilder-index">
3
+ <div class="docbuilder-index-inner">
4
+ <div class="docbuilder-header">
5
+ <h1>DocType Builder</h1>
6
+ <p class="subtitle">Select a DocType to view and edit its schema</p>
7
+ </div>
8
+ <div class="docbuilder-create">
9
+ <input
10
+ v-model="newName"
11
+ type="text"
12
+ placeholder="New doctype name (e.g. Invoice)"
13
+ :disabled="creating"
14
+ @keyup.enter="createDoctype" />
15
+ <button type="button" class="btn-create" :disabled="creating || !newName.trim()" @click="createDoctype">
16
+ {{ creating ? "Creating\u2026" : "+ New DocType" }}
17
+ </button>
18
+ </div>
19
+ <p v-if="createError" class="create-error">{{ createError }}</p>
20
+ <ClientOnly>
21
+ <div v-if="loading" class="loading">Loading doctypes...</div>
22
+ <p v-else-if="!doctypes.length" class="empty">No doctypes yet — create one above.</p>
23
+ <ATable v-else :columns="columns" :rows="doctypes" :config="config" @row:click="handleRowClick" />
24
+ </ClientOnly>
6
25
  </div>
7
- <ClientOnly>
8
- <div v-if="loading" class="loading">Loading doctypes...</div>
9
- <ATable v-else :columns="columns" :rows="doctypes" :config="config" @row:click="handleRowClick" />
10
- </ClientOnly>
26
+
27
+ <ActionSet :elements="indexActions" @action-click="handleAction" />
11
28
  </div>
12
29
  </template>
13
30
 
14
31
  <script setup>
15
- import { ref, onMounted } from "vue";
32
+ import { ActionSet } from "@stonecrop/desktop";
33
+ import { ref, computed, onMounted } from "vue";
16
34
  import { useRouter } from "nuxt/app";
17
35
  const router = useRouter();
18
36
  const doctypes = ref([]);
19
37
  const loading = ref(true);
20
- const columns = [
21
- { label: "Name", name: "name", fieldtype: "Data", width: "20ch" },
22
- { label: "Fields", name: "fieldCount", fieldtype: "Int", width: "10ch" }
23
- ];
24
- const config = {
25
- view: "uncounted"
26
- };
27
- onMounted(async () => {
38
+ async function loadDoctypes() {
28
39
  try {
29
- const data = await $fetch("/api/docbuilder/doctypes");
30
- doctypes.value = data;
40
+ doctypes.value = await $fetch("/api/_stonecrop/docbuilder/doctypes");
31
41
  } catch (error) {
32
42
  console.error("Error loading doctypes:", error);
33
43
  } finally {
34
44
  loading.value = false;
35
45
  }
36
- });
46
+ }
47
+ onMounted(loadDoctypes);
48
+ const columns = [
49
+ { label: "Name", name: "name", component: "ATextInput", width: "20ch" },
50
+ { label: "Fields", name: "fieldCount", component: "ANumericInput", width: "10ch" }
51
+ ];
52
+ const config = {
53
+ view: "uncounted",
54
+ clickable: true
55
+ };
37
56
  function handleRowClick({ row }) {
38
- router.push(`/docbuilder/${row.slug}`);
57
+ void router.push(`/docbuilder/${row.slug}`);
58
+ }
59
+ const newName = ref("");
60
+ const createError = ref("");
61
+ const creating = ref(false);
62
+ const DOCTYPE_NAME = /^[A-Z][\w-]*$/i;
63
+ async function createDoctype() {
64
+ const name = newName.value.trim();
65
+ if (!name) return;
66
+ if (!DOCTYPE_NAME.test(name)) {
67
+ createError.value = "Start with a letter; use only letters, numbers, hyphens, or underscores.";
68
+ return;
69
+ }
70
+ const slug = name.toLowerCase();
71
+ if (doctypes.value.some((d) => String(d.slug).toLowerCase() === slug)) {
72
+ createError.value = `A doctype "${name}" already exists.`;
73
+ return;
74
+ }
75
+ createError.value = "";
76
+ creating.value = true;
77
+ try {
78
+ await $fetch("/api/_stonecrop/docbuilder/save", {
79
+ method: "POST",
80
+ body: { doctype: name, fields: [], create: true }
81
+ });
82
+ void router.push(`/docbuilder/${slug}`);
83
+ } catch (error) {
84
+ console.error("Error creating doctype:", error);
85
+ const err = error;
86
+ if (err.statusCode === 409) {
87
+ createError.value = err.data?.message ?? `A doctype "${name}" already exists.`;
88
+ await loadDoctypes();
89
+ } else {
90
+ createError.value = "Failed to create doctype.";
91
+ }
92
+ creating.value = false;
93
+ }
94
+ }
95
+ const indexActions = computed(() => [
96
+ { type: "button", label: "Home", action: () => void router.push("/") }
97
+ ]);
98
+ function handleAction(_label, action) {
99
+ if (action) void action();
39
100
  }
40
101
  </script>
41
102
 
42
103
  <style scoped>
43
- .docbuilder-index-container{margin:0 auto;max-width:1200px;padding:2rem}.docbuilder-header{padding:2rem 0 3rem;text-align:center}.docbuilder-header h1{font-size:2.5rem;font-weight:700;margin:0 0 1rem}.subtitle{color:#6b7280;font-size:1.125rem;margin:0}.loading{color:#6b7280;padding:2rem;text-align:center}
104
+ .docbuilder-index{background:var(--sc-form-background,#fff);box-sizing:border-box;min-height:100vh}.docbuilder-index-inner{margin:0 auto;max-width:1200px;padding:2rem}.docbuilder-header{padding:2rem 0 3rem;text-align:center}.docbuilder-header h1{font-size:2.5rem;font-weight:700;margin:0 0 1rem}.subtitle{color:#6b7280;font-size:1.125rem;margin:0}.empty,.loading{color:#6b7280;padding:2rem;text-align:center}.docbuilder-create{display:flex;gap:.5rem;margin-bottom:1rem}.docbuilder-create input{border:1px solid var(--sc-gray-20,#d1d5db);border-radius:4px;flex:1;font:inherit;padding:.5em .75em}.btn-create{background:var(--sc-blue-40,#3b82f6);border:none;border-radius:.4rem;color:#fff;cursor:pointer;font-weight:500;padding:.5em 1.25em;white-space:nowrap}.btn-create:disabled{cursor:not-allowed;opacity:.5}.create-error{color:#b91c1c;font-size:.875rem;margin:-.5rem 0 1rem}
44
105
  </style>
@@ -1,2 +1,7 @@
1
- declare const _default: any;
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ name: string;
3
+ slug: string;
4
+ fields: any;
5
+ workflow: any;
6
+ }>>;
2
7
  export default _default;
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, readdir } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
- import { createError, defineEventHandler, getRouterParam, useRuntimeConfig } from "#imports";
4
+ import { createError, defineEventHandler, getRouterParam } from "h3";
5
+ import { useRuntimeConfig } from "#imports";
5
6
  export default defineEventHandler(async (event) => {
6
7
  const doctype = getRouterParam(event, "doctype");
7
8
  if (!doctype) {
@@ -12,13 +13,21 @@ export default defineEventHandler(async (event) => {
12
13
  }
13
14
  const config = useRuntimeConfig();
14
15
  const doctypesDir = config.stonecrop?.doctypesDir || resolve(process.cwd(), "doctypes");
15
- const filePath = resolve(doctypesDir, `${doctype}.json`);
16
- if (!filePath.startsWith(doctypesDir)) {
16
+ const exactPath = resolve(doctypesDir, `${doctype}.json`);
17
+ if (!exactPath.startsWith(doctypesDir + "/")) {
17
18
  throw createError({
18
19
  status: 400,
19
20
  message: "Invalid doctype name"
20
21
  });
21
22
  }
23
+ let filePath = exactPath;
24
+ if (!existsSync(filePath)) {
25
+ const files = await readdir(doctypesDir).catch(() => []);
26
+ const match = files.find((f) => f.toLowerCase() === `${doctype.toLowerCase()}.json`);
27
+ if (match) {
28
+ filePath = resolve(doctypesDir, match);
29
+ }
30
+ }
22
31
  if (!existsSync(filePath)) {
23
32
  throw createError({
24
33
  status: 404,
@@ -31,7 +40,8 @@ export default defineEventHandler(async (event) => {
31
40
  return {
32
41
  name: doctype.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
33
42
  slug: doctype,
34
- schema: data.schema ?? data.fields ?? []
43
+ fields: data.fields ?? [],
44
+ workflow: data.workflow ?? null
35
45
  };
36
46
  } catch (error) {
37
47
  throw createError({
@@ -1,2 +1,6 @@
1
- declare const _default: any;
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ name: string;
3
+ slug: string;
4
+ fieldCount: any;
5
+ }[]>>;
2
6
  export default _default;
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import { extname, resolve } from "node:path";
4
- import { defineEventHandler, useRuntimeConfig } from "#imports";
4
+ import { defineEventHandler } from "h3";
5
+ import { useRuntimeConfig } from "#imports";
5
6
  export default defineEventHandler(async (_event) => {
6
7
  const config = useRuntimeConfig();
7
8
  const doctypesDir = config.stonecrop?.doctypesDir || resolve(process.cwd(), "doctypes");
@@ -17,10 +18,11 @@ export default defineEventHandler(async (_event) => {
17
18
  const content = await readFile(filePath, "utf-8");
18
19
  const data = JSON.parse(content);
19
20
  const name = file.replace(".json", "");
21
+ const slug = name.toLowerCase();
20
22
  return {
21
23
  name: name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
22
- slug: name,
23
- fieldCount: (data.schema ?? data.fields)?.length || 0
24
+ slug,
25
+ fieldCount: data.fields?.length || 0
24
26
  };
25
27
  })
26
28
  );
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Reorder `next`'s keys to follow `reference`'s key order, appending any keys not present in
3
+ * `reference` (in their original `next` order) and dropping reference-only keys.
4
+ *
5
+ * The docbuilder rebuilds the workflow.actions map on every graph edit (node_editor emits transition
6
+ * actions first, then stateless ones), which reshuffles a doctype's action order — e.g. a leading
7
+ * `save` command jumps below the transitions — producing a spurious diff on the first save even when
8
+ * only a node was dragged. Users can't reorder actions in the builder, so the on-disk order is always
9
+ * the intended one: re-imposing it keeps saves byte-stable while still honouring adds/removes.
10
+ */
11
+ export declare function orderKeysByReference<T>(next: Record<string, T> | undefined, reference: Record<string, T> | undefined): Record<string, T> | undefined;
@@ -0,0 +1,12 @@
1
+ export function orderKeysByReference(next, reference) {
2
+ if (!next || !reference) return next;
3
+ const result = {};
4
+ for (const key of Object.keys(reference)) {
5
+ const value = next[key];
6
+ if (value !== void 0) result[key] = value;
7
+ }
8
+ for (const [key, value] of Object.entries(next)) {
9
+ if (!Object.prototype.hasOwnProperty.call(result, key)) result[key] = value;
10
+ }
11
+ return result;
12
+ }
@@ -1,2 +1,5 @@
1
- declare const _default: any;
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ success: boolean;
3
+ path: string;
4
+ }>>;
2
5
  export default _default;
@@ -1,6 +1,9 @@
1
- import { writeFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
3
- import { createError, defineEventHandler, readBody, useRuntimeConfig } from "#imports";
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
4
+ import { createError, defineEventHandler, readBody } from "h3";
5
+ import { useRuntimeConfig } from "#imports";
6
+ import { orderKeysByReference } from "./mergeDoctype.js";
4
7
  export default defineEventHandler(async (event) => {
5
8
  const body = await readBody(event);
6
9
  if (!body.doctype || typeof body.doctype !== "string") {
@@ -9,28 +12,62 @@ export default defineEventHandler(async (event) => {
9
12
  message: "Missing or invalid doctype name"
10
13
  });
11
14
  }
12
- if (!body.schema || !Array.isArray(body.schema)) {
15
+ if (!body.fields || !Array.isArray(body.fields)) {
13
16
  throw createError({
14
17
  status: 400,
15
- message: "Missing or invalid schema array"
18
+ message: "Missing or invalid fields array"
16
19
  });
17
20
  }
18
21
  const config = useRuntimeConfig();
19
22
  const doctypesDir = config.stonecrop?.doctypesDir || resolve(process.cwd(), "doctypes");
20
- const filename = body.doctype.toLowerCase().replace(/\s+/g, "-");
21
- const filePath = resolve(doctypesDir, `${filename}.json`);
22
- if (!filePath.startsWith(doctypesDir)) {
23
+ const requested = body.doctype;
24
+ const exactPath = resolve(doctypesDir, `${requested}.json`);
25
+ if (!exactPath.startsWith(doctypesDir + "/")) {
23
26
  throw createError({
24
27
  status: 400,
25
28
  message: "Invalid doctype name"
26
29
  });
27
30
  }
31
+ let filePath = exactPath;
32
+ if (!existsSync(filePath)) {
33
+ const files = await readdir(doctypesDir).catch(() => []);
34
+ const match = files.find((f) => f.toLowerCase() === `${requested.toLowerCase()}.json`);
35
+ if (match) {
36
+ filePath = resolve(doctypesDir, match);
37
+ }
38
+ }
39
+ if (body.create === true && existsSync(filePath)) {
40
+ throw createError({
41
+ status: 409,
42
+ message: `A doctype named "${requested}" already exists.`
43
+ });
44
+ }
45
+ let existing = {};
46
+ if (existsSync(filePath)) {
47
+ try {
48
+ const content = await readFile(filePath, "utf-8");
49
+ existing = JSON.parse(content);
50
+ } catch {
51
+ }
52
+ }
28
53
  const doctypeData = {
29
- schema: body.schema
54
+ ...existing,
55
+ fields: body.fields
30
56
  };
57
+ if (body.workflow !== void 0 && body.workflow !== null) {
58
+ const existingActions = existing.workflow?.actions;
59
+ const workflow = body.workflow;
60
+ doctypeData.workflow = workflow.actions ? { ...workflow, actions: orderKeysByReference(workflow.actions, existingActions) } : workflow;
61
+ } else if (doctypeData.workflow === null || doctypeData.workflow === void 0) {
62
+ delete doctypeData.workflow;
63
+ }
64
+ if (typeof doctypeData.name !== "string" || doctypeData.name.length === 0) {
65
+ doctypeData.name = requested;
66
+ }
67
+ delete doctypeData.schema;
31
68
  try {
32
- await writeFile(filePath, JSON.stringify(doctypeData, null, " "), "utf-8");
33
- return { success: true, path: `doctypes/${filename}.json` };
69
+ await writeFile(filePath, JSON.stringify(doctypeData, null, " ") + "\n", "utf-8");
70
+ return { success: true, path: `doctypes/${basename(filePath)}` };
34
71
  } catch (error) {
35
72
  throw createError({
36
73
  status: 500,
@@ -1,2 +1,3 @@
1
- declare const _default: any;
1
+ import type { ValidationResult } from '@stonecrop/schema';
2
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<ValidationResult>>;
2
3
  export default _default;
@@ -1,5 +1,5 @@
1
1
  import { validateField } from "@stonecrop/schema";
2
- import { createError, defineEventHandler, readBody } from "#imports";
2
+ import { createError, defineEventHandler, readBody } from "h3";
3
3
  export default defineEventHandler(async (event) => {
4
4
  const body = await readBody(event);
5
5
  if (!body.fields || !Array.isArray(body.fields)) {