@karibsen/eponyme 0.7.0 → 0.8.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 (46) hide show
  1. package/README.md +59 -7
  2. package/dist/module.d.mts +15 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +5 -0
  5. package/dist/runtime/components/fields/FieldRenderer.vue +11 -0
  6. package/dist/runtime/components/fields/RichTextField.vue +37 -1
  7. package/dist/runtime/components/fields/TagsField.d.vue.ts +21 -0
  8. package/dist/runtime/components/fields/TagsField.vue +55 -0
  9. package/dist/runtime/components/fields/TagsField.vue.d.ts +21 -0
  10. package/dist/runtime/components/ui/EPAlertDialog.d.vue.ts +37 -0
  11. package/dist/runtime/components/ui/EPAlertDialog.vue +66 -0
  12. package/dist/runtime/components/ui/EPAlertDialog.vue.d.ts +37 -0
  13. package/dist/runtime/components/ui/EPDialog.d.vue.ts +1 -0
  14. package/dist/runtime/components/ui/EPDialog.vue +6 -2
  15. package/dist/runtime/components/ui/EPDialog.vue.d.ts +1 -0
  16. package/dist/runtime/components/ui/EPTagsInput.d.vue.ts +27 -0
  17. package/dist/runtime/components/ui/EPTagsInput.vue +101 -0
  18. package/dist/runtime/components/ui/EPTagsInput.vue.d.ts +27 -0
  19. package/dist/runtime/composables/useEponymeCollection.d.ts +13 -1
  20. package/dist/runtime/composables/useEponymeCollection.js +29 -4
  21. package/dist/runtime/fields/index.d.ts +2 -0
  22. package/dist/runtime/fields/index.js +2 -1
  23. package/dist/runtime/fields/tags.d.ts +6 -0
  24. package/dist/runtime/fields/tags.js +3 -0
  25. package/dist/runtime/pages/EponymeIndexPage.vue +0 -3
  26. package/dist/runtime/server/api/eponyme-collections/[path].get.js +40 -1
  27. package/dist/runtime/server/plugins/eponyme-sync.js +11 -1
  28. package/dist/runtime/server/services/eponyme-form-store.js +2 -2
  29. package/dist/runtime/server/services/eponyme-store.d.ts +278 -30
  30. package/dist/runtime/server/services/eponyme-store.js +373 -71
  31. package/dist/runtime/server/utils/eponyme-reindex.d.ts +13 -0
  32. package/dist/runtime/server/utils/eponyme-reindex.js +4 -0
  33. package/dist/runtime/types/eponyme.d.ts +53 -2
  34. package/dist/runtime/types/field.d.ts +35 -2
  35. package/dist/runtime/utils/eponyme-entry-index.d.ts +39 -0
  36. package/dist/runtime/utils/eponyme-entry-index.js +51 -0
  37. package/dist/runtime/utils/get-field-default-value.js +2 -0
  38. package/dist/runtime/utils/normalize-eponyme-values.d.ts +13 -0
  39. package/dist/runtime/utils/{normalize-eponyme-phones.js → normalize-eponyme-values.js} +4 -2
  40. package/dist/runtime/utils/normalize-tags.d.ts +10 -0
  41. package/dist/runtime/utils/normalize-tags.js +17 -0
  42. package/dist/runtime/utils/validate-eponyme-data.js +16 -0
  43. package/dist/runtime/utils/variables.d.ts +4 -0
  44. package/dist/runtime/utils/variables.js +8 -1
  45. package/package.json +3 -2
  46. package/dist/runtime/utils/normalize-eponyme-phones.d.ts +0 -9
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Chips plus a row of suggestion buttons, knowing nothing about Eponyme.
3
+ *
4
+ * The suggestions the developer configured are not a filtered autocomplete list: they're
5
+ * rendered as standing buttons below the input so an editor can see and click them directly,
6
+ * with no typing or listbox navigation required. `TagsInputRoot` still owns the chips, their
7
+ * keyboard handling and the delete buttons.
8
+ */
9
+ type __VLS_Props = {
10
+ modelValue?: string[];
11
+ /** Rendered as clickable buttons below the input, minus the ones already picked. */
12
+ suggestions?: readonly string[];
13
+ /** Whether a value outside `suggestions` may be added. */
14
+ allowCustom?: boolean;
15
+ placeholder?: string;
16
+ max?: number;
17
+ required?: boolean;
18
+ disabled?: boolean;
19
+ invalid?: boolean;
20
+ };
21
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
22
+ "update:modelValue": (value: string[]) => any;
23
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
24
+ "onUpdate:modelValue"?: ((value: string[]) => any) | undefined;
25
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
26
+ declare const _default: typeof __VLS_export;
27
+ export default _default;
@@ -1,15 +1,27 @@
1
1
  import type { ComputedRef, Ref } from 'vue';
