@xleddyl/nuxt-cms 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +8 -1
  2. package/dist/module.d.mts +3 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +113 -3
  5. package/dist/runtime/app/components/cms/BrandMark.vue +16 -1
  6. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +2 -0
  7. package/dist/runtime/app/components/cms/EntryForm.vue +34 -3
  8. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +2 -0
  9. package/dist/runtime/app/components/cms/MediaDetail.vue +25 -13
  10. package/dist/runtime/app/components/cms/Search.d.vue.ts +7 -0
  11. package/dist/runtime/app/components/cms/Search.vue +347 -0
  12. package/dist/runtime/app/components/cms/Search.vue.d.ts +7 -0
  13. package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +2 -0
  14. package/dist/runtime/app/components/cms/VisualEditor.vue +21 -1
  15. package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +2 -0
  16. package/dist/runtime/app/components/cms/WelcomeModal.vue +7 -1
  17. package/dist/runtime/app/composables/cms-field-focus.d.ts +4 -0
  18. package/dist/runtime/app/composables/cms-field-focus.js +20 -0
  19. package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
  20. package/dist/runtime/app/layouts/cms-admin.vue +8 -3
  21. package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
  22. package/dist/runtime/app/pages/admin-collection.vue +3 -0
  23. package/dist/runtime/app/pages/admin-content.vue +3 -0
  24. package/dist/runtime/app/pages/admin-entry.vue +3 -0
  25. package/dist/runtime/assets/main.css +1 -1
  26. package/dist/runtime/server/api/collection.get.js +2 -4
  27. package/dist/runtime/server/api/media-download.get.d.ts +2 -0
  28. package/dist/runtime/server/api/media-download.get.js +90 -0
  29. package/dist/runtime/server/api/search.get.d.ts +3 -0
  30. package/dist/runtime/server/api/search.get.js +24 -0
  31. package/dist/runtime/server/utils/media-download.d.ts +1 -0
  32. package/dist/runtime/server/utils/media-download.js +8 -0
  33. package/dist/runtime/server/utils/media.d.ts +1 -0
  34. package/dist/runtime/server/utils/media.js +18 -3
  35. package/dist/runtime/server/utils/search.d.ts +20 -0
  36. package/dist/runtime/server/utils/search.js +233 -0
  37. package/dist/runtime/shared/search.d.ts +52 -0
  38. package/dist/runtime/shared/search.js +207 -0
  39. package/package.json +2 -2
package/README.md CHANGED
@@ -10,7 +10,7 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
10
10
  - **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
11
11
  - **Content with blocks**: news, blog and other content types with title, slug, cover, SEO fields and a body built from your own site components, drafts and scheduled publishing; `<CmsBlocks>` renders the body with typed props.
12
12
  - **Visual editor**: content items (and any blocks field built from site components) are edited on a live preview of your own site, with drag and drop, a block palette, per-locale and mobile previews, scheduling and publishing.
