@ramathibodi/nuxt-commons 4.0.11 → 4.0.13

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 (52) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/components/dialog/ImportProgress.d.vue.ts +35 -0
  3. package/dist/runtime/components/dialog/ImportProgress.vue +53 -0
  4. package/dist/runtime/components/dialog/ImportProgress.vue.d.ts +35 -0
  5. package/dist/runtime/components/document/TemplateBuilder.d.vue.ts +2 -2
  6. package/dist/runtime/components/document/TemplateBuilder.vue +113 -8
  7. package/dist/runtime/components/document/TemplateBuilder.vue.d.ts +2 -2
  8. package/dist/runtime/components/form/ActionPad.vue +1 -0
  9. package/dist/runtime/components/form/Birthdate.d.vue.ts +3 -3
  10. package/dist/runtime/components/form/Birthdate.vue.d.ts +3 -3
  11. package/dist/runtime/components/form/Date.vue +11 -6
  12. package/dist/runtime/components/form/Dialog.d.vue.ts +1 -5
  13. package/dist/runtime/components/form/Dialog.vue +1 -0
  14. package/dist/runtime/components/form/Dialog.vue.d.ts +1 -5
  15. package/dist/runtime/components/form/EditPad.vue +1 -0
  16. package/dist/runtime/components/form/Pad.d.vue.ts +24 -0
  17. package/dist/runtime/components/form/Pad.vue +11 -6
  18. package/dist/runtime/components/form/Pad.vue.d.ts +24 -0
  19. package/dist/runtime/components/form/Time.vue +10 -5
  20. package/dist/runtime/components/form/images/Edit.d.vue.ts +1 -3
  21. package/dist/runtime/components/form/images/Edit.vue.d.ts +1 -3
  22. package/dist/runtime/components/model/AutoRefreshChip.d.vue.ts +16 -0
  23. package/dist/runtime/components/model/AutoRefreshChip.vue +34 -0
  24. package/dist/runtime/components/model/AutoRefreshChip.vue.d.ts +16 -0
  25. package/dist/runtime/components/model/Pad.vue +2 -1
  26. package/dist/runtime/components/model/Table.d.vue.ts +158 -61
  27. package/dist/runtime/components/model/Table.vue +129 -7
  28. package/dist/runtime/components/model/Table.vue.d.ts +158 -61
  29. package/dist/runtime/components/model/iterator.d.vue.ts +198 -78
  30. package/dist/runtime/components/model/iterator.vue +140 -9
  31. package/dist/runtime/components/model/iterator.vue.d.ts +198 -78
  32. package/dist/runtime/composables/apiModel.d.ts +22 -3
  33. package/dist/runtime/composables/apiModel.js +27 -19
  34. package/dist/runtime/composables/autoRefresh.d.ts +42 -0
  35. package/dist/runtime/composables/autoRefresh.js +57 -0
  36. package/dist/runtime/composables/document/template.d.ts +61 -0
  37. package/dist/runtime/composables/document/template.js +60 -1
  38. package/dist/runtime/composables/document/validateTemplate.d.ts +62 -0
  39. package/dist/runtime/composables/document/validateTemplate.js +378 -0
  40. package/dist/runtime/composables/graphqlModel.d.ts +22 -3
  41. package/dist/runtime/composables/graphqlModel.js +27 -19
  42. package/dist/runtime/composables/graphqlModelOperation.d.ts +1 -0
  43. package/dist/runtime/composables/importProgress.d.ts +34 -0
  44. package/dist/runtime/composables/importProgress.js +50 -0
  45. package/dist/runtime/composables/modelAutoRefresh.d.ts +29 -0
  46. package/dist/runtime/composables/modelAutoRefresh.js +16 -0
  47. package/dist/runtime/composables/utils/validation.d.ts +4 -0
  48. package/dist/runtime/composables/utils/validation.js +2 -0
  49. package/dist/runtime/utils/virtualize.d.ts +15 -0
  50. package/dist/runtime/utils/virtualize.js +10 -0
  51. package/package.json +3 -2
  52. package/scripts/validate-document-template.mjs +158 -0