2
2
  import type eponymeConfig from '#eponyme/config';
3
- import type { EponymeCollectionDataByName, EponymeCollectionName } from '../types/index.js';
3
+ import type { EponymeCollectionDataByName, EponymeCollectionFilter, EponymeCollectionName } from '../types/index.js';
4
4
  import type { EponymeCollectionEntry, EponymeSortDirection, EponymeStatus, EponymeVersionSelector } from '../server/services/eponyme-store.js';
5
5
  type ConfigCollectionName = EponymeCollectionName<typeof eponymeConfig>;
6
6
  /** Sortable keys: the entry metadata plus every field of the collection. */
7
7
  export type EponymeCollectionSortKey<Name extends ConfigCollectionName> = 'updatedAt' | 'publishedAt' | 'title' | 'slug' | (keyof EponymeCollectionDataByName<typeof eponymeConfig, Name> & string);
8
+ /**
9
+ * The `where` a collection accepts: only the fields stored in a form the index can compare,
10
+ * each with the values that field can actually hold. The server rejects anything else, so
11
+ * the two agree rather than one silently widening the other.
12
+ */
13
+ export type EponymeCollectionFilterInput<Name extends ConfigCollectionName> = EponymeCollectionFilter<typeof eponymeConfig, Name>;
8
14
  export interface UseEponymeCollectionOptions<Name extends ConfigCollectionName> {
9
15
  take?: number;
10
16
  skip?: number;
11
17
  orderBy?: EponymeCollectionSortKey<Name>;
12
18
  order?: EponymeSortDirection;
19
+ /**
20
+ * Keeps only the entries matching every key. A list of values matches any of them; `not`
21
+ * excludes; `contains` matches a substring; and `gte`/`lte`/`gt`/`lt` compare as text,
22
+ * which is exact for `field.date()`, pinned by validation to `YYYY-MM-DD`.
23
+ */
24
+ where?: EponymeCollectionFilterInput<Name>;
13
25
  }