13
- - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, a directory on the server's disk, or a local mode backed directly by your `public/` folder), single-admin auth from env credentials.
13
+ - **Admin panel at `/cms`**: entry editing with validation, drafts, media library with one-click download of the original file (S3-compatible storage, a directory on the server's disk, or a local mode backed directly by your `public/` folder), a global search (⌘K / Ctrl K) over labels and every stored value in all locales, single-admin auth from env credentials.
14
14
  - **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination; fields marked `private` stay out of it.
15
15
  - **SQLite, Postgres, libSQL/Turso or Cloudflare D1**: a local file database by default, one config line to switch (including remote SQLite over the network).
16
16
  - **Runs on serverless too**: migrations are baked into the server bundle, uploads are presigned straight to your bucket, sessions are sealed cookies. Nothing on the request path needs a local disk or sticky instances.
@@ -56,6 +56,13 @@ build stops with an explicit message if the client for the configured driver is
56
56
 
57
57
  Then declare your content types in a `cms.config.ts` at the project root with `defineCmsConfig()`.
58
58
 
59
+ ### Admin branding
60
+
61
+ `cms.admin.title`, `cms.admin.subtitle` and `cms.admin.logo` replace the name, the tagline and the
62
+ mark in the admin sidebar, mobile bar and login page. `logo` takes a URL or public path, a path to
63
+ an `.svg` file in the project (inlined, so it follows the light and dark theme) or raw `<svg>`
64
+ markup; see [Configuration](docs/configuration.md#admin-branding).
65
+
59
66
  ### Disabling the CMS
60
67
 
61
68
  Keep the module in `modules[]` at all times and turn it off with the `enabled` option or the
package/dist/module.d.mts CHANGED
@@ -42,6 +42,9 @@ interface ModuleOptions {
42
42
  admin?: {
43
43
  email?: string;
44
44
  password?: string;
45
+ title?: string;
46
+ subtitle?: string;
47
+ logo?: string;
45
48
  };
46
49
  database?: ModuleOptionsDatabase;
47
50
  media?: ModuleOptionsMedia;
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.2.3",
4
+ "version": "0.4.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
- import { join, isAbsolute, resolve, relative, dirname } from 'node:path';
4
+ import { extname, resolve, isAbsolute, join, relative, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { defineNuxtModule, createResolver, useLogger, addImports, addTemplate, addServerPlugin, addTypeTemplate, addVitePlugin, addComponentsDir, addRouteMiddleware, extendPages, addServerHandler, resolvePath, addComponent } from '@nuxt/kit';
7
7
  import tailwindcss from '@tailwindcss/vite';
@@ -18,6 +18,91 @@ import { createHash } from 'node:crypto';
18
18
  import { readFile } from 'node:fs/promises';
19
19
  import { routePathsFromDir, resolvePageRoutes } from '../dist/runtime/shared/page-routes.js';
20
20
 
21
+ const DEFAULT_BRAND_TITLE = "nuxt-cms";
22
+ const DEFAULT_BRAND_SUBTITLE = "Content studio";
23
+ const ROOT_ALIASES = ["~~/", "@@/"];
24
+ const SRC_ALIASES = ["~/", "@/"];
25
+ function sanitizeSvg(markup) {
26
+ let svg = markup.replace(/<\?xml[\s\S]*?\?>/gi, "").replace(/<!DOCTYPE[\s\S]*?>/gi, "").replace(/<!--[\s\S]*?-->/g, "").replace(/<script\b[\s\S]*?<\/script\s*>/gi, "").replace(/<script\b[^>]*\/>/gi, "").replace(/<foreignObject\b[\s\S]*?<\/foreignObject\s*>/gi, "").replace(/<foreignObject\b[^>]*\/>/gi, "").replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "").replace(/\s+(?:xlink:)?href\s*=\s*("\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, "").trim();
27
+ const start = svg.search(/<svg\b/i);
28
+ if (start === -1) throw new Error("no <svg> element found");
29
+ svg = svg.slice(start);
30
+ return svg.replace(/<svg\b[^>]*>/i, (tag) => normalizeRootTag(tag));
31
+ }
32
+ function normalizeRootTag(tag) {
33
+ const attribute = (name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
34
+ const width = parseFloat(attribute("width")?.[1] ?? attribute("width")?.[2] ?? "");
35
+ const height = parseFloat(attribute("height")?.[1] ?? attribute("height")?.[2] ?? "");
36
+ let next = tag.replace(/\s(?:width|height)\s*=\s*(?:"[^"]*"|'[^']*')/gi, "");
37
+ if (!attribute("viewBox") && width > 0 && height > 0) {
38
+ next = next.replace(/<svg\b/i, `<svg viewBox="0 0 ${width} ${height}"`);
39
+ }
40
+ return next;
41
+ }
42
+ function isPublicReference(logo) {
43
+ return /^(?:https?:)?\/\//i.test(logo) || /^data:image\//i.test(logo) || logo.startsWith("/");
44
+ }
45
+ function projectFile(logo, paths) {
46
+ const rooted = ROOT_ALIASES.find((alias) => logo.startsWith(alias));
47
+ if (rooted) return resolve(paths.rootDir, logo.slice(rooted.length));
48
+ const sourced = SRC_ALIASES.find((alias) => logo.startsWith(alias));
49
+ if (sourced) return resolve(paths.srcDir, logo.slice(sourced.length));
50
+ return isAbsolute(logo) ? logo : resolve(paths.rootDir, logo);
51
+ }
52
+ function resolveBrandLogo(logo, paths) {
53
+ const value = logo?.trim() ?? "";
54
+ if (!value) return { logo: { kind: "default" }, file: null };
55
+ if (/^<svg[\s>]/i.test(value)) {
56
+ return { logo: { kind: "svg", markup: sanitizeSvgOption(value, "admin.logo") }, file: null };
57
+ }
58
+ if (isPublicReference(value)) {
59
+ return { logo: { kind: "image", src: value }, file: null };
60
+ }
61
+ const file = projectFile(value, paths);
62
+ if (!existsSync(file)) {
63
+ throw new Error(
64
+ `[nuxt-cms] admin.logo (${value}) points to a file that does not exist: ${file}. Use a path to an .svg file in the project, a public path such as '/logo.png', a URL, or inline <svg> markup.`
65
+ );
66
+ }
67
+ if (extname(file).toLowerCase() !== ".svg") {
68
+ throw new Error(
69
+ `[nuxt-cms] admin.logo (${value}) is not an .svg file. Only SVG files are read from the project: put the image in public/ and use its public path, for example '/${file.split(/[\\/]/).pop()}'.`
70
+ );
71
+ }
72
+ return {
73
+ logo: { kind: "svg", markup: sanitizeSvgOption(readFileSync(file, "utf8"), value) },
74
+ file
75
+ };
76
+ }
77
+ function sanitizeSvgOption(markup, source) {
78
+ try {
79
+ return sanitizeSvg(markup);
80
+ } catch {
81
+ throw new Error(`[nuxt-cms] admin.logo (${source}) does not contain an <svg> element.`);
82
+ }
83
+ }
84
+ function resolveBrand(options, paths) {
85
+ const { logo, file } = resolveBrandLogo(options?.logo, paths);
86
+ return {
87
+ brand: {
88
+ title: options?.title?.trim() || DEFAULT_BRAND_TITLE,
89
+ subtitle: options?.subtitle === void 0 ? DEFAULT_BRAND_SUBTITLE : options.subtitle.trim(),
90
+ logo
91
+ },
92
+ file
93
+ };
94
+ }
95
+ function renderBrandFile(brand) {
96
+ return [
97
+ `export const cmsBrand: {`,
98
+ ` title: string`,
99
+ ` subtitle: string`,
100
+ ` logo: { kind: 'default' } | { kind: 'image'; src: string } | { kind: 'svg'; markup: string }`,
101
+ `} = ${JSON.stringify(brand, null, 2)}`,
102
+ ``
103
+ ].join("\n");
104
+ }
105
+
21
106
  function componentPascalName(name) {
22
107
  return name.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
23
108
  }
@@ -1201,7 +1286,10 @@ function resolveModuleOptions(options) {
1201
1286
  configPath: options.configPath ?? "cms.config",
1202
1287
  admin: {
1203
1288
  email: options.admin?.email ?? "",
1204
- password: options.admin?.password ?? ""
1289
+ password: options.admin?.password ?? "",
1290
+ title: options.admin?.title ?? "",
1291
+ subtitle: options.admin?.subtitle,
1292
+ logo: options.admin?.logo ?? ""
1205
1293
  },
1206
1294
  database: resolveDatabaseOptions(options.database),
1207
1295
  media: resolveMediaOptions(options.media),
@@ -1735,6 +1823,18 @@ const module$1 = defineNuxtModule({
1735
1823
  },
1736
1824
  { nuxt: true, nitro: true }
1737
1825
  );
1826
+ const brandPaths = {
1827
+ rootDir: nuxt.options.rootDir,
1828
+ srcDir: nuxt.options.srcDir ?? nuxt.options.rootDir
1829
+ };
1830
+ const { file: brandFile } = resolveBrand(resolved.admin, brandPaths);
1831
+ if (brandFile) nuxt.options.watch.push(brandFile);
1832
+ const brandTemplate = addTemplate({
1833
+ filename: "cms/brand.ts",
1834
+ write: true,
1835
+ getContents: () => renderBrandFile(resolveBrand(resolved.admin, brandPaths).brand)
1836
+ });
1837
+ nuxt.options.alias["#cms-brand"] = brandTemplate.dst;
1738
1838
  addVitePlugin(tailwindcss());
1739
1839
  addVitePlugin(svgLoader({ defaultImport: "url", svgoConfig: { plugins: ["prefixIds"] } }));
1740
1840
  nuxt.options.css.push(
@@ -1871,6 +1971,11 @@ const module$1 = defineNuxtModule({
1871
1971
  method: "post",
1872
1972
  handler: resolver.resolve("./runtime/server/api/media-usage.post")
1873
1973
  });
1974
+ addServerHandler({
1975
+ route: "/api/cms/admin/media/download",
1976
+ method: "get",
1977
+ handler: resolver.resolve("./runtime/server/api/media-download.get")
1978
+ });
1874
1979
  if (mediaFilesystemBase) {
1875
1980
  addServerHandler({
1876
1981
  route: "/api/cms/admin/media/upload",
@@ -1882,6 +1987,11 @@ const module$1 = defineNuxtModule({
1882
1987
  handler: resolver.resolve("./runtime/server/routes/media-file")
1883
1988
  });
1884
1989
  }
1990
+ addServerHandler({
1991
+ route: "/api/cms/search",
1992
+ method: "get",
1993
+ handler: resolver.resolve("./runtime/server/api/search.get")
1994
+ });
1885
1995
  addServerHandler({
1886
1996
  route: "/api/cms/users",
1887
1997
  method: "get",
@@ -1,5 +1,19 @@
1
1
  <template>
2
- <span class="cms-brand-mark" :class="{ 'is-lg': size === 'lg' }" aria-hidden="true">
2
+ <span
3
+ v-if="cmsBrand.logo.kind === 'svg'"
4
+ class="cms-brand-logo"
5
+ :class="{ 'is-lg': size === 'lg' }"
6
+ aria-hidden="true"
7
+ v-html="cmsBrand.logo.markup"
8
+ />
9
+ <img
10
+ v-else-if="cmsBrand.logo.kind === 'image'"
11
+ class="cms-brand-logo"
12
+ :class="{ 'is-lg': size === 'lg' }"
13
+ :src="cmsBrand.logo.src"
14
+ :alt="cmsBrand.title"
15
+ />
16
+ <span v-else class="cms-brand-mark" :class="{ 'is-lg': size === 'lg' }" aria-hidden="true">
3
17
  <svg viewBox="0 0 16 16" fill="none">
4
18
  <path
5
19
  d="M4.5 5.5h7M4.5 8h7M4.5 10.5h4"
@@ -12,6 +26,7 @@
12
26
  </template>
13
27
 
14
28
  <script setup>
29
+ import { cmsBrand } from "#cms-brand";
15
30
  defineProps({
16
31
  size: { type: String, required: false }
17
32
  });
@@ -1,4 +1,5 @@
1
1
  import type { CmsEntry, CmsTab } from '#nuxt-cms';
2
+ import type { CmsSearchFocus } from '../../../shared/search.js';
2
3
  type __VLS_Props = {
3
4
  fields: CmsEntry['fields'];
4
5
  tabs?: CmsTab[];
@@ -7,6 +8,7 @@ type __VLS_Props = {
7
8
  loading?: boolean;
8
9
  formId?: string;
9
10
  footer?: boolean;
11
+ focus?: CmsSearchFocus | null;
10
12
  };
11
13
  type __VLS_ModelProps = {
12
14
  modelValue: Record<string, unknown>;
@@ -1,5 +1,12 @@
1
1
  <template>
2
- <CmsForm :id="formId" :state="state" :schema="schema" @submit="emit('submit')" @error="onError">
2
+ <CmsForm
3
+ :id="formId"
4
+ ref="form"
5
+ :state="state"
6
+ :schema="schema"
7
+ @submit="emit('submit')"
8
+ @error="onError"
9
+ >
3
10
  <div v-if="tabList.length > 1" class="cms-tabs" role="tablist">
4
11
  <button
5
12
  v-for="tab in tabList"
@@ -53,6 +60,8 @@
53
60
  :icon="fieldIcon(fields[key])"
54
61
  :name="key"
55
62
  :required="fields[key].required"
63
+ :data-cms-field="key"
64
+ :class="{ 'is-search-focus': highlighted === key }"
56
65
  >
57
66
  <template
58
67
  v-if="hasLocaleSwitch(fields[key]) && i18n.locales.length > 1"
@@ -102,8 +111,9 @@ import {
102
111
  resolveFormLayout,
103
112
  slugSourceValue
104
113
  } from "#nuxt-cms";
105
- import { computed, nextTick, ref, watch } from "#imports";
114
+ import { computed, nextTick, onMounted, ref, watch } from "#imports";
106
115
  import { buildEntrySchema } from "../../../shared/validation";
116
+ import { useCmsFieldFocus } from "../../composables/cms-field-focus";
107
117
  import { useCmsRuntime } from "../../composables/cms-runtime";
108
118
  import { entryFormLayout, useCmsSettingsState } from "../../composables/cms-settings";
109
119
  import { fieldIcon } from "../../utils/ui";
@@ -114,7 +124,8 @@ const props = defineProps({
114
124
  drafts: { type: Boolean, required: false },
115
125
  loading: { type: Boolean, required: false },
116
126
  formId: { type: String, required: false },
117
- footer: { type: Boolean, required: false, default: true }
127
+ footer: { type: Boolean, required: false, default: true },
128
+ focus: { type: [Object, null], required: false }
118
129
  });
119
130
  const state = defineModel({ type: Object, ...{ required: true } });
120
131
  const emit = defineEmits(["submit", "error"]);
@@ -194,4 +205,24 @@ const published = computed({
194
205
  function togglePublished() {
195
206
  published.value = !published.value;
196
207
  }
208
+ const form = ref(null);
209
+ const { highlighted, reveal } = useCmsFieldFocus();
210
+ function applyFocus(focus) {
211
+ if (focus.tab && tabList.value.some((tab2) => tab2.id === focus.tab)) activeTab.value = focus.tab;
212
+ const key = focus.field;
213
+ const field = key ? props.fields[key] : void 0;
214
+ if (!key || !field) return;
215
+ const tab = tabList.value.length ? fieldTab(field, tabList.value) : void 0;
216
+ if (tab) activeTab.value = tab;
217
+ if (focus.locale && hasLocaleSwitch(field) && i18n.locales.includes(focus.locale)) {
218
+ setLocale(key, focus.locale);
219
+ }
220
+ void nextTick(() => {
221
+ revealField(key);
222
+ reveal(() => form.value?.$el, key);
223
+ });
224
+ }
225
+ onMounted(() => {
226
+ if (props.focus) applyFocus(props.focus);
227
+ });
197
228
  </script>
@@ -1,4 +1,5 @@
1
1
  import type { CmsEntry, CmsTab } from '#nuxt-cms';
2
+ import type { CmsSearchFocus } from '../../../shared/search.js';
2
3
  type __VLS_Props = {
3
4
  fields: CmsEntry['fields'];
4
5
  tabs?: CmsTab[];
@@ -7,6 +8,7 @@ type __VLS_Props = {
7
8
  loading?: boolean;
8
9
  formId?: string;
9
10
  footer?: boolean;
11
+ focus?: CmsSearchFocus | null;
10
12
  };
11
13
  type __VLS_ModelProps = {
12
14
  modelValue: Record<string, unknown>;
@@ -13,22 +13,31 @@
13
13
  <CmsIcon v-else :name="mediaIconFor(item.type)" class="size-12" />
14
14
  </div>
15
15
 
16
- <div v-if="item.url" class="cms-actions">
17
- <CmsButton
18
- :label="copied ? 'Copied' : 'Copy URL'"
19
- :icon="copied ? 'check' : 'clipboard-document'"
20
- variant="soft"
21
- color="neutral"
22
- size="sm"
23
- @click="copyUrl"
24
- />
16
+ <div class="cms-actions">
17
+ <template v-if="item.url">
18
+ <CmsButton
19
+ :label="copied ? 'Copied' : 'Copy URL'"
20
+ :icon="copied ? 'check' : 'clipboard-document'"
21
+ variant="soft"
22
+ color="neutral"
23
+ size="sm"
24
+ @click="copyUrl"
25
+ />
26
+ <a
27
+ :href="item.url"
28
+ target="_blank"
29
+ rel="noopener"
30
+ class="cms-btn cms-btn-sm cms-btn-neutral cms-btn-ghost"
31
+ >
32
+ <CmsIcon name="arrow-top-right-on-square" class="size-4 shrink-0" />Open
33
+ </a>
34
+ </template>
25
35
  <a
26
- :href="item.url"
27
- target="_blank"
28
- rel="noopener"
36
+ :href="downloadHref"
37
+ download
29
38
  class="cms-btn cms-btn-sm cms-btn-neutral cms-btn-ghost"
30
39
  >
31
- <CmsIcon name="arrow-top-right-on-square" class="size-4 shrink-0" />Open
40
+ <CmsIcon name="arrow-down-tray" class="size-4 shrink-0" />Download
32
41
  </a>
33
42
  </div>
34
43
 
@@ -128,6 +137,9 @@ const dirty = computed(() => {
128
137
  if (!props.item) return false;
129
138
  return (alt.value.trim() || null) !== (props.item.alt ?? null) || !props.readOnly && folder.value !== (props.item.folder ?? null);
130
139
  });
140
+ const downloadHref = computed(
141
+ () => props.item ? `/api/cms/admin/media/download?key=${encodeURIComponent(props.item.key)}` : ""
142
+ );
131
143
  const facts = computed(() => {
132
144
  const item = props.item;
133
145
  if (!item) return [];
@@ -0,0 +1,7 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
2
+ open: () => any;
3
+ }, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
4
+ onOpen?: (() => any) | undefined;
5
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
6
+ declare const _default: typeof __VLS_export;
7
+ export default _default;