@@ -3,7 +3,7 @@ import { watchDebounced } from "@vueuse/core";
3
3
  import { useAlert } from "./alert.js";
4
4
  import { buildRequiredInputFields } from "./graphqlOperation.js";
5
5
  import { useGraphqlModelOperation } from "./graphqlModelOperation.js";
6
- import pLimit from "p-limit";
6
+ import { useImportProgress } from "./importProgress.js";
7
7
  import { arrayWrap } from "../utils/array.js";
8
8
  export function useGraphqlModel(props) {
9
9
  const alert = useAlert();
@@ -15,6 +15,7 @@ export function useGraphqlModel(props) {
15
15
  search.value = keyword;
16
16
  }
17
17
  const isLoading = ref(false);
18
+ const importProgress = useImportProgress();
18
19
  const { operationCreate, operationUpdate, operationDelete, operationRead, operationReadPageable, operationSearch } = useGraphqlModelOperation(props);
19
20
  function keyToField(key) {
20
21
  const parts = key.split(".");
@@ -64,27 +65,33 @@ export function useGraphqlModel(props) {
64
65
  } else items.value.push(result);
65
66
  if (callback && callback.setData) callback.setData(result);
66
67
  }).catch((error) => {
68
+ if (importing) throw error;
67
69
  alert?.addAlert({ alertType: "error", message: error });
68
70
  }).finally(() => {
69
71
  if (!importing) isLoading.value = false;
70
72
  if (callback) callback.done();
71
73
  });
72
74
  }
