@ramathibodi/nuxt-commons 4.0.10 → 4.0.11

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
@@ -6,6 +6,14 @@ Nuxt module for Nuxt 3 and Nuxt 4 that provides shared runtime building blocks f
6
6
  - runtime plugins for permission/dialog/default behavior
7
7
  - utility and lab exports
8
8
 
9
+ ## Documentation & playground
10
+
11
+ The live playground is the project's documentation site — every component has a real demo page running the real module against the real Vuetify theme, with a Vuetify-docs-style API panel (Props / Emits / Slots / Exposed) sourced from `docs/ai-summary.json`. Bug-repro fixtures live under *Scenarios*.
12
+
13
+ → **https://ramacare.gitlab.rama.mahidol.ac.th/frontend/rama-modules/** _(published from `master` by the `pages` CI job; if the project later moves to a unique Pages domain, update `NUXT_APP_BASE_URL` in the CI job accordingly)_
14
+
15
+ Run it locally with `pnpm run dev`. See `CLAUDE.md` > *Playground* for the conventions every new/modified component must follow.
16
+
9
17
  ## Install
10
18
 
11
19
  ```bash
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^4.3.1"
6
6
  },
7
- "version": "4.0.10",
7
+ "version": "4.0.11",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
@@ -5,7 +5,12 @@ interface Props {
5
5
  }
6
6
  /**
7
7
  * Public props accepted by DocumentTemplateBuilder.
8
- * Document each prop field with intent, defaults, and side effects for clear generated docs.
8
+ *
9
+ * To extend the Input Type dropdown with app-specific entries, call
10
+ * `registerDocumentTemplateInputType()` from a Nuxt plugin (app-wide) or
11
+ * `useDocumentTemplateInputType()` inside `<script setup>` of a page
12
+ * (page-scoped, auto-cleanup on unmount). Both flow through the same
13
+ * runtime registry that this component reads via `useDocumentTemplateInputTypes()`.
9
14
  */
10
15
  type __VLS_Props = Props;
11
16
  type __VLS_ModelProps = {
@@ -3,6 +3,9 @@ import { computed, ref, watch } from "vue";
3
3
  import * as prettier from "prettier";
4
4
  import prettierPluginHtml from "prettier/plugins/html";
5
5
  import { useDocumentTemplate, validationRulesRegex, optionStringToChoiceObject } from "../../composables/document/template";
6
+ import {
7
+ useDocumentTemplateInputTypes
8
+ } from "../../composables/document/templateInputTypes";
6
9
  import { autoActionHeader, templateToHeader } from "../../composables/document/templateFormTable";
7
10
  import { cloneDeep } from "lodash-es";
8
11
  import VueJsonPretty from "vue-json-pretty";
@@ -113,47 +116,24 @@ async function convertToAdvanceMode() {
113
116
  modelValue.value = await prettier.format(useDocumentTemplate(templateItems.value).replaceAll(">", ">\n"), { parser: "html", plugins: [prettierPluginHtml] });
114
117
  }
115
118
  }
116
- const inputTypeChoice = ref([
117
- { label: "Text Field", value: "VTextField" },
118
- { label: "Text Area", value: "VTextarea" },
119
- { label: "Text Date Picker", value: "FormDate" },
120
- { label: "Text Time Picker", value: "FormTime" },
121
- { label: "Text Date & Time Picker", value: "FormDateTime" },
122
- { label: "Select Dropdown", value: "VSelect" },
123
- { label: "Select Combobox", value: "VCombobox" },
124
- { label: "Autocomplete", value: "VAutocomplete" },
125
- { label: "Autocomplete from Master", value: "MasterAutocomplete" },
126
- { label: "Radio Buttons", value: "VRadio" },
127
- { label: "Radio Buttons Inline", value: "VRadioInline" },
128
- { label: "Checkbox", value: "VCheckbox" },
129
- { label: "Checkbox Group", value: "FormCheckboxGroup" },
130
- { label: "Switch", value: "VSwitch" },
131
- { label: "File Upload", value: "FormFile" },
132
- { label: "Signature Pad", value: "FormSignPad" },
133
- { label: "Table", value: "FormTable" },
134
- { label: "Table Data", value: "FormTableData" },
135
- { label: "[Decoration] Header", value: "Header" },
136
- { label: "[Decoration] Separator", value: "Separator" },
137
- { label: "[Advanced] Hidden Field", value: "FormHidden" },
138
- { label: "[Advanced] Inherit Form", value: "DocumentForm" },
139
- { label: "[Advanced] Custom Code", value: "CustomCode" }
140
- ]);
141
- const requireOption = ref(["VSelect", "VAutocomplete", "VCombobox", "VRadio", "VRadioInline", "MasterAutocomplete", "FormTable", "FormTableData", "DocumentForm", "FormCheckboxGroup"]);
142
- const notRequireVariable = ref(["Header", "Separator", "CustomCode", "DocumentForm"]);
143
- const notRequireLabel = ref(["Separator", "CustomCode", "FormFile", "FormHidden", "DocumentForm"]);
144
- const notRequireWidth = ref(["Separator", "FormHidden", "DocumentForm"]);
145
- const notRequireOptions = ref(["Separator", "CustomCode", "FormFile"]);
146
- const notRequireRules = ref(["Separator", "Header", "CustomCode", "FormHidden", "DocumentForm"]);
147
- const notRequireInputAttributes = ref(["CustomCode", "Header", "DocumentForm"]);
148
- const notRequireColumnAttributes = ref(["Separator", "FormHidden", "DocumentForm"]);
149
- const notRequireAdvancedSetting = ref(["Separator", "CustomCode"]);
150
- const notRequireClassAndStyle = ref(["FormHidden"]);
151
- const hasSpecificOption = ref(["FormHidden", "FormTable", "FormTableData"]);
152
- const choiceOption = ref(["VRadio", "VRadioInline", "VSelect", "VAutocomplete", "VCombobox", "FormCheckboxGroup"]);
153
- const inputOptionsLabel = ref({
154
- "MasterAutocomplete": "Group Key",
155
- "Header": "Class",
156
- "DocumentForm": "Template Code"
119
+ const inputTypes = computed(() => useDocumentTemplateInputTypes());
120
+ const inputTypeChoice = computed(() => inputTypes.value.map((t) => ({ label: t.label, value: t.value })));
121
+ const requireOption = computed(() => inputTypes.value.filter((t) => t.requiresOptions).map((t) => t.value));
122
+ const notRequireVariable = computed(() => inputTypes.value.filter((t) => t.needsVariableName === false).map((t) => t.value));
123
+ const notRequireLabel = computed(() => inputTypes.value.filter((t) => t.needsLabel === false).map((t) => t.value));
124
+ const notRequireWidth = computed(() => inputTypes.value.filter((t) => t.needsWidth === false).map((t) => t.value));
125
+ const notRequireOptions = computed(() => inputTypes.value.filter((t) => t.showsOptions === false).map((t) => t.value));
126
+ const notRequireRules = computed(() => inputTypes.value.filter((t) => t.hidesValidationRules).map((t) => t.value));
127
+ const notRequireInputAttributes = computed(() => inputTypes.value.filter((t) => t.hidesInputAttributes).map((t) => t.value));
128
+ const notRequireColumnAttributes = computed(() => inputTypes.value.filter((t) => t.hidesColumnAttributes).map((t) => t.value));
129
+ const notRequireAdvancedSetting = computed(() => inputTypes.value.filter((t) => t.hidesAdvancedSettings).map((t) => t.value));
130
+ const notRequireClassAndStyle = computed(() => inputTypes.value.filter((t) => t.hidesClassAndStyle).map((t) => t.value));
131
+ const hasSpecificOption = computed(() => inputTypes.value.filter((t) => t.hasSpecificOptionEditor).map((t) => t.value));
132
+ const choiceOption = computed(() => inputTypes.value.filter((t) => t.optionsAsChoice).map((t) => t.value));
133
+ const inputOptionsLabel = computed(() => {
134
+ const acc = {};
135
+ for (const t of inputTypes.value) if (t.optionsLabel) acc[t.value] = t.optionsLabel;
136
+ return acc;
157
137
  });
158
138
  const ruleOptions = (inputType) => (value) => {
159
139
  if (choiceOption.value.includes(inputType) && !/^[^'",]+(,[^'",]+)*$/.test(value)) return "Invalid options format";
@@ -5,7 +5,12 @@ interface Props {
5
5
  }
6
6
  /**
7
7
  * Public props accepted by DocumentTemplateBuilder.
8
- * Document each prop field with intent, defaults, and side effects for clear generated docs.
8
+ *
9
+ * To extend the Input Type dropdown with app-specific entries, call
10
+ * `registerDocumentTemplateInputType()` from a Nuxt plugin (app-wide) or
11
+ * `useDocumentTemplateInputType()` inside `<script setup>` of a page
12
+ * (page-scoped, auto-cleanup on unmount). Both flow through the same
13
+ * runtime registry that this component reads via `useDocumentTemplateInputTypes()`.
9
14
  */
10
15
  type __VLS_Props = Props;
11
16
  type __VLS_ModelProps = {
@@ -308,5 +308,5 @@ defineExpose({
308
308
  </template>
309
309
 
310
310
  <style>
311
- .form-data-dirty:not(.v-input--error) :not(.v-chip):not(.v-chip *){color:color-mix(in srgb,currentColor 70%,rgb(var(--v-theme-primary)))!important;text-shadow:0 0 .02em currentColor}
311
+ .form-data-dirty:not(.v-input--error) :not(.v-chip):not(.v-chip *):not(.v-toolbar):not(.v-toolbar *){color:color-mix(in srgb,currentColor 70%,rgb(var(--v-theme-primary)))!important;text-shadow:0 0 .02em currentColor}
312
312
  </style>
@@ -3,6 +3,7 @@ import { processTemplateFormTableData } from "./templateFormTableData.js";
3
3
  import { processTemplateFormHidden } from "./templateFormHidden.js";
4
4
  import { some, includes, cloneDeep } from "lodash-es";
5
5
  import { migrateInputAttributes, migrateTemplateString } from "./templateMigrate.js";
6
+ import { getDocumentTemplateInputTypeEntry } from "./templateInputTypes.js";
6
7
  export const validationRulesRegex = /^(require(?:\([^)]*\))?|requireIf\([^)]*\)|requireTrue(?:\([^)]*\))?|requireTrueIf\([^)]*\)|numeric(?:\([^)]*\))?|range\([^)]*\)|integer(?:\([^)]*\))?|unique\([^)]*\)|length(?:\([^)]*\))?|lengthGreater\([^)]*\)|lengthLess\([^)]*\)|telephone(?:\([^)]*\))?|email(?:\([^)]*\))?|regex\([^)]*\)|idcard(?:\([^)]*\))?|DateFuture(?:\([^)]*\))?|DatetimeFuture(?:\([^)]*\))?|DateHappen(?:\([^)]*\))?|DatetimeHappen(?:\([^)]*\))?|DateAfter\([^)]*\)|DateBefore\([^)]*\)|DateEqual\([^)]*\))(,(require(?:\([^)]*\))?|requireIf\([^)]*\)|requireTrue(?:\([^)]*\))?|requireTrueIf\([^)]*\)|numeric(?:\([^)]*\))?|range\([^)]*\)|integer(?:\([^)]*\))?|unique\([^)]*\)|length(?:\([^)]*\))?|lengthGreater\([^)]*\)|lengthLess\([^)]*\)|telephone(?:\([^)]*\))?|email(?:\([^)]*\))?|regex\([^)]*\)|idcard(?:\([^)]*\))?|DateFuture(?:\([^)]*\))?|DatetimeFuture(?:\([^)]*\))?|DateHappen(?:\([^)]*\))?|DatetimeHappen(?:\([^)]*\))?|DateAfter\([^)]*\)|DateBefore\([^)]*\)|DateEqual\([^)]*\)))*$/;
7
8
  export function useDocumentTemplate(items, parentTemplates) {
8
9
  if (!items) return "";
@@ -52,6 +53,14 @@ export function templateItemToString(inputItem, parentTemplates, dataVariable =
52
53
  }
53
54
  let templateString;
54
55
  const validationRules = item.validationRules ? buildValidationRules(item.validationRules) || "" : "";
56
+ const registryEntry = getDocumentTemplateInputTypeEntry(item.inputType);
57
+ if (registryEntry && typeof registryEntry.render === "function") {
58
+ templateString = registryEntry.render(item, parentTemplates, dataVariable);
59
+ if (item.computedValue && item.variableName) {
60
+ templateString = `${templateString || ""}<FormHidden v-model="${dataVariable}.${item.variableName}" :item-value="data" :hook="()=>${item.computedValue}"/>`.trim();
61
+ }
62
+ return templateString || "";
63
+ }
55
64
  switch (item.inputType) {
56
65
  case "CustomCode":
57
66
  templateString = item.inputCustomCode || "";
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Registry of input types available in `<DocumentTemplateBuilder>` (the
3
+ * structured editor for document templates) AND consumable by the runtime
4
+ * renderer (`useDocumentTemplate` in `./template.ts`).
5
+ *
6
+ * **Single registration entry point:** `registerDocumentTemplateInputType`.
7
+ *
8
+ * Use it from a Nuxt plugin to add an entry app-wide. The same `entry` object
9
+ * carries everything — dropdown label, builder-UI flags, and an optional
10
+ * `render` function consumed by the runtime renderer in `template.ts`:
11
+ *
12
+ * ```ts
13
+ * // app/plugins/document-template-inputs.ts
14
+ * export default defineNuxtPlugin(() => {
15
+ * registerDocumentTemplateInputType({
16
+ * label: 'From Staff',
17
+ * value: 'FromStaff',
18
+ * render(item, _parents, dataVariable) {
19
+ * return `<FromStaff :staff-id="${dataVariable}.${item.variableName}" />`
20
+ * },
21
+ * })
22
+ * })
23
+ * ```
24
+ *
25
+ * For rare page-scoped extensions, call `useDocumentTemplateInputType()` in
26
+ * `<script setup>` — it wraps `register` + `onScopeDispose` so the entry is
27
+ * removed automatically when the component unmounts.
28
+ *
29
+ * Merge order (later wins on `value` collision):
30
+ * built-in ⊕ runtime-registry
31
+ *
32
+ * The runtime renderer dispatches as follows inside `templateItemToString`:
33
+ * 1. If the registered entry for `inputType` has a `render` function → call it.
34
+ * 2. Else if `inputType` is one of the built-in special cases (Header,
35
+ * Separator, FormTable, …) → use the hand-written switch arm.
36
+ * 3. Else → `processDefaultTemplate` (the v-model/label fallback).
37
+ *
38
+ * The `computedValue` post-pass (appending `<FormHidden>` to write a derived
39
+ * value back to `data[variableName]`) still runs unconditionally, so custom
40
+ * renderers don't need to handle it.
41
+ */
42
+ import type { DocumentTemplateItem } from './template.js';
43
+ /**
44
+ * Signature for a custom renderer registered against a specific `inputType`.
45
+ *
46
+ * Must return the Vue template fragment string to splice into the document
47
+ * template. Receives:
48
+ *
49
+ * - `item` — a `cloneDeep`'d copy of the template item, with two
50
+ * transformations already applied to `item.inputAttributes`:
51
+ * 1. `migrateInputAttributes()` — Vuetify v3→v4 attribute migration
52
+ * (e.g. bare `dense` → `density="compact"`).
53
+ * 2. If `item.conditionalDisplay` was set, `v-if="<expr>"` has already
54
+ * been appended. **Renderers don't need to handle
55
+ * `conditionalDisplay` separately** — just include
56
+ * `item.inputAttributes` on the emitted tag.
57
+ * - `parentTemplates` — chain of ancestor template codes (used by nested
58
+ * types like `DocumentForm` / `FormTable`); pass-through to nested
59
+ * `useDocumentTemplate()` calls when relevant.
60
+ * - `dataVariable` — the form `data` variable name. `"data"` at the top
61
+ * level; nested tables pass their row alias (e.g. `"row"`).
62
+ *
63
+ * **Helpers safe to import from `./template`:**
64
+ *
65
+ * - `buildValidationRules(item.validationRules)` — produces the
66
+ * `:rules="[rules.x()]"` attribute string (or `''` when no rules).
67
+ * The same helper used by the built-in switch arms.
68
+ * - `optionStringToChoiceObject(item.inputOptions)` — parses
69
+ * `"a|1,b|2"` / array forms into `ChoiceItem[]`.
70
+ * - `escapeObjectForInlineBinding(obj)` — `JSON.stringify` + `'` escaping
71
+ * for inline `v-bind` payloads.
72
+ * - `processDefaultTemplate(item, insideTemplate?, optionString?, validationRules?, dataVariable?)`
73
+ * — the same fallback used for unknown types. Call it from a renderer
74
+ * when you want "the default plus a wrapper".
75
+ *
76
+ * **Handled for you AFTER `render` returns:**
77
+ *
78
+ * - The `computedValue` post-pass that appends
79
+ * `<FormHidden v-model="data.x" :hook="()=>computedValue"/>`
80
+ * when both `item.computedValue` and `item.variableName` are set.
81
+ * - Column wrapping (`<v-col cols=…>`), `columnAttributes`, `customClass`,
82
+ * `customStyle` are applied by the caller (`columnWrapTemplateItemString`).
83
+ *
84
+ * **What `optionString` is and why it isn't passed:** the built-in switch
85
+ * arms precompute an `optionString` for the specific built-in option-bearing
86
+ * types (`MasterAutocomplete`, `VSelect`, `VAutocomplete`, `VCombobox`,
87
+ * `FormCheckboxGroup`, `VRadio`, `VRadioInline`, `Header`). For any
88
+ * custom-registered `inputType` that string is empty, so passing it would
89
+ * be dead weight. Compose your own option markup with the two helpers above
90
+ * when needed.
91
+ *
92
+ * **Minimal "field-shaped" renderer template:**
93
+ *
94
+ * ```ts
95
+ * import { buildValidationRules } from '@ramathibodi/nuxt-commons'
96
+ *
97
+ * render(item, _parents, dataVariable) {
98
+ * const variable = (item.variableName ?? '').replace(/[^\w$]/g, '')
99
+ * const attrs = item.inputAttributes ? ` ${item.inputAttributes.trim()}` : ''
100
+ * const rules = item.validationRules ? buildValidationRules(item.validationRules) : ''
101
+ * return `<MyComponent `
102
+ * + `:value="${dataVariable}.${variable}" `
103
+ * + `@update:value="v => ${dataVariable}.${variable} = v"`
104
+ * + `${attrs}${rules ? ' ' + rules : ''}/>`
105
+ * }
106
+ * ```
107
+ */
108
+ export type DocumentTemplateInputTypeRenderer = (item: DocumentTemplateItem, parentTemplates: string | string[], dataVariable: string) => string;
109
+ export interface DocumentTemplateInputType {
110
+ /** Label shown in the input-type dropdown. */
111
+ label: string;
112
+ /**
113
+ * `inputType` value stored in the template JSON. For app-specific types,
114
+ * this must be a globally-registered Vue component tag — the runtime
115
+ * renderer emits it as `<value v-model=… label=… …/>`.
116
+ */
117
+ value: string;
118
+ /** Default `true`. Set `false` for decoration types (e.g. `Header`, `Separator`). */
119
+ needsVariableName?: boolean;
120
+ /** Default `true`. Set `false` for types that have no label (e.g. `Separator`, `CustomCode`). */
121
+ needsLabel?: boolean;
122
+ /** Default `true`. Set `false` for types laid out outside the grid (e.g. `Separator`, `FormHidden`). */
123
+ needsWidth?: boolean;
124
+ /**
125
+ * Whether the generic `inputOptions` editor (textarea / specific editor)
126
+ * is shown. Default `true`. Set `false` only when the type has no
127
+ * meaningful inputOptions (e.g. `Separator`, `CustomCode`, `FormFile`).
128
+ */
129
+ showsOptions?: boolean;
130
+ /** Default `false`. Set `true` to make `inputOptions` required in the editor. */
131
+ requiresOptions?: boolean;
132
+ /** Override the textarea label (`'Input Options'` by default). */
133
+ optionsLabel?: string;
134
+ /**
135
+ * Render the choice-array editor instead of a plain textarea. Applies to
136
+ * the choice-style inputs (select, radio, checkbox-group, etc.).
137
+ */
138
+ optionsAsChoice?: boolean;
139
+ /**
140
+ * Marks the type as having a custom inputOptions sub-editor handled inline
141
+ * by `TemplateBuilder.vue` (FormHidden / FormTable / FormTableData). Apps
142
+ * cannot extend this; new built-ins must update the builder UI as well.
143
+ */
144
+ hasSpecificOptionEditor?: boolean;
145
+ /** Default `false`. Hides the Validations expansion panel. */
146
+ hidesValidationRules?: boolean;
147
+ /** Default `false`. Hides the `Input Attributes` field inside Advanced settings. */
148
+ hidesInputAttributes?: boolean;
149
+ /** Default `false`. Hides the `Column Attributes` field inside Advanced settings. */
150
+ hidesColumnAttributes?: boolean;
151
+ /** Default `false`. Hides the entire Advanced settings expansion panel. */
152
+ hidesAdvancedSettings?: boolean;
153
+ /** Default `false`. Hides the Class and Styles expansion panel. */
154
+ hidesClassAndStyle?: boolean;
155
+ /**
156
+ * Optional renderer that overrides the default `<Tag v-model="…" label="…"/>`
157
+ * emission for this `inputType`. Use when an app component does not follow
158
+ * the standard contract (different value prop, slot content, etc.).
159
+ */
160
+ render?: DocumentTemplateInputTypeRenderer;
161
+ }
162
+ /**
163
+ * Built-in registry. One entry per inputType that `TemplateBuilder.vue`
164
+ * historically hard-coded. Flag values mirror the previous arrays
165
+ * (`requireOption`, `notRequireVariable`, `notRequireLabel`, `notRequireWidth`,
166
+ * `notRequireOptions`, `notRequireRules`, `notRequireInputAttributes`,
167
+ * `notRequireColumnAttributes`, `notRequireAdvancedSetting`,
168
+ * `notRequireClassAndStyle`, `choiceOption`, `inputOptionsLabel`,
169
+ * `hasSpecificOption`).
170
+ */
171
+ export declare const BUILT_IN_DOCUMENT_TEMPLATE_INPUT_TYPES: readonly DocumentTemplateInputType[];
172
+ /**
173
+ * Merge multiple lists of input-type definitions. Later entries win on
174
+ * `value` collision, allowing a consumer to override a built-in entry's
175
+ * label or flags (e.g. relabel "Text Field" to "Free Text" in one app).
176
+ *
177
+ * Render functions follow the same precedence: a later entry without
178
+ * `render` does NOT clear an earlier-registered render (the shallow merge
179
+ * keeps the prior value). To explicitly remove a render, set
180
+ * `render: undefined` on the later entry, or call
181
+ * `unregisterDocumentTemplateInputType`.
182
+ */
183
+ export declare function mergeDocumentTemplateInputTypes(...lists: Array<readonly DocumentTemplateInputType[] | DocumentTemplateInputType[] | undefined | null>): DocumentTemplateInputType[];
184
+ /**
185
+ * Register (or upsert) an input-type entry. Returns an `unregister()`
186
+ * function so callers — especially HMR-aware Nuxt plugins — can revert the
187
+ * registration. Calling `register` twice with the same `value` performs a
188
+ * shallow merge, matching the wider `mergeDocumentTemplateInputTypes`
189
+ * semantics. No-op (and returns a no-op unregister) when `entry` lacks a
190
+ * string `value`.
191
+ *
192
+ * **Where to call from:**
193
+ * - Nuxt plugin (`app/plugins/*.ts`) — the canonical place for app-wide
194
+ * registration. Entries persist for the lifetime of the app.
195
+ * - Inside a Vue component setup — prefer `useDocumentTemplateInputType`
196
+ * instead; it wraps this function with auto-cleanup on unmount.
197
+ */
198
+ export declare function registerDocumentTemplateInputType(entry: DocumentTemplateInputType): () => void;
199
+ /** Explicit removal — handy in unit tests and for one-off cleanup. */
200
+ export declare function unregisterDocumentTemplateInputType(value: string): void;
201
+ /**
202
+ * Page-scoped registration. Calls `registerDocumentTemplateInputType` and
203
+ * schedules the returned `unregister()` on `onScopeDispose`, so the entry
204
+ * is removed automatically when the surrounding effect scope (typically a
205
+ * Vue component's setup) is disposed.
206
+ *
207
+ * If called outside an effect scope, the registration persists for the
208
+ * lifetime of the runtime — at which point you should use
209
+ * `registerDocumentTemplateInputType` directly so the lifecycle is explicit.
210
+ */
211
+ export declare function useDocumentTemplateInputType(entry: DocumentTemplateInputType): void;
212
+ /** Snapshot of the runtime registry. Internal — exported for tests. */
213
+ export declare function _getDocumentTemplateRuntimeRegistry(): DocumentTemplateInputType[];
214
+ /** Test-only reset. Does not affect built-ins. */
215
+ export declare function _resetDocumentTemplateRuntimeRegistry(): void;
216
+ /**
217
+ * Look up a single entry from the merged registry (built-in ⊕ runtime).
218
+ * Used by `templateItemToString` to decide whether a custom `render` is in
219
+ * play.
220
+ */
221
+ export declare function getDocumentTemplateInputTypeEntry(value: string): DocumentTemplateInputType | undefined;
222
+ /**
223
+ * Read the merged input-type registry: built-in ⊕ runtime registry.
224
+ * Stable across calls (re-reads on every call so HMR-driven re-registration
225
+ * picks up). Returns a fresh array — callers may mutate without affecting
226
+ * the underlying state.
227
+ */
228
+ export declare function useDocumentTemplateInputTypes(): DocumentTemplateInputType[];
@@ -0,0 +1,128 @@
1
+ import { getCurrentScope, onScopeDispose } from "vue";
2
+ export const BUILT_IN_DOCUMENT_TEMPLATE_INPUT_TYPES = Object.freeze([
3
+ { label: "Text Field", value: "VTextField" },
4
+ { label: "Text Area", value: "VTextarea" },
5
+ { label: "Text Date Picker", value: "FormDate" },
6
+ { label: "Text Time Picker", value: "FormTime" },
7
+ { label: "Text Date & Time Picker", value: "FormDateTime" },
8
+ { label: "Select Dropdown", value: "VSelect", requiresOptions: true, optionsAsChoice: true },
9
+ { label: "Select Combobox", value: "VCombobox", requiresOptions: true, optionsAsChoice: true },
10
+ { label: "Autocomplete", value: "VAutocomplete", requiresOptions: true, optionsAsChoice: true },
11
+ { label: "Autocomplete from Master", value: "MasterAutocomplete", requiresOptions: true, optionsLabel: "Group Key" },
12
+ { label: "Radio Buttons", value: "VRadio", requiresOptions: true, optionsAsChoice: true },
13
+ { label: "Radio Buttons Inline", value: "VRadioInline", requiresOptions: true, optionsAsChoice: true },
14
+ { label: "Checkbox", value: "VCheckbox" },
15
+ { label: "Checkbox Group", value: "FormCheckboxGroup", requiresOptions: true, optionsAsChoice: true },
16
+ { label: "Switch", value: "VSwitch" },
17
+ { label: "File Upload", value: "FormFile", needsLabel: false, showsOptions: false },
18
+ { label: "Signature Pad", value: "FormSignPad" },
19
+ { label: "Table", value: "FormTable", requiresOptions: true, hasSpecificOptionEditor: true },
20
+ { label: "Table Data", value: "FormTableData", requiresOptions: true, hasSpecificOptionEditor: true },
21
+ {
22
+ label: "[Decoration] Header",
23
+ value: "Header",
24
+ needsVariableName: false,
25
+ needsWidth: false,
26
+ optionsLabel: "Class",
27
+ hidesValidationRules: true,
28
+ hidesInputAttributes: true
29
+ },
30
+ {
31
+ label: "[Decoration] Separator",
32
+ value: "Separator",
33
+ needsVariableName: false,
34
+ needsLabel: false,
35
+ needsWidth: false,
36
+ showsOptions: false,
37
+ hidesValidationRules: true,
38
+ hidesInputAttributes: true,
39
+ hidesColumnAttributes: true,
40
+ hidesAdvancedSettings: true
41
+ },
42
+ {
43
+ label: "[Advanced] Hidden Field",
44
+ value: "FormHidden",
45
+ needsLabel: false,
46
+ needsWidth: false,
47
+ hasSpecificOptionEditor: true,
48
+ hidesValidationRules: true,
49
+ hidesColumnAttributes: true,
50
+ hidesClassAndStyle: true
51
+ },
52
+ {
53
+ label: "[Advanced] Inherit Form",
54
+ value: "DocumentForm",
55
+ needsVariableName: false,
56
+ needsLabel: false,
57
+ needsWidth: false,
58
+ requiresOptions: true,
59
+ optionsLabel: "Template Code",
60
+ hidesValidationRules: true,
61
+ hidesInputAttributes: true,
62
+ hidesColumnAttributes: true
63
+ },
64
+ {
65
+ label: "[Advanced] Custom Code",
66
+ value: "CustomCode",
67
+ needsVariableName: false,
68
+ needsLabel: false,
69
+ showsOptions: false,
70
+ hidesValidationRules: true,
71
+ hidesInputAttributes: true,
72
+ hidesAdvancedSettings: true
73
+ }
74
+ ]);
75
+ export function mergeDocumentTemplateInputTypes(...lists) {
76
+ const byValue = /* @__PURE__ */ new Map();
77
+ for (const list of lists) {
78
+ if (!list) continue;
79
+ for (const entry of list) {
80
+ if (!entry || typeof entry.value !== "string" || entry.value.length === 0) continue;
81
+ byValue.set(entry.value, { ...byValue.get(entry.value), ...entry });
82
+ }
83
+ }
84
+ return [...byValue.values()];
85
+ }
86
+ const runtimeRegistry = /* @__PURE__ */ new Map();
87
+ export function registerDocumentTemplateInputType(entry) {
88
+ if (!entry || typeof entry.value !== "string" || entry.value.length === 0) {
89
+ return () => {
90
+ };
91
+ }
92
+ const previous = runtimeRegistry.get(entry.value);
93
+ const merged = { ...previous, ...entry };
94
+ runtimeRegistry.set(entry.value, merged);
95
+ return () => {
96
+ if (runtimeRegistry.get(entry.value) === merged) {
97
+ runtimeRegistry.delete(entry.value);
98
+ }
99
+ };
100
+ }
101
+ export function unregisterDocumentTemplateInputType(value) {
102
+ runtimeRegistry.delete(value);
103
+ }
104
+ export function useDocumentTemplateInputType(entry) {
105
+ const unregister = registerDocumentTemplateInputType(entry);
106
+ if (getCurrentScope()) {
107
+ onScopeDispose(unregister);
108
+ }
109
+ }
110
+ export function _getDocumentTemplateRuntimeRegistry() {
111
+ return [...runtimeRegistry.values()];
112
+ }
113
+ export function _resetDocumentTemplateRuntimeRegistry() {
114
+ runtimeRegistry.clear();
115
+ }
116
+ export function getDocumentTemplateInputTypeEntry(value) {
117
+ if (!value) return void 0;
118
+ const fromRuntime = runtimeRegistry.get(value);
119
+ const builtIn = BUILT_IN_DOCUMENT_TEMPLATE_INPUT_TYPES.find((t) => t.value === value);
120
+ if (fromRuntime) return { ...builtIn, ...fromRuntime };
121
+ return builtIn;
122
+ }
123
+ export function useDocumentTemplateInputTypes() {
124
+ return mergeDocumentTemplateInputTypes(
125
+ BUILT_IN_DOCUMENT_TEMPLATE_INPUT_TYPES,
126
+ _getDocumentTemplateRuntimeRegistry()
127
+ );
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramathibodi/nuxt-commons",
3
- "version": "4.0.10",
3
+ "version": "4.0.11",
4
4
  "description": "Ramathibodi Nuxt modules for common components",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,6 +47,8 @@
47
47
  "prepack": "nuxt-module-build build",
48
48
  "dev": "npm run dev:prepare && nuxt dev playground",
49
49
  "dev:build": "nuxt build playground",
50
+ "dev:generate": "nuxt generate playground",
51
+ "playground:scaffold": "node scripts/scaffold-playground-pages.mjs",
50
52
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
51
53
  "docs:api:components": "vue-docgen -c docs/vue-docgen.config.cjs && npm run docs:ai:summary && node scripts/enrich-vue-docs-from-ai.mjs",
52
54
  "docs:api:composables": "typedoc --options docs/typedoc.json",
@@ -159,6 +159,73 @@ function extractEmits(content) {
159
159
  return emits
160
160
  }
161
161
 
162
+ function extractTemplate(content) {
163
+ const m = content.match(/<template[^>]*>([\s\S]*)<\/template>/i)
164
+ return m ? m[1] : ''
165
+ }
166
+
167
+ function extractSlots(content) {
168
+ const slots = []
169
+ const seen = new Set()
170
+ const template = extractTemplate(content)
171
+ if (!template) return slots
172
+
173
+ const slotRe = /<slot\b([^>]*?)(?:\/?>)/g
174
+ let match
175
+ while ((match = slotRe.exec(template)) !== null) {
176
+ const attrs = match[1] || ''
177
+ const nameMatch = attrs.match(/(?:^|\s)name\s*=\s*"([^"]+)"/)
178
+ || attrs.match(/(?:^|\s)#([A-Za-z_][A-Za-z0-9_-]*)/)
179
+ const name = nameMatch ? nameMatch[1] : 'default'
180
+ if (seen.has(name)) continue
181
+ seen.add(name)
182
+
183
+ const bindings = []
184
+ const bindRe = /:([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"/g
185
+ let bm
186
+ while ((bm = bindRe.exec(attrs)) !== null) {
187
+ if (bm[1] === 'name') continue
188
+ bindings.push({ name: bm[1], expression: compact(bm[2]) })
189
+ }
190
+ slots.push({ name, bindings })
191
+ }
192
+ return slots
193
+ }
194
+
195
+ function extractExposed(content) {
196
+ const exposed = []
197
+ const m = content.match(/defineExpose\s*\(\s*\{([\s\S]*?)\}\s*\)/)
198
+ if (!m) return exposed
199
+ const body = m[1]
200
+ // Match top-level identifiers; ignore values, nested braces handled by depth.
201
+ let depth = 0
202
+ let token = ''
203
+ const tokens = []
204
+ for (let i = 0; i < body.length; i++) {
205
+ const ch = body[i]
206
+ if (ch === '{' || ch === '(' || ch === '[') depth++
207
+ else if (ch === '}' || ch === ')' || ch === ']') depth--
208
+ else if (ch === ',' && depth === 0) {
209
+ tokens.push(token); token = ''
210
+ continue
211
+ }
212
+ token += ch
213
+ }
214
+ if (token.trim()) tokens.push(token)
215
+
216
+ for (const raw of tokens) {
217
+ const piece = raw.trim().replace(/^\s*\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim()
218
+ if (!piece) continue
219
+ // `name`, `name: value`, `name(...) {}` — take the first identifier.
220
+ const idMatch = piece.match(/^([A-Za-z_$][A-Za-z0-9_$]*)/)
221
+ if (!idMatch) continue
222
+ const name = idMatch[1]
223
+ if (exposed.find(e => e.name === name)) continue
224
+ exposed.push({ name })
225
+ }
226
+ return exposed
227
+ }
228
+
162
229
  function extractExports(content) {
163
230
  const functions = []
164
231
  const constants = []
@@ -251,11 +318,15 @@ for (const file of componentFiles) {
251
318
  const summary = extractTopDescription(content)
252
319
  const props = extractProps(content)
253
320
  const emits = extractEmits(content)
321
+ const slots = extractSlots(content)
322
+ const exposed = extractExposed(content)
254
323
  const componentEntry = {
255
324
  path: rel,
256
325
  summary,
257
326
  props,
258
327
  emits,
328
+ slots,
329
+ exposed,
259
330
  }
260
331
  docModel.components.push(componentEntry)
261
332
 
@@ -283,6 +354,23 @@ for (const file of componentFiles) {
283
354
  } else {
284
355
  lines.push('- Emits: None detected.')
285
356
  }
357
+ if (slots.length) {
358
+ lines.push(`- Slots (${slots.length}):`)
359
+ for (const s of slots) {
360
+ const bindings = s.bindings.length ? ` bindings: ${s.bindings.map(b => `${b.name}=${b.expression}`).join(', ')}` : ''
361
+ lines.push(` - \`${s.name}\`${bindings}`)
362
+ }
363
+ } else {
364
+ lines.push('- Slots: None detected.')
365
+ }
366
+ if (exposed.length) {
367
+ lines.push(`- Exposed (${exposed.length}):`)
368
+ for (const x of exposed) {
369
+ lines.push(` - \`${x.name}\``)
370
+ }
371
+ } else {
372
+ lines.push('- Exposed: None detected.')
373
+ }
286
374
  lines.push('')
287
375
  }
288
376
 
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scaffold per-component playground pages.
4
+ *
5
+ * Reads docs/ai-summary.json and, for every component under
6
+ * src/runtime/components/, emits:
7
+ * - playground/pages/components/<slug>.vue (stub demo + API panel)
8
+ * - playground/playgrounds.generated.ts (component registry array)
9
+ *
10
+ * Idempotency: pages start with a "SCAFFOLD_MARKER" comment. The script
11
+ * overwrites only files that still carry the marker — hand-edited pages
12
+ * (marker removed) are left alone.
13
+ *
14
+ * Run with: pnpm exec node scripts/scaffold-playground-pages.mjs
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+
19
+ const rootDir = process.cwd()
20
+ const aiSummary = JSON.parse(fs.readFileSync(path.join(rootDir, 'docs/ai-summary.json'), 'utf8'))
21
+ const pagesDir = path.join(rootDir, 'playground/pages/components')
22
+ const registryFile = path.join(rootDir, 'playground/playgrounds.generated.ts')
23
+
24
+ const SCAFFOLD_MARKER = '<!-- SCAFFOLD: auto-generated by scripts/scaffold-playground-pages.mjs. Delete this line to opt out of overwrites. -->'
25
+
26
+ function pascal(segment) {
27
+ return segment.charAt(0).toUpperCase() + segment.slice(1)
28
+ }
29
+
30
+ function deriveNames(componentPath) {
31
+ // componentPath like "src/runtime/components/form/images/Pad.vue"
32
+ const rel = componentPath.replace(/^src\/runtime\/components\//, '').replace(/\.vue$/, '')
33
+ const segments = rel.split('/')
34
+ const componentName = segments.map(pascal).join('')
35
+ const slug = segments.map(s => s.toLowerCase()).join('-')
36
+ const title = componentName
37
+ // First path segment is the group; root-level components fall into "general".
38
+ const group = segments.length > 1 ? segments[0].toLowerCase() : 'general'
39
+ return { componentName, slug, title, group }
40
+ }
41
+
42
+ function renderPage({ componentName, title, summary, sourcePath }) {
43
+ const props = inferStubProps(componentName)
44
+ const propsAttr = props ? ' ' + props : ''
45
+ const setup = setupForComponent[componentName] ?? ''
46
+ return `<script setup lang="ts">
47
+ // ${SCAFFOLD_MARKER.replace(/^<!--\s*|\s*-->$/g, '')}
48
+ //
49
+ // To enrich this page (richer demo, multiple variants, controls, etc.),
50
+ // remove the SCAFFOLD comment in the <template> below — the scaffold
51
+ // script will then leave this file alone on future runs.
52
+ ${setup}</script>
53
+
54
+ <template>
55
+ ${SCAFFOLD_MARKER}
56
+ <PlaygroundPage
57
+ title="${title}"
58
+ summary="${escapeAttr(summary)}"
59
+ source-path="${sourcePath}"
60
+ >
61
+ <div class="text-caption text-medium-emphasis mb-3">
62
+ Auto-scaffolded demo — mounts the component with minimal props.
63
+ Replace this block (and delete the SCAFFOLD comment) when you wire
64
+ up a realistic interactive demo.
65
+ </div>
66
+ <${componentName}${propsAttr} />
67
+ </PlaygroundPage>
68
+ </template>
69
+ `
70
+ }
71
+
72
+ function escapeAttr(s) {
73
+ return (s || '').replace(/"/g, '&quot;').replace(/\n/g, ' ')
74
+ }
75
+
76
+ /**
77
+ * Hand-tuned minimal props for components that throw without them.
78
+ * Keep tiny — anything richer belongs in a hand-enriched page.
79
+ */
80
+ const stubProps = {
81
+ FormTable: 'title="Demo table"',
82
+ FormTableData: 'title="Demo table"',
83
+ FormActionPad: 'title="Demo"',
84
+ FormEditPad: 'title="Demo"',
85
+ FormSystem: 'name="demoForm"',
86
+ FormDialog: ':model-value="false" title="Demo"',
87
+ FormImagesCapture: 'title="Demo"',
88
+ FormImagesEdit: 'title="Demo"',
89
+ FormImagesField: 'title="Demo"',
90
+ FormImagesPad: 'title="Demo"',
91
+ DialogConfirm: ':model-value="false"',
92
+ DialogLoading: ':model-value="false"',
93
+ DialogIndex: ':model-value="false" title="Demo"',
94
+ DialogHost: '',
95
+ DialogDefaultConfirm: ':model-value="false"',
96
+ DialogDefaultLoading: ':model-value="false"',
97
+ DialogDefaultNotify: ':model-value="false"',
98
+ DialogDefaultPrinting: ':model-value="false"',
99
+ DialogDefaultVerifyUser: ':model-value="false"',
100
+ DocumentForm: 'documentName="demo"',
101
+ DocumentTemplateBuilder: 'title="Template Builder"',
102
+ ModelTable: 'modelName="MasterItem" :fields="[\'*\']"',
103
+ ModelPad: 'modelName="MasterItem"',
104
+ ModelIterator: 'modelName="MasterItem" :fields="[\'*\']"',
105
+ ModelLabel: 'modelName="MasterItem"',
106
+ ModelSelect: 'modelName="MasterItem"',
107
+ ModelAutocomplete: 'modelName="MasterItem"',
108
+ ModelCombobox: 'modelName="MasterItem"',
109
+ MasterLabel: 'groupKey="DEMO"',
110
+ MasterSelect: 'groupKey="DEMO"',
111
+ MasterAutocomplete: 'groupKey="DEMO"',
112
+ MasterCombobox: 'groupKey="DEMO"',
113
+ MasterRadioGroup: 'groupKey="DEMO"',
114
+ LabelDate: 'model-value="2026-05-18"',
115
+ LabelDateAgo: ':model-value="DateTime.now()"',
116
+ LabelDateCount: ':model-value="DateTime.now()"',
117
+ LabelField: 'label="Demo" model-value="value"',
118
+ LabelFormatMoney: ':model-value="1234.5"',
119
+ LabelMask: 'mask="###-###-####" model-value="1234567890"',
120
+ LabelObject: ':model-value="{ key: \'value\' }"',
121
+ PdfView: '',
122
+ PdfPrint: '',
123
+ SplitterPanel: '',
124
+ TabsGroup: ':items="[{ label: \'Tab 1\' }, { label: \'Tab 2\' }]"',
125
+ ImportCSV: '',
126
+ ExportCSV: ':model-value="[]" file-name="demo"',
127
+ FileBtn: 'label="Upload"',
128
+ BarcodeReader: '',
129
+ MrzReader: '',
130
+ TextBarcode: ':model-value="\'ABC123\'"',
131
+ Alert: '',
132
+ DeviceIdCardButton: '',
133
+ DeviceIdCardWebSocket: '',
134
+ DeviceScanner: '',
135
+ FormLogin: '',
136
+ FormPad: '',
137
+ FormBirthdate: '',
138
+ FormCheckboxGroup: ':items="[{ title: \'One\', value: 1 }, { title: \'Two\', value: 2 }]"',
139
+ FormCodeEditor: 'language="javascript"',
140
+ FormDate: '',
141
+ FormDateTime: '',
142
+ FormTime: '',
143
+ FormFile: '',
144
+ FormHidden: '',
145
+ FormIterator: '',
146
+ FormSignPad: '',
147
+ }
148
+
149
+ function inferStubProps(componentName) {
150
+ return stubProps[componentName] ?? ''
151
+ }
152
+
153
+ /** Per-component <script setup> snippets injected after the SCAFFOLD comment. */
154
+ const setupForComponent = {
155
+ LabelDateAgo: `import { DateTime } from 'luxon'\n`,
156
+ LabelDateCount: `import { DateTime } from 'luxon'\n`,
157
+ }
158
+
159
+ function shouldOverwrite(filePath) {
160
+ if (!fs.existsSync(filePath)) return true
161
+ const content = fs.readFileSync(filePath, 'utf8')
162
+ return content.includes(SCAFFOLD_MARKER)
163
+ }
164
+
165
+ const entries = []
166
+ let written = 0
167
+ let skipped = 0
168
+
169
+ for (const component of aiSummary.components) {
170
+ const { componentName, slug, title, group } = deriveNames(component.path)
171
+ const summary = (component.summary || '').replace(/"/g, '\'')
172
+ const sourcePath = component.path
173
+
174
+ const subdir = slug.includes('-') ? '' : '' // flat layout under components/
175
+ const fileName = `${slug}.vue`
176
+ const filePath = path.join(pagesDir, subdir, fileName)
177
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
178
+
179
+ if (shouldOverwrite(filePath)) {
180
+ fs.writeFileSync(filePath, renderPage({ componentName, title, summary, sourcePath }))
181
+ written++
182
+ }
183
+ else {
184
+ skipped++
185
+ }
186
+
187
+ entries.push({
188
+ slug,
189
+ title,
190
+ summary,
191
+ to: `/components/${slug}`,
192
+ group,
193
+ })
194
+ }
195
+
196
+ entries.sort((a, b) => a.slug.localeCompare(b.slug))
197
+
198
+ const registrySource = `// Auto-generated by scripts/scaffold-playground-pages.mjs.
199
+ // Do not edit by hand — re-run the script after adding components.
200
+ import type { PlaygroundEntry } from './playgrounds'
201
+
202
+ export const componentEntries: PlaygroundEntry[] = ${JSON.stringify(entries, null, 2)}
203
+ `
204
+ fs.writeFileSync(registryFile, registrySource)
205
+
206
+ console.log(`Pages scaffolded: ${written} written, ${skipped} preserved (hand-edited).`)
207
+ console.log(`Registry: ${path.relative(rootDir, registryFile)} (${entries.length} entries)`)