@merkaly/nuxt 0.10.0 → 0.10.2

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
@@ -35,6 +35,31 @@ On boot the module restores an existing session (user and token) and then runs t
35
35
  | store | optional | required | `merkaly:tenant` resolves the store from the host (public); a signed-in buyer is restored too; protected pages (account, checkout) ask for login. |
36
36
  | www | optional | not used | No hooks; anonymous or signed-in. |
37
37
 
38
+ ## SEO: robots and sitemap
39
+
40
+ Every app gets `@nuxtjs/robots`. Apps are **private by default**: in production
41
+ `robots.txt` answers `Disallow: /` and pages carry the noindex meta and the
42
+ `X-Robots-Tag` header (admin, dashboard). Outside production nothing is indexable.
43
+
44
+ Public apps (www, store) opt in, and can also get `@nuxtjs/sitemap`, with
45
+ `hreflang` built from `merkaly.i18n` when the strategy has a locale prefix:
46
+
47
+ ```ts
48
+ export default defineNuxtConfig({
49
+ merkaly: {
50
+ seo: {
51
+ indexable: true,
52
+ url: 'https://www.merkaly.io',
53
+ disallow: ['/en/auth', '/es/auth', '/pt/auth'], // robots.txt
54
+ sitemap: { exclude: ['/en/auth', '/es/auth', '/pt/auth'] }, // or `true`
55
+ },
56
+ },
57
+ });
58
+ ```
59
+
60
+ The sitemap is only installed for indexable apps and is served at `/sitemap.xml`
61
+ (linked from `robots.txt`).
62
+
38
63
  ## Money formatting
39
64
 
40
65
  Money in Merkaly is stored as **integer cents**. `MKFormatMoney`, `MKInputMoney`,
package/dist/module.d.mts CHANGED
@@ -57,6 +57,18 @@ interface MerkalyModuleOptions {
57
57
  project: string;
58
58
  token: string;
59
59
  };
60
+ seo?: {
61
+ /** robots.txt paths to block, e.g. the Auth0 callback. */
62
+ disallow?: string[];
63
+ /** Private apps (admin, dashboard) stay false: robots.txt Disallow, noindex meta and X-Robots-Tag. */
64
+ indexable?: boolean;
65
+ /** Generate /sitemap.xml from the pages, with hreflang from the i18n locales. */
66
+ sitemap?: boolean | {
67
+ exclude?: string[];
68
+ };
69
+ /** Public site URL, used in robots.txt and the sitemap. */
70
+ url?: string;
71
+ };
60
72
  }
61
73
  declare const _default: _nuxt_schema.NuxtModule<MerkalyModuleOptions, MerkalyModuleOptions, false>;
62
74
 
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.14.0"
6
6
  },
7
- "version": "0.10.0",
7
+ "version": "0.10.2",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { defineNuxtModule, useLogger, createResolver, addPlugin, addServerHandler, addRouteMiddleware, addImportsDir, addComponentsDir, addTypeTemplate, addTemplate } from '@nuxt/kit';
1
+ import { defineNuxtModule, useLogger, createResolver, installModule, addPlugin, addServerHandler, addRouteMiddleware, addImportsDir, addComponentsDir, addTypeTemplate, addTemplate } from '@nuxt/kit';
2
2
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { resolve, dirname } from 'node:path';
4
4
  import { defu } from 'defu';
@@ -42,6 +42,13 @@ const defaultOptions = {
42
42
  dsn: "",
43
43
  project: "",
44
44
  token: ""
45
+ },
46
+ seo: {
47
+ disallow: [],
48
+ indexable: false,
49
+ // private unless the app opts in
50
+ sitemap: false,
51
+ url: ""
45
52
  }
46
53
  };