73
- function importItems(importItems2, callback) {
75
+ function importItems(importData, callback) {
76
+ if (importProgress.isImporting.value) return;
77
+ if (importData.length === 0) {
78
+ if (callback) callback.done();
79
+ return;
80
+ }
74
81
  isLoading.value = true;
75
- const limit = pLimit(50);
76
- const importPromises = importItems2.map(
77
- (item) => limit(
78
- () => (item[props.modelKey || "id"] ? operationUpdate.value?.call(fields.value, { input: item }).then((result) => {
79
- if (!result) {
80
- return createItem(Object.assign({}, props.initialData, item), void 0, true);
81
- }
82
- }) : createItem(Object.assign({}, props.initialData, item), void 0, true)).catch((error) => {
83
- alert?.addAlert({ alertType: "error", message: error });
84
- })
85
- )
86
- );
87
- Promise.all(importPromises).finally(() => {
82
+ const worker = (item) => {
83
+ const createAsNew = () => createItem(Object.assign({}, props.initialData, item), void 0, true);
84
+ return item[props.modelKey || "id"] ? operationUpdate.value?.call(fields.value, { input: item }).then((result) => {
85
+ if (!result) return createAsNew();
86
+ }) : createAsNew();
87
+ };
88
+ importProgress.run(importData, worker, { concurrency: props.importConcurrency }).then(({ succeeded, failed }) => {
89
+ if (failed > 0) {
90
+ alert?.addAlert({ alertType: "warning", message: `\u0E19\u0E33\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 ${succeeded} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23, \u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27 ${failed} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23` });
91
+ } else {
92
+ alert?.addAlert({ alertType: "success", message: `\u0E19\u0E33\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 ${succeeded} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23` });
93
+ }
94
+ }).finally(() => {
88
95
  isLoading.value = false;
89
96
  reload();
90
97
  if (callback) callback.done();
@@ -127,7 +134,7 @@ export function useGraphqlModel(props) {
127
134
  sortBy: options.sortBy
128
135
  };
129
136
  isLoading.value = true;
130
- operationReadPageable.value?.call([{ data: fields.value }, "meta"], Object.assign({}, props.modelBy, { pageable: pageableVariable })).then((result) => {
137
+ return operationReadPageable.value?.call([{ data: fields.value }, "meta"], Object.assign({}, props.modelBy, { pageable: pageableVariable })).then((result) => {
131
138
  items.value = result.data;
132
139
  itemsLength.value = result.meta.totalItems;
133
140
  }).catch((error) => {
@@ -141,10 +148,10 @@ export function useGraphqlModel(props) {
141
148
  }
142
149
  function reload() {
143
150
  if (canServerPageable.value) {
144
- if (currentOptions.value) loadItems(currentOptions.value);
151
+ if (currentOptions.value) return loadItems(currentOptions.value);
145
152
  } else {
146
153
  isLoading.value = true;
147
- operationRead.value?.call(fields.value, props.modelBy).then((result) => {
154
+ return operationRead.value?.call(fields.value, props.modelBy).then((result) => {
148
155
  items.value = arrayWrap(result);
149
156
  }).catch((error) => {
150
157
  items.value = [];
@@ -184,6 +191,7 @@ export function useGraphqlModel(props) {
184
191
  deleteItem,
185
192
  loadItems,
186
193
  reload,
187
- isLoading
194
+ isLoading,
195
+ importProgress
188
196
  };
189
197
  }
@@ -10,6 +10,7 @@ export interface GraphqlModelConfigProps {
10
10
  operationReadPageable?: graphqlOperationObject<any, any> | string;
11
11
  operationSearch?: graphqlOperationObject<any, any> | string;
12
12
  fields?: Array<string | object>;
13
+ importConcurrency?: number;
13
14
  }
14
15
  export declare function useGraphqlModelOperation<T extends GraphqlModelConfigProps>(props: T): {
15
16
  operationCreate: import("vue").ComputedRef<graphqlOperationObject<any, any>>;
@@ -0,0 +1,34 @@
1
+ export interface ImportError {
2
+ index: number;
3
+ message: string;
4
+ }
5
+ export interface ImportSummary {
6
+ succeeded: number;
7
+ failed: number;
8
+ errors: ImportError[];
9
+ }
10
+ export type ImportWorker<T = any> = (item: T, index: number) => Promise<any>;
11
+ /**
12
+ * useImportProgress drives a concurrency-limited bulk import while exposing
13
+ * reactive progress. The runner continues on per-row errors and never rejects;
14
+ * callers read the resolved summary for the success/failure breakdown.
15
+ */
16
+ export declare function useImportProgress(): {
17
+ isImporting: import("vue").Ref<boolean, boolean>;
18
+ total: import("vue").Ref<number, number>;
19
+ processed: import("vue").Ref<number, number>;
20
+ succeeded: import("vue").Ref<number, number>;
21
+ failed: import("vue").Ref<number, number>;
22
+ errors: import("vue").Ref<{
23
+ index: number;
24
+ message: string;
25
+ }[], ImportError[] | {
26
+ index: number;
27
+ message: string;
28
+ }[]>;
29
+ percent: import("vue").ComputedRef<number>;
30
+ reset: () => void;
31
+ run: <T = any>(items: T[], worker: ImportWorker<T>, options?: {
32
+ concurrency?: number;
33
+ }) => Promise<ImportSummary>;
34
+ };
@@ -0,0 +1,50 @@
1
+ import { computed, ref } from "vue";
2
+ import pLimit from "p-limit";
3
+ export function useImportProgress() {
4
+ const isImporting = ref(false);
5
+ const total = ref(0);
6
+ const processed = ref(0);
7
+ const succeeded = ref(0);
8
+ const failed = ref(0);
9
+ const errors = ref([]);
10
+ const percent = computed(
11
+ () => total.value ? Math.round(processed.value / total.value * 100) : 0
12
+ );
13
+ function reset() {
14
+ total.value = 0;
15
+ processed.value = 0;
16
+ succeeded.value = 0;
17
+ failed.value = 0;
18
+ errors.value = [];
19
+ }
20
+ async function run(items, worker, options = {}) {
21
+ if (isImporting.value) {
22
+ return { succeeded: succeeded.value, failed: failed.value, errors: [...errors.value] };
23
+ }
24
+ reset();
25
+ total.value = items.length;
26
+ isImporting.value = true;
27
+ const limit = pLimit(options.concurrency && options.concurrency > 0 ? options.concurrency : 3);
28
+ try {
29
+ await Promise.all(
30
+ items.map(
31
+ (item, index) => limit(async () => {
32
+ try {
33
+ await worker(item, index);
34
+ succeeded.value++;
35
+ } catch (error) {
36
+ failed.value++;
37
+ errors.value.push({ index, message: error?.message || String(error) });
38
+ } finally {
39
+ processed.value++;
40
+ }
41
+ })
42
+ )
43
+ );
44
+ } finally {
45
+ isImporting.value = false;
46
+ }
47
+ return { succeeded: succeeded.value, failed: failed.value, errors: [...errors.value] };
48
+ }
49
+ return { isImporting, total, processed, succeeded, failed, errors, percent, reset, run };
50
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared auto-refresh wiring for `<ModelTable>` and `<ModelIterator>`.
3
+ *
4
+ * Both components expose the same `autoRefresh` / `autoRefreshDefault` / `autoRefreshControl`
5
+ * props and gate polling on the same signals (dialog open + in-flight loading). This helper
6
+ * keeps that contract in one place: pass the model's `reload`/`isLoading` and the dialog-open
7
+ * ref, get back the `useAutoRefresh` handle plus a `manualReload` that reloads and restarts
8
+ * the countdown (used by the toolbar refresh icon).
9
+ */
10
+ import { type Ref } from 'vue';
11
+ import { type UseAutoRefreshHandle } from './autoRefresh.js';
12
+ export interface ModelAutoRefreshProps {
13
+ autoRefresh?: number | boolean;
14
+ autoRefreshDefault?: number;
15
+ }
16
+ export interface UseModelAutoRefreshDeps {
17
+ /** The model composable's reload(). */
18
+ reload: () => void | Promise<void>;
19
+ /** The model composable's loading flag. */
20
+ isLoading: Ref<boolean>;
21
+ /** Component ref that is true while a create/edit dialog is open. */
22
+ isDialogOpen: Ref<boolean>;
23
+ }
24
+ export interface UseModelAutoRefreshHandle {
25
+ autoRefresh: UseAutoRefreshHandle;
26
+ /** Reload now and restart the countdown — wired to the toolbar refresh icon. */
27
+ manualReload: () => void;
28
+ }
29
+ export declare function useModelAutoRefresh(props: ModelAutoRefreshProps, deps: UseModelAutoRefreshDeps): UseModelAutoRefreshHandle;
@@ -0,0 +1,16 @@
1
+ import { computed } from "vue";
2
+ import { useAutoRefresh } from "./autoRefresh.js";
3
+ export function useModelAutoRefresh(props, deps) {
4
+ const autoRefresh = useAutoRefresh({
5
+ interval: () => props.autoRefresh,
6
+ defaultSeconds: props.autoRefreshDefault,
7
+ reload: deps.reload,
8
+ isLoading: deps.isLoading,
9
+ paused: computed(() => deps.isDialogOpen.value)
10
+ });
11
+ function manualReload() {
12
+ deps.reload();
13
+ autoRefresh.reset();
14
+ }
15
+ return { autoRefresh, manualReload };
16
+ }
@@ -3,6 +3,7 @@ export declare function useRules(): {
3
3
  requireIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
4
4
  requireTrue: (customError?: string) => (value: any) => string | true;
5
5
  requireTrueIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
6
+ requireNotEmpty: (customError?: string) => (value: any) => string | true;
6
7
  numeric: (customError?: string) => (value: any) => string | true;
7
8
  range: (minValue: number, maxValue: number, customError?: string) => (value: any) => string | true;
8
9
  integer: (customError?: string) => (value: any) => string | true;
@@ -31,6 +32,7 @@ export declare function useRules(): {
31
32
  requireIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
32
33
  requireTrue: (customError?: string) => (value: any) => string | true;
33
34
  requireTrueIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
35
+ requireNotEmpty: (customError?: string) => (value: any) => string | true;
34
36
  numeric: (customError?: string) => (value: any) => string | true;
35
37
  range: (minValue: number, maxValue: number, customError?: string) => (value: any) => string | true;
36
38
  integer: (customError?: string) => (value: any) => string | true;
@@ -59,6 +61,7 @@ export declare function useRules(): {
59
61
  requireIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
60
62
  requireTrue: (customError?: string) => (value: any) => string | true;
61
63
  requireTrueIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
64
+ requireNotEmpty: (customError?: string) => (value: any) => string | true;
62
65
  numeric: (customError?: string) => (value: any) => string | true;
63
66
  range: (minValue: number, maxValue: number, customError?: string) => (value: any) => string | true;
64
67
  integer: (customError?: string) => (value: any) => string | true;
@@ -87,6 +90,7 @@ export declare function useRules(): {
87
90
  requireIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
88
91
  requireTrue: (customError?: string) => (value: any) => string | true;
89
92
  requireTrueIf: (conditionIf: boolean, customError?: string) => (value: any) => string | true;
93
+ requireNotEmpty: (customError?: string) => (value: any) => string | true;
90
94
  numeric: (customError?: string) => (value: any) => string | true;
91
95
  range: (minValue: number, maxValue: number, customError?: string) => (value: any) => string | true;
92
96
  integer: (customError?: string) => (value: any) => string | true;
@@ -7,6 +7,7 @@ export function useRules() {
7
7
  const requireIf = (conditionIf, customError = "This field is required") => (value) => condition(!!value || value === false || value === 0 || !conditionIf, customError);
8
8
  const requireTrue = (customError = "This field must be true") => (value) => condition(!!value, customError);
9
9
  const requireTrueIf = (conditionIf, customError = "This field must be true") => (value) => condition(!!value || !conditionIf, customError);
10
+ const requireNotEmpty = (customError = "This field is required") => (value) => condition(Array.isArray(value) ? value.length > 0 : !!value || value === false || value === 0, customError);
10
11
  const numeric = (customError = "This field must be a number") => (value) => condition(!value || !isNaN(Number(value)), customError);
11
12
  const range = (minValue, maxValue, customError = `Value is out of range (${minValue}-${maxValue})`) => (value) => condition(!value || value >= minValue && value <= maxValue, customError);
12
13
  const integer = (customError = "This field must be an integer") => (value) => condition(!value || isInteger(value) || /^\+?-?\d+$/.test(value), customError);
@@ -35,6 +36,7 @@ export function useRules() {
35
36
  requireIf,
36
37
  requireTrue,
37
38
  requireTrueIf,
39
+ requireNotEmpty,
38
40
  numeric,
39
41
  range,
40
42
  integer,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pure helpers for the ModelTable/ModelIterator virtualization layer (#246).
3
+ */
4
+ /**
5
+ * Decide whether to render rows virtualized.
6
+ * @param count rendered-row count (items.length)
7
+ * @param override tri-state `virtual` prop: true/false force, undefined = auto
8
+ * @param threshold auto-virtualize above this count (default 500)
9
+ */
10
+ export declare function shouldVirtualize(count: number, override: boolean | undefined, threshold: number | undefined): boolean;
11
+ /**
12
+ * Cards-per-row for a Vuetify grid column span (out of 12).
13
+ * span 12 -> 1, 6 -> 2, 4 -> 3, 2 -> 6. Falsy / out-of-range -> 1 (full width).
14
+ */
15
+ export declare function itemsPerRowForSpan(span: number | string | boolean | undefined): number;
@@ -0,0 +1,10 @@
1
+ export function shouldVirtualize(count, override, threshold) {
2
+ if (override === true) return true;
3
+ if (override === false) return false;
4
+ return count > (threshold ?? 500);
5
+ }
6
+ export function itemsPerRowForSpan(span) {
7
+ const n = Number(span);
8
+ if (!Number.isFinite(n) || n <= 0 || n > 12) return 1;
9
+ return Math.max(1, Math.floor(12 / n));
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramathibodi/nuxt-commons",
3
- "version": "4.0.11",
3
+ "version": "4.0.13",
4
4
  "description": "Ramathibodi Nuxt modules for common components",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,6 +49,7 @@
49
49
  "dev:build": "nuxt build playground",
50
50
  "dev:generate": "nuxt generate playground",
51
51
  "playground:scaffold": "node scripts/scaffold-playground-pages.mjs",
52
+ "template:validate": "node scripts/validate-document-template.mjs",
52
53
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
53
54
  "docs:api:components": "vue-docgen -c docs/vue-docgen.config.cjs && npm run docs:ai:summary && node scripts/enrich-vue-docs-from-ai.mjs",
54
55
  "docs:api:composables": "typedoc --options docs/typedoc.json",
@@ -88,7 +89,7 @@
88
89
  "@techstark/opencv-js": "4.11.0-release.1",
89
90
  "@thumbmarkjs/thumbmarkjs": "^1.7.4",
90
91
  "@vue/apollo-composable": "^4.2.2",
91
- "@vuepic/vue-datepicker": "^7.4.1",
92
+ "@vuepic/vue-datepicker": "^12.1.0",
92
93
  "@vueuse/integrations": "^14.2.1",
93
94
  "@zxing/browser": "^0.1.5",
94
95
  "cropperjs": "^1.6.2",
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codegen-style validator for Document Template JSON.
4
+ *
5
+ * Usage:
6
+ * node scripts/validate-document-template.mjs [file|glob ...] [options]
7
+ * cat template.json | node scripts/validate-document-template.mjs
8
+ *
9
+ * Options:
10
+ * --strict Promote builder-unsafe warnings to errors (exit 1 on them too).
11
+ * --json Emit machine-readable JSON: [{ file, result }].
12
+ * --quiet Print errors only (suppress warnings and infos).
13
+ * -h, --help Show this help.
14
+ *
15
+ * Exit code: 1 if any input has errors (warnings count as errors under --strict),
16
+ * otherwise 0.
17
+ *
18
+ * Shares the exact validation core used by the `useDocumentTemplate` runtime via
19
+ * jiti (the TypeScript loader Nuxt already ships), so the CLI and the composable
20
+ * can never diverge.
21
+ */
22
+ import { readFileSync } from 'node:fs'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { dirname, resolve, relative } from 'node:path'
25
+ import process from 'node:process'
26
+ import { createJiti } from 'jiti'
27
+
28
+ const dir = dirname(fileURLToPath(import.meta.url))
29
+ const repoRoot = resolve(dir, '..')
30
+
31
+ const jiti = createJiti(import.meta.url)
32
+ const { validateDocumentTemplate } = await jiti.import(
33
+ resolve(repoRoot, 'src/runtime/composables/document/validateTemplate.ts'),
34
+ )
35
+
36
+ // --- arg parsing -----------------------------------------------------------
37
+ const argv = process.argv.slice(2)
38
+ const flags = new Set(argv.filter(a => a.startsWith('-')))
39
+ const files = argv.filter(a => !a.startsWith('-'))
40
+
41
+ if (flags.has('-h') || flags.has('--help')) {
42
+ printHelp()
43
+ process.exit(0)
44
+ }
45
+
46
+ const strict = flags.has('--strict')
47
+ const asJson = flags.has('--json')
48
+ const quiet = flags.has('--quiet')
49
+
50
+ // --- input collection ------------------------------------------------------
51
+ /** @type {{ file: string, content: string }[]} */
52
+ const inputs = []
53
+
54
+ if (files.length === 0) {
55
+ const stdin = readFileSync(0, 'utf8')
56
+ if (!stdin.trim()) {
57
+ console.error('No input: pass file paths/globs or pipe JSON via stdin.')
58
+ process.exit(2)
59
+ }
60
+ inputs.push({ file: '<stdin>', content: stdin })
61
+ }
62
+ else {
63
+ const resolved = await expandGlobs(files)
64
+ if (resolved.length === 0) {
65
+ console.error(`No files matched: ${files.join(', ')}`)
66
+ process.exit(2)
67
+ }
68
+ for (const f of resolved) {
69
+ try {
70
+ inputs.push({ file: f, content: readFileSync(f, 'utf8') })
71
+ }
72
+ catch (e) {
73
+ console.error(`Cannot read ${f}: ${e.message}`)
74
+ process.exit(2)
75
+ }
76
+ }
77
+ }
78
+
79
+ // --- validation ------------------------------------------------------------
80
+ const results = inputs.map(({ file, content }) => ({
81
+ file,
82
+ result: validateDocumentTemplate(content, { strict }),
83
+ }))
84
+
85
+ const hadErrors = results.some(r => !r.result.valid)
86
+
87
+ if (asJson) {
88
+ const rel = f => (f === '<stdin>' ? f : relative(repoRoot, resolve(f)))
89
+ console.log(JSON.stringify(results.map(r => ({ file: rel(r.file), result: r.result })), null, 2))
90
+ process.exit(hadErrors ? 1 : 0)
91
+ }
92
+
93
+ // --- human report ----------------------------------------------------------
94
+ let totalErrors = 0
95
+ let totalWarnings = 0
96
+ let totalInfos = 0
97
+
98
+ for (const { file, result } of results) {
99
+ const shown = quiet ? result.errors : result.issues
100
+ totalErrors += result.errors.length
101
+ totalWarnings += result.warnings.length
102
+ totalInfos += result.infos.length
103
+
104
+ const status = result.valid ? (result.warnings.length ? 'WARN' : 'OK') : 'FAIL'
105
+ console.log(`\n${status} ${file}`)
106
+ if (shown.length === 0) {
107
+ if (!quiet) console.log(' no issues')
108
+ continue
109
+ }
110
+ for (const issue of shown) {
111
+ const tag = issue.severity.toUpperCase().padEnd(7)
112
+ const loc = issue.path || '(root)'
113
+ console.log(` ${tag} ${loc} ${issue.code} ${issue.message}`)
114
+ }
115
+ }
116
+
117
+ console.log(
118
+ `\n${results.length} file(s): ${totalErrors} error(s), ${totalWarnings} warning(s), ${totalInfos} info(s)`
119
+ + (strict ? ' [strict]' : ''),
120
+ )
121
+
122
+ process.exit(hadErrors ? 1 : 0)
123
+
124
+ // --- helpers ---------------------------------------------------------------
125
+ async function expandGlobs(patterns) {
126
+ const out = []
127
+ for (const p of patterns) {
128
+ if (/[*?[\]{}]/.test(p)) {
129
+ try {
130
+ const { glob } = await import('node:fs/promises')
131
+ for await (const entry of glob(p)) out.push(entry)
132
+ }
133
+ catch {
134
+ console.error(`Globbing not supported in this Node version for "${p}"; pass explicit file paths.`)
135
+ process.exit(2)
136
+ }
137
+ }
138
+ else {
139
+ out.push(p)
140
+ }
141
+ }
142
+ // De-dupe while preserving order.
143
+ return [...new Set(out)]
144
+ }
145
+
146
+ function printHelp() {
147
+ console.log(`Validate Document Template JSON.
148
+
149
+ Usage:
150
+ node scripts/validate-document-template.mjs [file|glob ...] [options]
151
+ cat template.json | node scripts/validate-document-template.mjs
152
+
153
+ Options:
154
+ --strict Promote builder-unsafe warnings to errors.
155
+ --json Emit machine-readable JSON.
156
+ --quiet Print errors only.
157
+ -h, --help Show this help.`)
158
+ }