mktcms 0.3.17 → 0.3.19

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
@@ -28,6 +28,35 @@ This is my personal, minimalist alternative to @nuxt/content and Studio, which a
28
28
  npx nuxi module add mktcms
29
29
  ```
30
30
 
31
+ The module also applies a small set of app defaults that you can still override in your own `nuxt.config.ts`:
32
+
33
+ - `router.options.scrollBehaviorType = 'smooth'`
34
+ - default German `app.head` language, title, description, and favicon
35
+ - `mdc.headings.anchorLinks = false`
36
+ - includes and configures `@nuxtjs/robots` with admin route disallow rules
37
+ - includes and configures `@nuxt/fonts` with default font weights
38
+ - includes and configures `@nuxtjs/plausible` with `proxy = true` and `autoPageviews = false`
39
+ - default frontmatter schema for `Seiten/Startseite.md` and `Seiten/**/*.md`
40
+
41
+ Example override:
42
+
43
+ ```ts
44
+ export default defineNuxtConfig({
45
+ app: {
46
+ head: {
47
+ title: 'Meine Website',
48
+ },
49
+ },
50
+ mktcms: {
51
+ frontmatter: {
52
+ 'Seiten/**/*.md': {
53
+ seoTitle: { type: 'string', label: 'Eigener SEO-Titel' },
54
+ },
55
+ },
56
+ },
57
+ })
58
+ ```
59
+
31
60
  ```bash
32
61
  NUXT_PUBLIC_MKTCMS_SITE_URL="http://localhost:3000"
33
62
  NUXT_PUBLIC_MKTCMS_SHOW_VERSIONING=false
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mktcms",
3
3
  "configKey": "mktcms",
4
- "version": "0.3.17",
4
+ "version": "0.3.19",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,6 +1,100 @@
1
1
  import { defineNuxtModule, createResolver, addComponent, addImports, addServerImports, addServerPlugin, addServerHandler, extendPages } from '@nuxt/kit';
2
2
  import defu from 'defu';
3
3
 