47
54
  function hasI18nConfig(options) {
@@ -76,6 +83,10 @@ function buildModuleDependencies(nuxt, options) {
76
83
  if (hasPlausibleConfig(options)) {
77
84
  dependencies["@nuxtjs/plausible"] = {};
78
85
  }
86
+ configureSite(nuxt, options);
87
+ dependencies["@nuxtjs/robots"] = {
88
+ overrides: { disallow: options.seo?.disallow ?? [] }
89
+ };
79
90
  const logger = useLogger(MODULE_NAME);
80
91
  Object.keys(dependencies).forEach((dependency) => {
81
92
  logger.info(`Loaded ${dependency} module`);
@@ -126,6 +137,33 @@ function configureI18n(nuxt, options) {
126
137
  vueI18n
127
138
  };
128
139
  }
140
+ function configureSite(nuxt, options) {
141
+ nuxt.options.site = defu({
142
+ indexable: Boolean(options.seo?.indexable),
143
+ url: options.seo?.url || void 0
144
+ }, nuxt.options.site || {});
145
+ }
146
+ function configureSitemap(options) {
147
+ const exclude = typeof options.seo?.sitemap === "object" ? options.seo.sitemap.exclude ?? [] : [];
148
+ const prefixed = hasI18nConfig(options) && options.i18n.strategy && options.i18n.strategy !== "no_prefix";
149
+ if (!prefixed) {
150
+ return { exclude };
151
+ }
152
+ return {
153
+ autoI18n: {
154
+ // locales are registered through a hook, so the sitemap module can't read them on its own
155
+ defaultLocale: options.i18n.defaultLocale,
156
+ locales: options.i18n.locales.map((locale) => ({
157
+ _hreflang: locale.language,
158
+ _sitemap: locale.language,
159
+ code: locale.code,
160
+ language: locale.language
161
+ })),
162
+ strategy: options.i18n.strategy
163
+ },
164
+ exclude
165
+ };
166
+ }
129
167
  function configureRuntimeConfig(nuxt, options) {
130
168
  nuxt.options.runtimeConfig.merkaly = defu({
131
169
  sentry: {
@@ -278,6 +316,10 @@ const module$1 = defineNuxtModule({
278
316
  const resolver = createResolver(import.meta.url);
279
317
  configureNuxtOptions(nuxt, options);
280
318
  await configureExternalIntegrations(nuxt, logger);
319
+ if (options.seo?.indexable && options.seo.sitemap) {
320
+ await installModule("@nuxtjs/sitemap", configureSitemap(options));
321
+ logger.info("Loaded @nuxtjs/sitemap module");
322
+ }
281
323
  registerRuntimeFeatures(options, resolver);
282
324
  }
283
325
  });
@@ -1,4 +1,16 @@
1
- declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
1
+ export interface InputDropzoneRejection {
2
+ file: File;
3
+ reason: 'type' | 'size';
4
+ }
5
+ declare function openDialog(): void;
6
+ declare var __VLS_1: {
7
+ dragging: boolean;
8
+ open: typeof openDialog;
9
+ };
10
+ type __VLS_Slots = {} & {
11
+ default?: (props: typeof __VLS_1) => any;
12
+ };
13
+ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
2
14
  accept: {
3
15
  type: {
4
16
  (arrayLength: number): string[];
@@ -18,15 +30,31 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
18
30
  };
19
31
  default: () => never[];
20
32
  };
33
+ disabled: {
34
+ type: BooleanConstructor;
35
+ default: boolean;
36
+ };
37
+ hint: {
38
+ type: StringConstructor;
39
+ default: undefined;
40
+ };
41
+ maxSize: {
42
+ type: NumberConstructor;
43
+ default: number;
44
+ };
21
45
  multiple: {
22
46
  type: BooleanConstructor;
23
47
  default: boolean;
24
48
  };
25
- modelValue: {
26
- type: import("vue").PropType<unknown[]>;
49
+ title: {
50
+ type: StringConstructor;
51
+ default: undefined;
27
52
  };
28
- }>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
29
- "update:modelValue": (value: unknown[]) => any;
53
+ }>, {
54
+ open: typeof openDialog;
55
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
56
+ select: (files: File[]) => any;
57
+ reject: (rejections: InputDropzoneRejection[]) => any;
30
58
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
31
59
  accept: {
32
60
  type: {
@@ -47,18 +75,42 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
47
75
  };
48
76
  default: () => never[];
49
77
  };
78
+ disabled: {
79
+ type: BooleanConstructor;
80
+ default: boolean;
81
+ };
82
+ hint: {
83
+ type: StringConstructor;
84
+ default: undefined;
85
+ };
86
+ maxSize: {
87
+ type: NumberConstructor;
88
+ default: number;
89
+ };
50
90
  multiple: {
51
91
  type: BooleanConstructor;
52
92
  default: boolean;
53
93
  };
54
- modelValue: {
55
- type: import("vue").PropType<unknown[]>;
94
+ title: {
95
+ type: StringConstructor;
96
+ default: undefined;
56
97
  };
57
98
  }>> & Readonly<{
58
- "onUpdate:modelValue"?: ((value: unknown[]) => any) | undefined;
99
+ onSelect?: ((files: File[]) => any) | undefined;
100
+ onReject?: ((rejections: InputDropzoneRejection[]) => any) | undefined;
59
101
  }>, {
102
+ title: string;
103
+ disabled: boolean;
60
104
  accept: string[];
105
+ hint: string;
106
+ maxSize: number;
61
107
  multiple: boolean;
62
108
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
109
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
63
110
  declare const _default: typeof __VLS_export;
64
111
  export default _default;
112
+ type __VLS_WithSlots<T, S> = T & {
113
+ new (): {
114
+ $slots: S;
115
+ };
116
+ };
@@ -1,80 +1,82 @@
1
1
  <script setup>
2
+ import { useDropZone, useFileDialog } from "@vueuse/core";
2
3
  import { filesize } from "filesize";
4
+ import { computed, useTemplateRef } from "vue";
5
+ import { useI18n } from "vue-i18n";
3
6
  const props = defineProps({
4
7
  accept: { type: Array, default: () => [] },
5
- multiple: { type: Boolean, default: false }
8
+ // MIME types, e.g. image/png; empty accepts anything
9
+ disabled: { type: Boolean, default: false },
10
+ hint: { type: String, default: void 0 },
11
+ maxSize: { type: Number, default: 0 },
12
+ // bytes; 0 means no limit
13
+ multiple: { type: Boolean, default: false },
14
+ title: { type: String, default: void 0 }
6
15
  });
7
- const dropZoneRef = ref();
8
- const fileInputRef = ref();
9
- const pictures = defineModel({ type: Array, default: () => [] });
10
- const { isOverDropZone } = useDropZone(dropZoneRef, {
11
- dataTypes: props.accept,
16
+ const emit = defineEmits(["reject", "select"]);
17
+ const { t } = useI18n({ useScope: "local" });
18
+ const dropZone = useTemplateRef("dropZone");
19
+ const titleText = computed(() => props.title ?? t(props.multiple ? "titleMany" : "title"));
20
+ const hintText = computed(() => props.hint ?? (props.maxSize ? t("hint", { size: filesize(props.maxSize) }) : ""));
21
+ const { open, onChange } = useFileDialog({ accept: props.accept.join(","), multiple: props.multiple, reset: true });
22
+ onChange((files) => pick(files ? [...files] : []));
23
+ const { isOverDropZone } = useDropZone(dropZone, {
12
24
  multiple: props.multiple,
13
- onDrop: handleFileChange,
14
- preventDefaultForUnhandled: false
25
+ onDrop: (files) => pick(files ?? []),
26
+ preventDefaultForUnhandled: true
15
27
  });
16
- function removePicture(id) {
17
- pictures.value = pictures.value.filter((picture) => picture._id !== id);
28
+ function reasonOf(file) {
29
+ if (props.accept.length && !props.accept.includes(file.type)) return "type";
30
+ if (props.maxSize && file.size > props.maxSize) return "size";
18
31
  }
19
- function triggerInput() {
20
- fileInputRef.value?.click();
32
+ function pick(files) {
33
+ if (props.disabled || !files.length) return;
34
+ const checked = (props.multiple ? files : files.slice(0, 1)).map((file) => ({ file, reason: reasonOf(file) }));
35
+ const rejections = checked.filter((it) => Boolean(it.reason));
36
+ const accepted = checked.filter((it) => !it.reason).map((it) => it.file);
37
+ if (rejections.length) emit("reject", rejections);
38
+ if (accepted.length) emit("select", accepted);
21
39
  }
22
- function handleFileChange(files) {
23
- if (!files.length) return;
24
- const images = [...files].map((it) => ({
25
- alt: it.name,
26
- id: crypto.randomUUID(),
27
- name: it.name,
28
- size: it.size,
29
- url: URL.createObjectURL(it)
30
- }));
31
- return pictures.value.push(...images);
40
+ function openDialog() {
41
+ if (!props.disabled) open();
32
42
  }
43
+ defineExpose({ open: openDialog });
33
44
  </script>
34
45
 
35
46
  <template>
36
- <div ref="dropZoneRef" :class="{ 'dz-drag-hover': isOverDropZone }" class="dropzone dz-clickable text-start">
37
- <input
38
- ref="fileInputRef"
39
- :accept="props.accept.join(',')"
40
- :multiple="props.multiple"
41
- class="d-none"
42
- type="file"
43
- @change="(e) => handleFileChange(e.target.files)"
44
- >
47
+ <div
48
+ ref="dropZone"
49
+ :class="{ 'dz-drag-hover': isOverDropZone, 'opacity-50 pe-none': props.disabled }"
50
+ class="dropzone dz-clickable text-start"
51
+ @click="openDialog()">
52
+ <slot :dragging="isOverDropZone" :open="openDialog">
53
+ <div class="dz-message needsclick">
54
+ <i class="ki-duotone ki-file-up text-primary fs-3x">
55
+ <span class="path1" />
56
+ <span class="path2" />
57
+ </i>
45
58
 
46
- <div class="dz-message needsclick" @click="triggerInput">
47
- <i class="ki-duotone ki-file-up text-primary fs-3x">
48
- <span class="path1" />
49
- <span class="path2" />
50
- </i>
59
+ <div class="ms-4">
60
+ <h3 class="fs-5 fw-bold text-gray-900 mb-1" v-text="titleText" />
51
61
 
52
- <div class="ms-4">
53
- <h3 class="fs-5 fw-bold text-gray-900 mb-1">Drop files here or click to upload.</h3>
54
- <span class="fs-7 fw-semibold text-gray-400">Upload up to 10 files</span>
55
- </div>
56
- </div>
57
-
58
- <template v-for="picture in pictures" :key="picture._id">
59
- <div class="dz-preview border dz-image-preview">
60
- <div class="dz-image">
61
- <BImg :alt="picture.alt" :src="picture.url" class="w-100" />
62
- </div>
63
-
64
- <div class="dz-details">
65
- <div class="dz-size">
66
- <span v-text="filesize(picture.size)" />
67
- </div>
68
-
69
- <div class="dz-filename">
70
- <span v-text="picture.name" />
71
- </div>
62
+ <span v-if="hintText" class="fs-7 fw-semibold text-gray-500" v-text="hintText" />
72
63
  </div>
73
-
74
- <span class="dz-remove" @click="removePicture(picture._id)">
75
- <span>Remove file</span>
76
- </span>
77
64
  </div>
78
- </template>
65
+ </slot>
79
66
  </div>
80
67
  </template>
68
+
69
+ <i18n lang="json">
70
+ {
71
+ "en": {
72
+ "hint": "Up to {size}",
73
+ "title": "Drop a file here or click to choose",
74
+ "titleMany": "Drop files here or click to choose"
75
+ },
76
+ "pt": {
77
+ "hint": "Até {size}",
78
+ "title": "Solte um arquivo aqui ou clique para escolher",
79
+ "titleMany": "Solte os arquivos aqui ou clique para escolher"
80
+ }
81
+ }
82
+ </i18n>
@@ -1,4 +1,16 @@
1
- declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
1
+ export interface InputDropzoneRejection {
2
+ file: File;
3
+ reason: 'type' | 'size';
4
+ }
5
+ declare function openDialog(): void;
6
+ declare var __VLS_1: {
7
+ dragging: boolean;
8
+ open: typeof openDialog;
9
+ };
10
+ type __VLS_Slots = {} & {
11
+ default?: (props: typeof __VLS_1) => any;
12
+ };
13
+ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
2
14
  accept: {
3
15
  type: {
4
16
  (arrayLength: number): string[];
@@ -18,15 +30,31 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
18
30
  };
19
31
  default: () => never[];
20
32
  };
33
+ disabled: {
34
+ type: BooleanConstructor;
35
+ default: boolean;
36
+ };
37
+ hint: {
38
+ type: StringConstructor;
39
+ default: undefined;
40
+ };
41
+ maxSize: {
42
+ type: NumberConstructor;
43
+ default: number;
44
+ };
21
45
  multiple: {
22
46
  type: BooleanConstructor;
23
47
  default: boolean;
24
48
  };
25
- modelValue: {
26
- type: import("vue").PropType<unknown[]>;
49
+ title: {
50
+ type: StringConstructor;
51
+ default: undefined;
27
52
  };
28
- }>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
29
- "update:modelValue": (value: unknown[]) => any;
53
+ }>, {
54
+ open: typeof openDialog;
55
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
56
+ select: (files: File[]) => any;
57
+ reject: (rejections: InputDropzoneRejection[]) => any;
30
58
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
31
59
  accept: {
32
60
  type: {
@@ -47,18 +75,42 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
47
75
  };
48
76
  default: () => never[];
49
77
  };
78
+ disabled: {
79
+ type: BooleanConstructor;
80
+ default: boolean;
81
+ };
82
+ hint: {
83
+ type: StringConstructor;
84
+ default: undefined;
85
+ };
86
+ maxSize: {
87
+ type: NumberConstructor;
88
+ default: number;
89
+ };
50
90
  multiple: {
51
91
  type: BooleanConstructor;
52
92
  default: boolean;
53
93
  };
54
- modelValue: {
55
- type: import("vue").PropType<unknown[]>;
94
+ title: {
95
+ type: StringConstructor;
96
+ default: undefined;
56
97
  };
57
98
  }>> & Readonly<{
58
- "onUpdate:modelValue"?: ((value: unknown[]) => any) | undefined;
99
+ onSelect?: ((files: File[]) => any) | undefined;
100
+ onReject?: ((rejections: InputDropzoneRejection[]) => any) | undefined;
59
101
  }>, {
102
+ title: string;
103
+ disabled: boolean;
60
104
  accept: string[];
105
+ hint: string;
106
+ maxSize: number;
61
107
  multiple: boolean;
62
108
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
109
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
63
110
  declare const _default: typeof __VLS_export;
64
111
  export default _default;
112
+ type __VLS_WithSlots<T, S> = T & {
113
+ new (): {
114
+ $slots: S;
115
+ };
116
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merkaly/nuxt",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/merkaly-io/nuxt.git"
@@ -28,7 +28,7 @@
28
28
  "dist"
29
29
  ],
30
30
  "dependencies": {
31
- "@auth0/auth0-spa-js": "^2.29.0",
31
+ "@auth0/auth0-spa-js": "^2.30.0",
32
32
  "@bootstrap-vue-next/nuxt": "^1.0.0",
33
33
  "@nuxt/devtools": "^4.0.0",
34
34
  "@nuxt/eslint": "1.17.0",
@@ -38,7 +38,9 @@
38
38
  "@nuxt/kit": "^4.6.0",
39
39
  "@nuxtjs/i18n": "^10.6.0",
40
40
  "@nuxtjs/plausible": "^4.0.0",
41
- "@sentry/nuxt": "^11.9.0",
41
+ "@nuxtjs/robots": "^7.0.2",
42
+ "@nuxtjs/sitemap": "^9.1.0",
43
+ "@sentry/nuxt": "^11.10.0",
42
44
  "@types/node": "latest",
43
45
  "@types/vue-select": "^3.16.8",
44
46
  "@vueuse/components": "^15.0.0",
@@ -50,13 +52,13 @@
50
52
  "change-case": "^5.4.4",
51
53
  "class-transformer": "^0.5.1",
52
54
  "class-validator": "^0.15.1",
53
- "eslint": "^10.13.0",
55
+ "eslint": "^10.14.0",
54
56
  "filesize": "^11.0.17",
55
57
  "gsap": "^3.15.0",
56
58
  "notivue": "^2.4.5",
57
59
  "nuxt": "^4.6.3",
58
60
  "reflect-metadata": "^0.2.2",
59
- "sass": "^1.106.1",
61
+ "sass": "^1.107.1",
60
62
  "typescript": "~5.9.3",
61
63
  "v-money3": "^3.26.1",
62
64
  "vite-svg-loader": "^5.1.1",