14
26
  export interface UseEponymeCollectionResult<Data extends Record<string, unknown>> {
15
27
  entries: ComputedRef<EponymeCollectionEntry<Data>[]>;
@@ -3,18 +3,43 @@ import { useEventListener } from "@vueuse/core";
3
3
  import { computed } from "vue";
4
4
  import { readPreviewQuery, readPreviewVersion } from "../utils/preview.js";
5
5
  import { cacheForPublicRead } from "../utils/hydration-cache.js";
6
+ function serializeFilter(where) {
7
+ const query = {};
8
+ const parts = [];
9
+ const write = (name, values) => {
10
+ if (!values.length) return;
11
+ query[name] = values;
12
+ parts.push(`${name}=${values.join("|")}`);
13
+ };
14
+ const list = (value) => (Array.isArray(value) ? value : [value]).map(String).filter(Boolean);
15
+ for (const key of Object.keys(where ?? {}).sort()) {
16
+ const condition = where[key];
17
+ if (condition === void 0 || condition === null) continue;
18
+ if (typeof condition !== "object" || Array.isArray(condition)) {
19
+ write(`where[${key}]`, list(condition));
20
+ continue;
21
+ }
22
+ for (const operator of Object.keys(condition).sort()) {
23
+ write(`where[${key}][${operator}]`, list(condition[operator]));
24
+ }
25
+ }
26
+ return { query, key: parts.join(",") };
27
+ }
6
28
  export function useEponymeCollection(name, options = {}) {
7
29
  const requestFetch = useRequestFetch();
30
+ const filter = serializeFilter(options.where);
8
31
  const query = {
9
32
  take: options.take,
10
33
  skip: options.skip,
11
34
  orderBy: options.orderBy,
12
- order: options.order
35
+ order: options.order,
36
+ ...filter.query
13
37
  };
14
38
  const result = useAsyncData(
15
- // The options belong in the key, otherwise two differently sorted calls to the
16
- // same collection would share one cache entry.
17
- `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
39
+ // Every option belongs in the key, otherwise two differently sorted — or differently
40
+ // filtered — calls to the same collection would share one cache entry and one would
41
+ // silently answer with the other's results.
42
+ `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}:${filter.key}`,
18
43
  // Always the published listing, which is publicly cacheable: the browser cache may answer it.
19
44
  () => requestFetch(`/api/eponyme-collections/${name}`, { query }),
20
45
  { getCachedData: cacheForPublicRead(true) }
@@ -13,6 +13,7 @@ import { section } from './section.js';
13
13
  import { select } from './select.js';
14
14
  import { slug } from './slug.js';
15
15
  import { string } from './string.js';
16
+ import { tags } from './tags.js';
16
17
  import { tab } from './tab.js';
17
18
  import { textarea } from './textarea.js';
18
19
  import { url } from './url.js';
@@ -30,6 +31,7 @@ export declare const field: {
30
31
  select: typeof select;
31
32
  radio: typeof radio;
32
33
  checkboxGroup: typeof checkboxGroup;
34
+ tags: typeof tags;
33
35
  date: typeof date;
34
36
  color: typeof color;
35
37
  array: typeof array;
@@ -13,8 +13,9 @@ import { section } from "./section.js";
13
13
  import { select } from "./select.js";
14
14
  import { slug } from "./slug.js";
15
15
  import { string } from "./string.js";
16
+ import { tags } from "./tags.js";
16
17
  import { tab } from "./tab.js";
17
18
  import { textarea } from "./textarea.js";
18
19
  import { url } from "./url.js";
19
- export const field = { string, slug, email, phone, url, textarea, richText, number, boolean, image, select, radio, checkboxGroup, date, color, array, section, tab };
20
+ export const field = { string, slug, email, phone, url, textarea, richText, number, boolean, image, select, radio, checkboxGroup, tags, date, color, array, section, tab };
20
21
  export { today };
@@ -0,0 +1,6 @@
1
+ import type { TagsFieldDefinition, TagsFieldOptions } from '../types/field.js';
2
+ /**
3
+ * The whole options object is the type parameter, so the literals an author writes —
4
+ * the suggestions and `allowCustom` — survive into the value type. See `TagsValueOf`.
5
+ */
6
+ export declare function tags<const O extends TagsFieldOptions>(options?: O): TagsFieldDefinition<O>;
@@ -0,0 +1,3 @@
1
+ export function tags(options = {}) {
2
+ return { type: "tags", options };
3
+ }
@@ -104,9 +104,6 @@ function closePreview() {
104
104
  <section class="ep:mx-auto ep:w-full ep:max-w-3xl ep:px-6 ep:py-8 ep:md:px-10 ep:md:py-12">
105
105
  <div class="ep:flex ep:flex-wrap ep:items-start ep:justify-between ep:gap-4">
106
106
  <div>
107
- <p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
108
- Eponyme
109
- </p>
110
107
  <h1 class="ep:mt-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
111
108
  Content entries
112
109
  </h1>
@@ -4,12 +4,50 @@ import { requireEponymeUser } from "../../utils/auth.js";
4
4
  import { getEponymeCacheTags, setEponymePublicCache } from "../../utils/eponyme-cache.js";
5
5
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
6
6
  const MAX_TAKE = 200;
7
+ const OPERATORS = ["gte", "lte", "gt", "lt", "contains", "in", "not"];
8
+ const LIST_OPERATORS = /* @__PURE__ */ new Set(["in", "not"]);
9
+ const FILTER_KEY = /^where\[([^\]]+)\](?:\[([^\]]+)\])?$/;
7
10
  function readCount(raw, min, max) {
8
11
  if (raw === void 0 || raw === "") return void 0;
9
12
  const value = Number(raw);
10
13
  if (!Number.isFinite(value)) return void 0;
11
14
  return Math.min(max, Math.max(min, Math.trunc(value)));
12
15
  }
16
+ function readFilter(query, allowed) {
17
+ const where = {};
18
+ for (const [rawKey, rawValue] of Object.entries(query)) {
19
+ const match = FILTER_KEY.exec(rawKey);
20
+ if (!match) continue;
21
+ const [, key, operator] = match;
22
+ if (!allowed?.includes(key)) {
23
+ throw createError({
24
+ statusCode: 400,
25
+ statusMessage: `Unknown filter key "${key}". Available keys: ${allowed?.join(", ") || "none"}.`
26
+ });
27
+ }
28
+ if (operator === void 0) {
29
+ const values = (Array.isArray(rawValue) ? rawValue : [rawValue]).map(String).filter(Boolean);
30
+ if (values.length) where[key] = values.length === 1 ? values[0] : values;
31
+ continue;
32
+ }
33
+ if (!OPERATORS.includes(operator)) {
34
+ throw createError({
35
+ statusCode: 400,
36
+ statusMessage: `Unknown filter operator "${operator}". Available operators: ${OPERATORS.join(", ")}.`
37
+ });
38
+ }
39
+ const existing = where[key];
40
+ const operators = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
41
+ if (LIST_OPERATORS.has(operator)) {
42
+ const values = (Array.isArray(rawValue) ? rawValue : [rawValue]).map(String).filter(Boolean);
43
+ if (values.length) operators[operator] = values;
44
+ } else {
45
+ operators[operator] = String(Array.isArray(rawValue) ? rawValue[0] : rawValue);
46
+ }
47
+ where[key] = operators;
48
+ }
49
+ return Object.keys(where).length ? where : void 0;
50
+ }
13
51
  export default defineEventHandler(async (event) => {
14
52
  const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-collections\//, ""));
15
53
  const query = getQuery(event);
@@ -33,7 +71,8 @@ export default defineEventHandler(async (event) => {
33
71
  take: readCount(query.take, 1, MAX_TAKE),
34
72
  skip: readCount(query.skip, 0, Number.MAX_SAFE_INTEGER),
35
73
  orderBy,
36
- order: query.order === "asc" ? "asc" : query.order === "desc" ? "desc" : void 0
74
+ order: query.order === "asc" ? "asc" : query.order === "desc" ? "desc" : void 0,
75
+ where: readFilter(query, service.collectionFilterKeys(name))
37
76
  });
38
77
  if (!page) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
39
78
  return query.raw ? page : interpolateEponymeContent(page);
@@ -1,4 +1,4 @@
1
- import { defineNitroPlugin } from "nitropack/runtime";
1
+ import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";
2
2
  import { useEponymeAuthService } from "../services/eponyme-auth-service.js";
3
3
  import { useEponymeService } from "../services/eponyme-service.js";
4
4
  export default defineNitroPlugin(async () => {
@@ -11,4 +11,14 @@ export default defineNitroPlugin(async () => {
11
11
  );
12
12
  }
13
13
  await useEponymeService().syncAll();
14
+ const content = useRuntimeConfig().eponymeContent;
15
+ if (content?.autoReindex === false) return;
16
+ try {
17
+ await useEponymeService().syncIndexState();
18
+ } catch (error) {
19
+ throw new Error(
20
+ "[Eponyme] Index rebuild failed. Apply the EponymeEntryIndex and EponymeIndexState Prisma migrations before starting the application, or set `eponyme.autoReindex: false` to start without it and run `reindexEponymeEntries()` by hand.",
21
+ { cause: error }
22
+ );
23
+ }
14
24
  });
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { normalizeEponymePhones } from "../../utils/normalize-eponyme-phones.js";
2
+ import { normalizeEponymeValues } from "../../utils/normalize-eponyme-values.js";
3
3
  import { getEponymeForms } from "../../utils/get-eponyme-schemas.js";
4
4
  import { validateEponymeData } from "../../utils/validate-eponyme-data.js";
5
5
  const DEFAULT_PER_PAGE = 25;
@@ -41,7 +41,7 @@ export class EponymeFormService {
41
41
  const unknownKeys = Object.keys(submitted).filter((key) => !(key in definition.fields));
42
42
  if (unknownKeys.length)
43
43
  return { errors: Object.fromEntries(unknownKeys.map((key) => [key, ["Unknown field."]])) };
44
- const rest = normalizeEponymePhones(definition.fields, submitted);
44
+ const rest = normalizeEponymeValues(definition.fields, submitted);
45
45
  const errors = validateEponymeData(definition.fields, rest, "publish");
46
46
  if (Object.keys(errors).length) return { errors };
47
47
  return { data: rest };