4
+ const DEFAULT_HEAD_META = [
5
+ {
6
+ name: "description",
7
+ content: "Meine neue mktCMS Website"
8
+ }
9
+ ];
10
+ const DEFAULT_HEAD_LINK = [
11
+ {
12
+ rel: "icon",
13
+ type: "image/png",
14
+ href: "/favicon.png"
15
+ }
16
+ ];
17
+ const defaultFrontmatterSchema = {
18
+ seoTitle: {
19
+ type: "string",
20
+ label: "SEO-Titel"
21
+ }
22
+ };
23
+ const defaultFrontmatterSchemas = {
24
+ "Seiten/Startseite.md": defaultFrontmatterSchema,
25
+ "Seiten/**/*.md": defaultFrontmatterSchema
26
+ };
27
+ function mergeHeadMeta(meta) {
28
+ const nextMeta = Array.isArray(meta) ? [...meta] : [];
29
+ for (const defaultMeta of DEFAULT_HEAD_META) {
30
+ const hasMatch = nextMeta.some((item) => item?.name === defaultMeta.name);
31
+ if (!hasMatch) {
32
+ nextMeta.push(defaultMeta);
33
+ }
34
+ }
35
+ return nextMeta;
36
+ }
37
+ function mergeHeadLinks(links) {
38
+ const nextLinks = Array.isArray(links) ? [...links] : [];
39
+ for (const defaultLink of DEFAULT_HEAD_LINK) {
40
+ const hasMatch = nextLinks.some((item) => item?.rel === defaultLink.rel);
41
+ if (!hasMatch) {
42
+ nextLinks.push(defaultLink);
43
+ }
44
+ }
45
+ return nextLinks;
46
+ }
47
+ function applyMktcmsNuxtDefaults(nuxtOptions, moduleOptions = {}) {
48
+ nuxtOptions.router = defu(nuxtOptions.router, {
49
+ options: {
50
+ scrollBehaviorType: "smooth"
51
+ }
52
+ });
53
+ nuxtOptions.app = defu(nuxtOptions.app, {
54
+ head: {
55
+ htmlAttrs: {
56
+ lang: "de"
57
+ },
58
+ title: "Neue Website"
59
+ }
60
+ });
61
+ nuxtOptions.app.head = nuxtOptions.app.head || {};
62
+ nuxtOptions.app.head.meta = mergeHeadMeta(nuxtOptions.app.head.meta);
63
+ nuxtOptions.app.head.link = mergeHeadLinks(nuxtOptions.app.head.link);
64
+ nuxtOptions.runtimeConfig = defu(nuxtOptions.runtimeConfig, {
65
+ plausibleApiKey: ""
66
+ });
67
+ nuxtOptions.runtimeConfig.public = defu(nuxtOptions.runtimeConfig.public, {
68
+ plausibleApiHost: ""
69
+ });
70
+ nuxtOptions.runtimeConfig.mktcms = defu(nuxtOptions.runtimeConfig.mktcms, {
71
+ adminAuthKey: "",
72
+ authCookieMaxAgeSeconds: 7 * 24 * 60 * 60,
73
+ authCookiePath: "/",
74
+ authCookieSameSite: "lax",
75
+ authCookieSecure: process.env.NODE_ENV === "production",
76
+ loginRateLimitMaxAttempts: 5,
77
+ loginRateLimitWindowSeconds: 300,
78
+ loginRateLimitBlockSeconds: 600,
79
+ uploadMaxBytes: 50 * 1024 * 1024,
80
+ smtpHost: "",
81
+ smtpPort: 465,
82
+ smtpSecure: true,
83
+ smtpUser: "",
84
+ smtpPass: "",
85
+ mailerFrom: "",
86
+ mailerTo: "",
87
+ gitUser: "",
88
+ gitRepo: "",
89
+ gitToken: "",
90
+ frontmatter: defu(moduleOptions.frontmatter || {}, defaultFrontmatterSchemas)
91
+ });
92
+ nuxtOptions.runtimeConfig.public.mktcms = defu(nuxtOptions.runtimeConfig.public.mktcms, {
93
+ siteUrl: "",
94
+ showVersioning: false
95
+ });
96
+ }
97
+
4
98
  const module$1 = defineNuxtModule({
5
99
  meta: {
6
100
  name: "mktcms",
@@ -8,43 +102,38 @@ const module$1 = defineNuxtModule({
8
102
  },
9
103
  moduleDependencies: {
10
104
  "@nuxtjs/mdc": {
11
- version: "^0.20.0"
105
+ version: "^0.20.0",
106
+ defaults: {
107
+ headings: {
108
+ anchorLinks: false
109
+ }
110
+ }
111
+ },
112
+ "@nuxt/fonts": {
113
+ version: "^0.14.0",
114
+ defaults: {
115
+ defaults: {
116
+ weights: [300, 400, 700, 800]
117
+ }
118
+ }
119
+ },
120
+ "@nuxtjs/robots": {
121
+ version: "^6.0.6",
122
+ defaults: {
123
+ disallow: ["/api/admin/*", "/admin/*"]
124
+ }
125
+ },
126
+ "@nuxtjs/plausible": {
127
+ version: "^3.0.2",
128
+ defaults: {
129
+ proxy: true,
130
+ autoPageviews: false
131
+ }
12
132
  }
13
133
  },
14
134
  setup(_options, _nuxt) {
15
135
  const resolver = createResolver(import.meta.url);
16
- _nuxt.options.runtimeConfig = defu(_nuxt.options.runtimeConfig, {
17
- plausibleApiKey: ""
18
- });
19
- _nuxt.options.runtimeConfig.public = defu(_nuxt.options.runtimeConfig.public, {
20
- plausibleApiHost: ""
21
- });
22
- _nuxt.options.runtimeConfig.mktcms = defu((_nuxt.options.runtimeConfig.mktcms, {
23
- adminAuthKey: "",
24
- authCookieMaxAgeSeconds: 7 * 24 * 60 * 60,
25
- authCookiePath: "/",
26
- authCookieSameSite: "lax",
27
- authCookieSecure: process.env.NODE_ENV === "production",
28
- loginRateLimitMaxAttempts: 5,
29
- loginRateLimitWindowSeconds: 300,
30
- loginRateLimitBlockSeconds: 600,
31
- uploadMaxBytes: 50 * 1024 * 1024,
32
- smtpHost: "",
33
- smtpPort: 465,
34
- smtpSecure: true,
35
- smtpUser: "",
36
- smtpPass: "",
37
- mailerFrom: "",
38
- mailerTo: "",
39
- gitUser: "",
40
- gitRepo: "",
41
- gitToken: "",
42
- frontmatter: _options.frontmatter || {}
43
- }));
44
- _nuxt.options.runtimeConfig.public.mktcms = defu((_nuxt.options.runtimeConfig.public.mktcms, {
45
- siteUrl: "",
46
- showVersioning: false
47
- }));
136
+ applyMktcmsNuxtDefaults(_nuxt.options, _options);
48
137
  addComponent({
49
138
  name: "AdminWidget",
50
139
  filePath: resolver.resolve("runtime/app/components/frontend/widget.vue")
@@ -1,6 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  isOpen: boolean;
3
- uiHint: 'image' | 'pdf' | 'file';
3
+ uiHint: 'image' | 'pdf' | 'file' | 'media';
4
4
  };
5
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
6
6
  select: (path: string) => any;
@@ -1,6 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  isOpen: boolean;
3
- uiHint: 'image' | 'pdf' | 'file';
3
+ uiHint: 'image' | 'pdf' | 'file' | 'media';
4
4
  };
5
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
6
6
  select: (path: string) => any;
@@ -2,7 +2,7 @@ type __VLS_Props = {
2
2
  path: string;
3
3
  name: string;
4
4
  level: number;
5
- uiHint: 'image' | 'pdf' | 'file';
5
+ uiHint: 'image' | 'pdf' | 'file' | 'media';
6
6
  };
7
7
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
8
8
  select: (path: string) => any;
@@ -2,7 +2,7 @@ type __VLS_Props = {
2
2
  path: string;
3
3
  name: string;
4
4
  level: number;
5
- uiHint: 'image' | 'pdf' | 'file';
5
+ uiHint: 'image' | 'pdf' | 'file' | 'media';
6
6
  };
7
7
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
8
8
  select: (path: string) => any;
@@ -1,5 +1,8 @@
1
1
  <script setup>
2
2
  import { onBeforeUnmount, onMounted, ref, watch } from "vue";
3
+ import TurndownService from "turndown";
4
+ import FilePickerModal from "./frontmatter/filePicker/modal.vue";
5
+ import { isImagePath } from "../../../../shared/contentFiles";
3
6
  import "monaco-editor/min/vs/editor/editor.main.css";
4
7
  import "monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js";
5
8
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
@@ -11,9 +14,238 @@ const props = defineProps({
11
14
  });
12
15
  const emit = defineEmits(["update:modelValue"]);
13
16
  const rootEl = ref(null);
17
+ const isFilePickerOpen = ref(false);
18
+ const isContextMenuOpen = ref(false);
19
+ const contextMenuPosition = ref({ x: 0, y: 0 });
14
20
  let editor;
21
+ let pendingFileInsertionSelection;
15
22
  let resizeObserver;
16
23
  let suppressModelEmit = false;
24
+ const allowedPasteElementNames = /* @__PURE__ */ new Set([
25
+ "a",
26
+ "b",
27
+ "blockquote",
28
+ "br",
29
+ "em",
30
+ "h1",
31
+ "h2",
32
+ "h3",
33
+ "h4",
34
+ "h5",
35
+ "h6",
36
+ "i",
37
+ "li",
38
+ "ol",
39
+ "p",
40
+ "strong",
41
+ "ul"
42
+ ]);
43
+ const removablePasteElementNames = /* @__PURE__ */ new Set([
44
+ "applet",
45
+ "area",
46
+ "audio",
47
+ "button",
48
+ "canvas",
49
+ "caption",
50
+ "col",
51
+ "colgroup",
52
+ "embed",
53
+ "figcaption",
54
+ "figure",
55
+ "form",
56
+ "hr",
57
+ "iframe",
58
+ "img",
59
+ "input",
60
+ "link",
61
+ "map",
62
+ "math",
63
+ "meta",
64
+ "noscript",
65
+ "object",
66
+ "option",
67
+ "picture",
68
+ "script",
69
+ "select",
70
+ "source",
71
+ "style",
72
+ "svg",
73
+ "table",
74
+ "tbody",
75
+ "td",
76
+ "textarea",
77
+ "tfoot",
78
+ "th",
79
+ "thead",
80
+ "title",
81
+ "tr",
82
+ "track",
83
+ "video"
84
+ ]);
85
+ const turndownService = new TurndownService({
86
+ headingStyle: "atx",
87
+ codeBlockStyle: "fenced",
88
+ bulletListMarker: "-"
89
+ });
90
+ turndownService.addRule("removeUnsupportedPasteElements", {
91
+ filter: (node) => removablePasteElementNames.has(node.nodeName.toLowerCase()) || node.nodeName.includes(":"),
92
+ replacement: () => ""
93
+ });
94
+ function unwrapElement(element) {
95
+ const parent = element.parentNode;
96
+ if (!parent)
97
+ return;
98
+ while (element.firstChild)
99
+ parent.insertBefore(element.firstChild, element);
100
+ parent.removeChild(element);
101
+ }
102
+ function isSafeHref(href) {
103
+ if (!href.trim())
104
+ return false;
105
+ try {
106
+ const url = new URL(href, window.location.origin);
107
+ return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol);
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+ function sanitizePastedHtml(html) {
113
+ const clipboardDocument = new DOMParser().parseFromString(html, "text/html");
114
+ const comments = clipboardDocument.createTreeWalker(clipboardDocument.body, NodeFilter.SHOW_COMMENT);
115
+ const commentsToRemove = [];
116
+ while (comments.nextNode())
117
+ commentsToRemove.push(comments.currentNode);
118
+ for (const comment of commentsToRemove)
119
+ comment.remove();
120
+ const elements = Array.from(clipboardDocument.body.querySelectorAll("*"));
121
+ for (const element of elements) {
122
+ if (!element.isConnected)
123
+ continue;
124
+ const tagName = element.tagName.toLowerCase();
125
+ const inlineStyle = element.getAttribute("style") ?? "";
126
+ if (/display\s*:\s*none/i.test(inlineStyle) || /mso-hide\s*:\s*all/i.test(inlineStyle)) {
127
+ element.remove();
128
+ continue;
129
+ }
130
+ if (tagName.includes(":") || removablePasteElementNames.has(tagName)) {
131
+ element.remove();
132
+ continue;
133
+ }
134
+ if (tagName === "a") {
135
+ const href = element.getAttribute("href") ?? "";
136
+ if (!isSafeHref(href)) {
137
+ unwrapElement(element);
138
+ continue;
139
+ }
140
+ for (const attribute of Array.from(element.attributes)) {
141
+ if (attribute.name !== "href")
142
+ element.removeAttribute(attribute.name);
143
+ }
144
+ continue;
145
+ }
146
+ if (!allowedPasteElementNames.has(tagName)) {
147
+ unwrapElement(element);
148
+ continue;
149
+ }
150
+ for (const attribute of Array.from(element.attributes))
151
+ element.removeAttribute(attribute.name);
152
+ }
153
+ return clipboardDocument.body;
154
+ }
155
+ function insertMarkdown(markdown, selections = editor?.getSelections()) {
156
+ if (!editor || !selections?.length)
157
+ return;
158
+ editor.pushUndoStop();
159
+ editor.executeEdits("insert-markdown", selections.map((selection) => ({
160
+ range: selection,
161
+ text: markdown,
162
+ forceMoveMarkers: true
163
+ })));
164
+ editor.pushUndoStop();
165
+ editor.focus();
166
+ }
167
+ function escapeMarkdownLabel(label) {
168
+ return label.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
169
+ }
170
+ function filenameWithoutExtension(path) {
171
+ const filename = path.split(":").at(-1) ?? path;
172
+ return filename.replace(/\.[^/.]+$/, "");
173
+ }
174
+ function toContentUrl(path) {
175
+ return `/api/content/${encodeURIComponent(path)}`;
176
+ }
177
+ function toMarkdownFileReference(path) {
178
+ const label = escapeMarkdownLabel(filenameWithoutExtension(path));
179
+ const url = toContentUrl(path);
180
+ if (isImagePath(path))
181
+ return `![${label}](${url})`;
182
+ return `[${label}](${url})`;
183
+ }
184
+ function closeContextMenu() {
185
+ isContextMenuOpen.value = false;
186
+ }
187
+ function openFilePicker() {
188
+ closeContextMenu();
189
+ isFilePickerOpen.value = true;
190
+ }
191
+ function insertSelectedFile(path) {
192
+ insertMarkdown(toMarkdownFileReference(path), pendingFileInsertionSelection ? [pendingFileInsertionSelection] : void 0);
193
+ pendingFileInsertionSelection = void 0;
194
+ }
195
+ function getMouseTargetPosition(event) {
196
+ const target = editor?.getTargetAtClientPoint(event.clientX, event.clientY);
197
+ if (target && "position" in target && target.position)
198
+ return target.position;
199
+ return void 0;
200
+ }
201
+ function handleContextMenu(event) {
202
+ if (!editor || !rootEl.value?.contains(event.target))
203
+ return;
204
+ event.preventDefault();
205
+ event.stopPropagation();
206
+ const position = getMouseTargetPosition(event);
207
+ if (position) {
208
+ pendingFileInsertionSelection = new monaco.Selection(
209
+ position.lineNumber,
210
+ position.column,
211
+ position.lineNumber,
212
+ position.column
213
+ );
214
+ editor.setPosition(position);
215
+ } else {
216
+ pendingFileInsertionSelection = editor.getSelection() ?? void 0;
217
+ }
218
+ contextMenuPosition.value = {
219
+ x: event.clientX,
220
+ y: event.clientY
221
+ };
222
+ isContextMenuOpen.value = true;
223
+ }
224
+ function handleDocumentClick(event) {
225
+ if (!isContextMenuOpen.value)
226
+ return;
227
+ const target = event.target;
228
+ if (target.closest("[data-monaco-custom-context-menu]"))
229
+ return;
230
+ closeContextMenu();
231
+ }
232
+ function handleDocumentKeydown(event) {
233
+ if (event.key === "Escape")
234
+ closeContextMenu();
235
+ }
236
+ function handlePaste(event) {
237
+ if (!editor?.hasTextFocus())
238
+ return;
239
+ const html = event.clipboardData?.getData("text/html");
240
+ if (!html)
241
+ return;
242
+ const markdown = turndownService.turndown(sanitizePastedHtml(html)).trim();
243
+ if (!markdown)
244
+ return;
245
+ event.preventDefault();
246
+ event.stopPropagation();
247
+ insertMarkdown(markdown);
248
+ }
17
249
  function ensureMonacoWorkers() {
18
250
  const globalAny = globalThis;
19
251
  if (globalAny.MonacoEnvironment?.getWorker)
@@ -35,13 +267,18 @@ onMounted(() => {
35
267
  minimap: { enabled: false },
36
268
  scrollBeyondLastLine: false,
37
269
  wordWrap: "on",
38
- automaticLayout: true
270
+ automaticLayout: true,
271
+ contextmenu: false
39
272
  });
40
273
  editor.onDidChangeModelContent(() => {
41
274
  if (!editor || suppressModelEmit)
42
275
  return;
43
276
  emit("update:modelValue", editor.getValue());
44
277
  });
278
+ document.addEventListener("paste", handlePaste, true);
279
+ document.addEventListener("contextmenu", handleContextMenu, true);
280
+ document.addEventListener("click", handleDocumentClick, true);
281
+ document.addEventListener("keydown", handleDocumentKeydown, true);
45
282
  resizeObserver = new ResizeObserver(() => {
46
283
  editor?.layout();
47
284
  });
@@ -63,14 +300,47 @@ watch(() => props.modelValue, (nextValue) => {
63
300
  onBeforeUnmount(() => {
64
301
  resizeObserver?.disconnect();
65
302
  resizeObserver = void 0;
303
+ document.removeEventListener("paste", handlePaste, true);
304
+ document.removeEventListener("contextmenu", handleContextMenu, true);
305
+ document.removeEventListener("click", handleDocumentClick, true);
306
+ document.removeEventListener("keydown", handleDocumentKeydown, true);
66
307
  editor?.dispose();
67
308
  editor = void 0;
68
309
  });
69
310
  </script>
70
311
 
71
312
  <template>
72
- <div
73
- ref="rootEl"
74
- class="w-full"
75
- />
313
+ <div class="relative w-full h-full">
314
+ <div
315
+ ref="rootEl"
316
+ class="w-full h-full"
317
+ />
318
+
319
+ <div
320
+ v-if="isContextMenuOpen"
321
+ data-monaco-custom-context-menu
322
+ class="fixed z-9999 min-w-48 rounded-md border border-black/10 bg-white p-1 shadow-[0_8px_24px_rgba(0,0,0,0.18)]"
323
+ :style="{
324
+ left: `${contextMenuPosition.x}px`,
325
+ top: `${contextMenuPosition.y}px`
326
+ }"
327
+ role="menu"
328
+ >
329
+ <button
330
+ type="button"
331
+ class="w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
332
+ role="menuitem"
333
+ @click="openFilePicker"
334
+ >
335
+ Datei auswählen
336
+ </button>
337
+ </div>
338
+
339
+ <FilePickerModal
340
+ :is-open="isFilePickerOpen"
341
+ ui-hint="media"
342
+ @close="isFilePickerOpen = false"
343
+ @select="insertSelectedFile"
344
+ />
345
+ </div>
76
346
  </template>
@@ -8,7 +8,7 @@ function alphaSort(a, b) {
8
8
  }
9
9
  const querySchema = z.object({
10
10
  path: z.string().optional(),
11
- type: z.enum(["image", "pdf", "file"]).optional()
11
+ type: z.enum(["image", "pdf", "file", "media"]).optional()
12
12
  });
13
13
  export default defineEventHandler(async (event) => {
14
14
  const { path, type } = await getValidatedQuery(event, (query) => querySchema.parse(query));
@@ -23,6 +23,9 @@ export default defineEventHandler(async (event) => {
23
23
  if (type === "pdf") {
24
24
  return isPdfPath(key);
25
25
  }
26
+ if (type === "media") {
27
+ return isImagePath(key) || isPdfPath(key);
28
+ }
26
29
  return true;
27
30
  });
28
31
  const filteredFiles = matchingKeys.filter((key) => !key.includes(":"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mktcms",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Simple CMS module for Nuxt",
5
5
  "repository": "mktcode/mktcms",
6
6
  "license": "MIT",
@@ -38,8 +38,11 @@
38
38
  "css:watch": "tailwindcss -i ./src/runtime/app/styles/admin.css -o ./src/runtime/app/styles/admin.min.css --minify --watch"
39
39
  },
40
40
  "dependencies": {
41
+ "@nuxt/fonts": "^0.14.0",
41
42
  "@nuxt/kit": "^4.2.2",
42
43
  "@nuxtjs/mdc": "^0.20.1",
44
+ "@nuxtjs/plausible": "^3.0.2",
45
+ "@nuxtjs/robots": "^6.0.6",
43
46
  "@types/ejs": "^3.1.5",
44
47
  "@vueuse/core": "^14.2.1",
45
48
  "csv-parse": "^6.1.0",
@@ -52,6 +55,7 @@
52
55
  "nodemailer": "^7.0.13",
53
56
  "sharp": "^0.34.5",
54
57
  "simple-git": "^3.32.2",
58
+ "turndown": "^7.2.4",
55
59
  "unzipper": "^0.12.3",
56
60
  "yaml": "^2.8.2",
57
61
  "zod": "^4.3.6"
@@ -66,6 +70,7 @@
66
70
  "@tailwindcss/typography": "^0.5.19",
67
71
  "@types/node": "^25.3.0",
68
72
  "@types/nodemailer": "^7.0.11",
73
+ "@types/turndown": "^5.0.6",
69
74
  "@types/unzipper": "^0.10.11",
70
75
  "changelogen": "^0.6.2",
71
76
  "eslint": "^9.39.3",