smoodly 0.0.17 → 0.0.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.
Files changed (73) hide show
  1. package/README.md +56 -6
  2. package/dist/admin/client-ops.js +1 -1
  3. package/dist/admin/editor/CanvasOverlay.js +4 -2
  4. package/dist/admin/editor/EditorView.js +19 -32
  5. package/dist/admin/editor/GlobalInspector.d.ts +5 -2
  6. package/dist/admin/editor/GlobalInspector.js +4 -3
  7. package/dist/admin/editor/NoticePane.js +3 -1
  8. package/dist/admin/forms/FieldWidget.d.ts +6 -6
  9. package/dist/admin/forms/FieldWidget.js +22 -10
  10. package/dist/admin/forms/RefPicker.d.ts +11 -0
  11. package/dist/admin/forms/RefPicker.js +30 -0
  12. package/dist/admin/forms/TagsField.d.ts +10 -0
  13. package/dist/admin/forms/TagsField.js +38 -0
  14. package/dist/admin/forms/ref-picker.d.ts +25 -0
  15. package/dist/admin/forms/ref-picker.js +30 -0
  16. package/dist/admin/forms/useRefOptions.d.ts +7 -0
  17. package/dist/admin/forms/useRefOptions.js +36 -0
  18. package/dist/admin/forms/useTagSuggestions.d.ts +5 -0
  19. package/dist/admin/forms/useTagSuggestions.js +22 -0
  20. package/dist/admin/ops-impl.js +23 -4
  21. package/dist/admin/ops.d.ts +4 -0
  22. package/dist/admin/serialize.d.ts +2 -0
  23. package/dist/admin/serialize.js +1 -0
  24. package/dist/admin/shell/AdminApp.js +8 -2
  25. package/dist/admin/shell/EntriesList.js +3 -2
  26. package/dist/admin/shell/EntryForm.js +14 -28
  27. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  28. package/dist/admin/shell/ErrorPane.js +6 -4
  29. package/dist/admin/shell/GlobalFields.d.ts +3 -6
  30. package/dist/admin/shell/GlobalFields.js +1 -1
  31. package/dist/admin/shell/GlobalForm.js +8 -22
  32. package/dist/admin/shell/GlobalList.js +3 -2
  33. package/dist/admin/shell/IconRail.js +3 -2
  34. package/dist/admin/shell/PagesList.js +4 -3
  35. package/dist/admin/shell/base.d.ts +4 -1
  36. package/dist/admin/shell/base.js +15 -3
  37. package/dist/admin/shell/router.d.ts +4 -3
  38. package/dist/admin/shell/router.js +17 -14
  39. package/dist/admin/ui/ActionMenu.d.ts +3 -1
  40. package/dist/admin/ui/ActionMenu.js +4 -3
  41. package/dist/admin/ui/Combobox.d.ts +30 -0
  42. package/dist/admin/ui/Combobox.js +89 -0
  43. package/dist/admin/ui/PublishControl.d.ts +3 -1
  44. package/dist/admin/ui/PublishControl.js +2 -2
  45. package/dist/admin/ui/combo-box.d.ts +16 -0
  46. package/dist/admin/ui/combo-box.js +21 -0
  47. package/dist/admin/ui/primitives.d.ts +1 -1
  48. package/dist/admin/ui/primitives.js +1 -1
  49. package/dist/admin/ui/theme.js +43 -4
  50. package/dist/admin/validate.js +39 -0
  51. package/dist/admin-path.d.ts +9 -0
  52. package/dist/admin-path.js +24 -0
  53. package/dist/config.d.ts +10 -1
  54. package/dist/config.js +4 -0
  55. package/dist/entry-page.d.ts +5 -0
  56. package/dist/entry-page.js +1 -1
  57. package/dist/entry-store.js +1 -1
  58. package/dist/fields.d.ts +3 -0
  59. package/dist/fields.js +5 -0
  60. package/dist/index.d.ts +4 -3
  61. package/dist/index.js +2 -1
  62. package/dist/next/page-renderer.js +7 -3
  63. package/dist/page.js +1 -1
  64. package/dist/paths.d.ts +21 -0
  65. package/dist/paths.js +25 -0
  66. package/dist/render.d.ts +10 -0
  67. package/dist/render.js +2 -1
  68. package/dist/site.d.ts +14 -9
  69. package/dist/site.js +31 -18
  70. package/dist/supabase-entry-store.js +1 -1
  71. package/dist/tags.d.ts +19 -0
  72. package/dist/tags.js +50 -0
  73. package/package.json +1 -1
package/dist/fields.js CHANGED
@@ -107,6 +107,9 @@ export const f = {
107
107
  radio: (options) => builder({ type: "radio", options: normalizeOptions(options) }),
108
108
  /** Several values from a list, stored as an array, shown as a checkbox list. */
109
109
  checkboxes: (options) => builder({ type: "checkboxes", options: normalizeOptions(options) }),
110
+ /** Short labels edited as chips, stored as a string array: trimmed, no
111
+ * case-insensitive duplicates (spec 2026-09-17 §4). `.min`/`.max` count tags. */
112
+ tags: () => builder({ type: "tags" }),
110
113
  object: (shape) => {
111
114
  for (const [k, v] of Object.entries(shape))
112
115
  assertContainerItem("f.object", v.descriptor, k);
@@ -124,6 +127,8 @@ export const f = {
124
127
  const nested = findMatch(d, (sub) => sub.type === "list");
125
128
  if (nested !== null)
126
129
  throw new Error(`smoodly: f.list cannot hold another list (at ${describePath(nested)}) — nested lists are not supported.`);
130
+ if (d.type === "tags")
131
+ throw new Error("smoodly: f.list cannot hold f.tags() — a list of tag lists is a nested list; put the tags in an object item.");
127
132
  assertContainerItem("f.list", d);
128
133
  return builder({ type: "list", item: d });
129
134
  },
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ import { entryPage } from "./entry-page.tsx";
7
7
  import { globalItem } from "./global.ts";
8
8
  import { marks } from "./marks.ts";
9
9
  export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.tsx";
10
- export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageLink, PageViewProps } from "./render.tsx";
10
+ export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageContextAlternate, PageLink, PageViewProps } from "./render.tsx";
11
11
  export { RichText, defaultRichTextImage } from "./richtext-render.tsx";
12
12
  export type { RichTextProps, RichTextElement, RichTextImageProps } from "./richtext-render.tsx";
13
13
  export type { PageSchema } from "./page.ts";
@@ -21,6 +21,7 @@ export * from "./custom/index.ts";
21
21
  export type { Condition, ConditionOp, ConditionScope } from "./conditions.ts";
22
22
  export { defaultsOf, withDefaults } from "./field-defaults.ts";
23
23
  export { filterPageTree } from "./page-tree.ts";
24
+ export { DEFAULT_ADMIN_PATH } from "./admin-path.ts";
24
25
  export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.ts";
25
26
  export type { Crop, Hotspot, AssetKind, ImageValue, VideoValue, FileValue, AssetMeta, AssetRecord, ResolvedImage, ResolvedVideo, ResolvedFile, } from "./media.ts";
26
27
  export { z } from "./zones.ts";
@@ -48,8 +49,8 @@ export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collection
48
49
  export type { AffectedTargets } from "./revalidate.ts";
49
50
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.ts";
50
51
  export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve.ts";
51
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.ts";
52
- export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions } from "./paths.ts";
52
+ export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
53
+ export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
53
54
  export { fixedPathFor } from "./localize.ts";
54
55
  export type { PageStore, PageRecord, PageLocale, PageVersion, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
55
56
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export { DEFAULT_STYLE_VALUES, STYLE_KEYS, SPACING_NONE, resolveStyleValues, sty
16
16
  export * from "./custom/index.js";
17
17
  export { defaultsOf, withDefaults } from "./field-defaults.js";
18
18
  export { filterPageTree } from "./page-tree.js";
19
+ export { DEFAULT_ADMIN_PATH } from "./admin-path.js";
19
20
  export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.js";
20
21
  export { z } from "./zones.js";
21
22
  export { defineConfig } from "./config.js";
@@ -35,7 +36,7 @@ export { SupabasePathIndex } from "./supabase-path-index.js";
35
36
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.js";
36
37
  export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.js";
37
38
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
38
- export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
39
+ export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
39
40
  export { fixedPathFor } from "./localize.js";
40
41
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
41
42
  export { DEFAULT_TOOLSET } from "./toolset.js";
@@ -34,7 +34,7 @@ export function createPageRenderer(options) {
34
34
  console.warn(`smoodly: no page registration named "${hit.template}" — the record at ${hit.path} is a 404.`);
35
35
  notFound();
36
36
  }
37
- const page = pageContextOf(hit, (path) => href(path, locale));
37
+ const page = pageContextOf(hit, href);
38
38
  return renderPage(Page, hit.tree, { sections: options.sections, elements: options.elements, styles: site.styles, custom: site.custom }, { editor, page });
39
39
  }
40
40
  const View = entryPages.get(hit.collection);
@@ -42,12 +42,16 @@ export function createPageRenderer(options) {
42
42
  console.warn(`smoodly: no entry page for collection "${hit.collection}" — the entry at ${hit.path} is a 404.`);
43
43
  notFound();
44
44
  }
45
- return _jsx(View, { entry: hit.entry, path: hit.path });
45
+ return _jsx(View, { entry: hit.entry, path: hit.path, locale: hit.locale, locales: hit.locales });
46
46
  }
47
+ /** hreflang from the SAME map a language switch reads (spec 2026-09-15 §3): the locales with a URL, through href. */
47
48
  function alternates(hit) {
48
49
  if (site.locales.supported.length < 2)
49
50
  return {};
50
- const languages = Object.fromEntries(Object.entries(hit.paths).map(([locale, path]) => [locale, href(path, locale)]));
51
+ const languages = {};
52
+ for (const [locale, alt] of Object.entries(hit.locales))
53
+ if (alt?.path)
54
+ languages[locale] = href(alt.path, locale);
51
55
  return { alternates: { languages } };
52
56
  }
53
57
  async function page(pathFor, opts, accept) {
package/dist/page.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describePath, findMatch } from "./fields.js";
2
2
  /** What a page field may be: a settings form, never a ref, a list or a document (spec 2026-09-10 page fields §1). */
3
- const PAGE_FIELD_TYPES = new Set(["text", "link", "date", "number", "boolean", "select", "radio", "checkboxes", "image", "video", "file", "object"]);
3
+ const PAGE_FIELD_TYPES = new Set(["text", "link", "date", "number", "boolean", "select", "radio", "checkboxes", "tags", "image", "video", "file", "object"]);
4
4
  /** The descriptor at a dotted path below `d` (object subfields only — page fields hold no lists). */
5
5
  function descriptorAt(d, path) {
6
6
  let cur = d;
package/dist/paths.d.ts CHANGED
@@ -91,6 +91,27 @@ export declare function entrySegment(entry: EntryLike, locale: string): string |
91
91
  export declare function entryPathRows(targets: EntryLike[], byId: (id: string) => EntryLike | undefined, collection: {
92
92
  path?: string | Record<string, string>;
93
93
  }, set: LocaleSet): PathRow[];
94
+ /** A target's address in one locale (spec 2026-09-15 §1). A page always
95
+ * has both; an entry's slug is null when its row claims no URL, and its
96
+ * path null then or when the collection has no `path`. */
97
+ export type LocaleAlternate = {
98
+ slug: string | null;
99
+ path: string | null;
100
+ };
101
+ export type PageAlternate = {
102
+ slug: string;
103
+ path: string;
104
+ };
105
+ /** The page's address in EVERY supported locale, in config order: null
106
+ * where the node is absent, where an ancestor is, or where `advertised`
107
+ * says no — the site passes "published, or draft mode", so an
108
+ * unpublished translation is never linked on the public site. */
109
+ export declare function pageAlternates(node: PageLike, byId: (id: string) => PageLike | undefined, opts: PagePathOptions, advertised: (locale: string) => boolean): Record<string, PageAlternate | null>;
110
+ /** The entry's address in EVERY supported locale, in config order: null
111
+ * where the row is absent or not advertised. `pathIn` is the caller's
112
+ * path source — the derived rows in a list, the `paths` index for one
113
+ * loaded record — so both entry reads apply ONE rule. */
114
+ export declare function entryAlternates(entry: EntryLike, set: LocaleSet, advertised: (locale: string) => boolean, pathIn: (locale: string) => string | null): Record<string, LocaleAlternate | null>;
94
115
  export type PageTreeNode<T extends PageLike> = {
95
116
  record: T;
96
117
  path: string | null;
package/dist/paths.js CHANGED
@@ -130,6 +130,31 @@ export function entryPathRows(targets, byId, collection, set) {
130
130
  }
131
131
  return rows;
132
132
  }
133
+ /** The page's address in EVERY supported locale, in config order: null
134
+ * where the node is absent, where an ancestor is, or where `advertised`
135
+ * says no — the site passes "published, or draft mode", so an
136
+ * unpublished translation is never linked on the public site. */
137
+ export function pageAlternates(node, byId, opts, advertised) {
138
+ const out = {};
139
+ for (const locale of opts.locales.supported) {
140
+ const own = node.locales[locale];
141
+ const path = own && advertised(locale) ? pagePathIn(node, byId, locale, opts) : null;
142
+ out[locale] = own && path !== null ? { slug: own.slug, path } : null;
143
+ }
144
+ return out;
145
+ }
146
+ /** The entry's address in EVERY supported locale, in config order: null
147
+ * where the row is absent or not advertised. `pathIn` is the caller's
148
+ * path source — the derived rows in a list, the `paths` index for one
149
+ * loaded record — so both entry reads apply ONE rule. */
150
+ export function entryAlternates(entry, set, advertised, pathIn) {
151
+ const out = {};
152
+ for (const locale of set.supported) {
153
+ const own = entry.locales[locale];
154
+ out[locale] = own && advertised(locale) ? { slug: own.slug, path: pathIn(locale) } : null;
155
+ }
156
+ return out;
157
+ }
133
158
  export function buildPageTree(records, locale, opts) {
134
159
  const byId = (id) => records.find((r) => r.id === id);
135
160
  const build = (parentId) => records
package/dist/render.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
+ import type { PageAlternate } from "./paths.ts";
2
3
  import type { PageSchema } from "./page.ts";
3
4
  import type { BuilderMap, NamespaceValues } from "./schema.ts";
4
5
  import { type StyleValues } from "./styles.ts";
@@ -41,16 +42,25 @@ export type PageLink = {
41
42
  export type PageContextLink = PageLink & {
42
43
  url: string;
43
44
  };
45
+ /** The page's address in one other locale, `url` through the app's href seam (spec 2026-09-15 §3). */
46
+ export type PageContextAlternate = PageAlternate & {
47
+ url: string;
48
+ };
44
49
  export type PageContext<Fields = Record<string, unknown>> = {
45
50
  id: string;
46
51
  title: string;
47
52
  path: string;
48
53
  url: string;
54
+ /** The locale the page was rendered in. */
55
+ locale: string;
49
56
  /** The page's own fields, typed from its schema on the view; resolved, defaults applied. */
50
57
  fields: Fields;
51
58
  /** Root first, parent last. */
52
59
  ancestors: ReadonlyArray<PageContextLink>;
53
60
  children: ReadonlyArray<PageContextLink>;
61
+ /** One key per supported locale: the page's `{ slug, path, url }` there, or null where it is
62
+ * absent or unpublished (draft mode: absent only). What a language switch links to. */
63
+ locales: Readonly<Record<string, PageContextAlternate | null>>;
54
64
  };
55
65
  /** The `page` prop of a view rendered outside the site (a test, a preview
56
66
  * of a shape): a real object, so a view never guards an optional prop, but
package/dist/render.js CHANGED
@@ -8,7 +8,8 @@ import { CUSTOM_NODE_TYPE, createCustomSection } from "./custom/index.js";
8
8
  * no position in the tree. Frozen: one singleton shared by every such
9
9
  * render, so a view that pushed onto it would leak into the next. */
10
10
  export const EMPTY_PAGE_CONTEXT = Object.freeze({
11
- id: "", title: "", path: "/", url: "/", fields: Object.freeze({}), ancestors: Object.freeze([]), children: Object.freeze([]),
11
+ id: "", title: "", path: "/", url: "/", locale: "", fields: Object.freeze({}), ancestors: Object.freeze([]), children: Object.freeze([]),
12
+ locales: Object.freeze({}),
12
13
  });
13
14
  export function Zone({ of: content }) {
14
15
  // A malformed tree (bad data, a client that skipped isPageTree) must not
package/dist/site.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { AssetStore } from "./asset-store.ts";
2
2
  import type { CollectionSchema, EntryOrder } from "./collections.ts";
3
3
  import type { Descriptor } from "./fields.ts";
4
4
  import { type EntryRecord, type EntryStore, type Where } from "./entry-store.ts";
5
- import { type LocaleSet, type PathTarget } from "./paths.ts";
5
+ import { type LocaleAlternate, type LocaleSet, type PageAlternate, type PathTarget } from "./paths.ts";
6
6
  import type { SectionLike } from "./refs.ts";
7
7
  import type { PageContext, PageLink, PageTree } from "./render.tsx";
8
8
  import { type GlobalSchema } from "./global.ts";
@@ -39,6 +39,8 @@ export type SitePageSchema = {
39
39
  };
40
40
  export type SitePage = {
41
41
  kind: "page";
42
+ /** The locale the hit was loaded in. */
43
+ locale: string;
42
44
  record: PageRecord;
43
45
  template: string;
44
46
  tree: PageTree;
@@ -48,20 +50,21 @@ export type SitePage = {
48
50
  fields: Record<string, unknown>;
49
51
  ancestors: PageLink[];
50
52
  children: PageLink[];
51
- /** locale → path, for metadata alternates: the locales the page is
52
- * PUBLISHED in, since that is what may be advertised — draft mode
53
- * carries every locale the page exists in. */
54
- paths: Record<string, string>;
53
+ /** One key per supported locale: the page's `{ slug, path }` there, or null where it is
54
+ * absent or, outside draft mode, unpublished (spec 2026-09-15 §1). */
55
+ locales: Record<string, PageAlternate | null>;
55
56
  };
56
57
  export type SiteEntry = {
57
58
  kind: "entry";
59
+ /** The locale the hit was loaded in. */
60
+ locale: string;
58
61
  collection: string;
59
62
  /** `fields` are the locale's MERGED fields — shared + localized + slug —
60
63
  * with refs resolved: the published snapshot on the public path, the
61
64
  * live row in draft mode. */
62
65
  entry: EntryRecord;
63
66
  path: string;
64
- paths: Record<string, string>;
67
+ locales: Record<string, LocaleAlternate | null>;
65
68
  };
66
69
  export type SiteHit = SitePage | SiteEntry;
67
70
  export type PageTreeItem = {
@@ -75,6 +78,8 @@ export type PageTreeItem = {
75
78
  export type SiteEntryItem = {
76
79
  entry: EntryRecord;
77
80
  path: string | null;
81
+ locale: string;
82
+ locales: Record<string, LocaleAlternate | null>;
78
83
  };
79
84
  export type EntryTreeItem = SiteEntryItem & {
80
85
  children: EntryTreeItem[];
@@ -131,7 +136,7 @@ export interface SmoodlySite {
131
136
  draft?: boolean;
132
137
  }): Promise<V | null>;
133
138
  }
134
- /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
135
- * bound to the render locale; the default keeps CMS paths as URLs. */
136
- export declare function pageContextOf(hit: SitePage, toUrl?: (path: string) => string): PageContext;
139
+ /** The `page` prop for a loaded page. `href` is the app's seam from a
140
+ * locale-relative path to a site URL; the default keeps CMS paths as URLs. */
141
+ export declare function pageContextOf(hit: SitePage, href?: (path: string, locale: string) => string): PageContext;
137
142
  export declare function createSmoodlySite(options: SiteOptions): SmoodlySite;
package/dist/site.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./entry-store.js";
2
- import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
2
+ import { buildPageTree, chainOf, entryAlternates, entryPathRows, entrySegment, pageAlternates, pagePath, } from "./paths.js";
3
3
  import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
4
4
  import { elementSchemas } from "./richtext-walk.js";
5
5
  import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
@@ -7,18 +7,20 @@ import { SMD_KEY } from "./section-wrapper.js";
7
7
  import { resolveStyleValues } from "./styles.js";
8
8
  import { resolveCustom } from "./custom/index.js";
9
9
  import { withDefaults } from "./field-defaults.js";
10
- /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
11
- * bound to the render locale; the default keeps CMS paths as URLs. */
12
- export function pageContextOf(hit, toUrl = (p) => p) {
13
- const withUrl = (l) => ({ ...l, url: toUrl(l.path) });
10
+ /** The `page` prop for a loaded page. `href` is the app's seam from a
11
+ * locale-relative path to a site URL; the default keeps CMS paths as URLs. */
12
+ export function pageContextOf(hit, href = (p) => p) {
13
+ const withUrl = (l) => ({ ...l, url: href(l.path, hit.locale) });
14
14
  return {
15
15
  id: hit.record.id,
16
16
  title: hit.title,
17
17
  path: hit.path,
18
- url: toUrl(hit.path),
18
+ url: href(hit.path, hit.locale),
19
+ locale: hit.locale,
19
20
  fields: hit.fields,
20
21
  ancestors: hit.ancestors.map(withUrl),
21
22
  children: hit.children.map(withUrl),
23
+ locales: Object.fromEntries(Object.entries(hit.locales).map(([code, alt]) => [code, alt && { ...alt, url: href(alt.path, code) }])),
22
24
  };
23
25
  }
24
26
  export function createSmoodlySite(options) {
@@ -128,10 +130,12 @@ export function createSmoodlySite(options) {
128
130
  const fields = await linkFields([...chain.slice(0, -1), ...kids], locale, draft);
129
131
  const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft, fields));
130
132
  const children = kids.map((n) => link(n, byId, locale, draft, fields));
131
- // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
132
- const paths = Object.fromEntries(Object.entries(await pages.pathsOf(id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
133
+ // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present
134
+ // one. Derived from the tree already listed — no index lookup (spec 2026-09-15 §2).
135
+ const alternates = pageAlternates(record, byId, pathOpts, (l) => draft || record.locales[l]?.status === "published");
133
136
  return {
134
137
  kind: "page",
138
+ locale,
135
139
  record,
136
140
  template: record.template,
137
141
  tree,
@@ -140,7 +144,7 @@ export function createSmoodlySite(options) {
140
144
  fields: withDefaults(tree.fields, pageFields(record.template)),
141
145
  ancestors,
142
146
  children,
143
- paths,
147
+ locales: alternates,
144
148
  };
145
149
  }
146
150
  async function loadEntry(id, locale, path, draft) {
@@ -162,8 +166,11 @@ export function createSmoodlySite(options) {
162
166
  return null;
163
167
  const fields = await resolveFields(raw, schema.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
164
168
  // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
165
- const paths = Object.fromEntries(Object.entries(await entries.pathsOf(schema.name, id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
166
- return { kind: "entry", collection: schema.name, entry: { ...record, fields }, path, paths };
169
+ // One record is in memory (a nested entry's parents are not), so the `paths` index
170
+ // supplies the paths — the same single lookup as before (spec 2026-09-15 §2).
171
+ const known = await entries.pathsOf(schema.name, id);
172
+ const alternates = entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", (l) => known[l] ?? null);
173
+ return { kind: "entry", locale, collection: schema.name, entry: { ...record, fields }, path, locales: alternates };
167
174
  }
168
175
  return null;
169
176
  }
@@ -171,15 +178,21 @@ export function createSmoodlySite(options) {
171
178
  /** One locale's published fields and path for entries of one collection;
172
179
  * `all` is the whole collection (a draft ancestor still contributes
173
180
  * its segment). `fieldsById` is the published read for the locale. */
174
- function entryItems(schema, wanted, all, locale, fieldsById) {
181
+ function entryItems(schema, wanted, all, locale, draft, fieldsById) {
175
182
  const index = new Map(all.map((e) => [e.id, e]));
176
183
  const byId = (x) => index.get(x);
177
184
  return wanted
178
185
  .filter((record) => fieldsById[record.id] !== undefined)
179
- .map((record) => ({
180
- entry: { ...record, fields: fieldsById[record.id] },
181
- path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
182
- }));
186
+ .map((record) => {
187
+ const rows = entryPathRows([record], byId, schema, locales);
188
+ const pathIn = (l) => rows.find((r) => r.locale === l)?.path ?? null;
189
+ return {
190
+ entry: { ...record, fields: fieldsById[record.id] },
191
+ path: pathIn(locale),
192
+ locale,
193
+ locales: entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", pathIn),
194
+ };
195
+ });
183
196
  }
184
197
  async function getEntries(collection, o = {}) {
185
198
  const schema = schemaOf(options.collections, collection);
@@ -220,7 +233,7 @@ export function createSmoodlySite(options) {
220
233
  const fetchAssets = assetFetcher();
221
234
  if (fetchAssets)
222
235
  await resolveMediaSets(Object.values(fieldsById).map((values) => ({ values, descriptors: schema.fields })), fetchAssets, elements);
223
- return entryItems(schema, inLocale, all, locale, fieldsById);
236
+ return entryItems(schema, inLocale, all, locale, draft, fieldsById);
224
237
  }
225
238
  const api = {
226
239
  async getPagesTree(o) {
@@ -257,7 +270,7 @@ export function createSmoodlySite(options) {
257
270
  const locale = localeOf(o);
258
271
  if (idOrPath.startsWith("/")) {
259
272
  const hit = await site.resolve(locale, idOrPath, { draft: o?.draft ?? false });
260
- return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path } : null;
273
+ return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path, locale: hit.locale, locales: hit.locales } : null;
261
274
  }
262
275
  const items = await getEntries(collection, { locale, draft: o?.draft ?? false });
263
276
  const byId = items.find((i) => i.entry.id === idOrPath);
@@ -365,7 +365,7 @@ export class SupabaseEntryStore {
365
365
  }
366
366
  for (const [key, want] of Object.entries(options?.where ?? {})) {
367
367
  // Any-of compares the text form (`->>`). A scalar is JSONB containment;
368
- // an array-valued field (checkboxes, refList, list) contains `[value]`,
368
+ // an array-valued field (checkboxes, refList, list, tags) contains `[value]`,
369
369
  // a scalar field `value` — the memory adapter's matchesWhere agrees.
370
370
  if (Array.isArray(want)) {
371
371
  query = query.in(`fields->>${key}`, want.map(String));
package/dist/tags.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** Trimmed, inner whitespace runs collapsed to one space. */
2
+ export declare const normalizeTag: (s: string) => string;
3
+ /** Tags are one tag when they differ only in case. */
4
+ export declare const sameTag: (a: string, b: string) => boolean;
5
+ /** Commit typed or pasted text: split on commas, normalize, drop empties and
6
+ * duplicates of what is already there, stop at `max`. A part that matches a
7
+ * `known` tag takes its casing. `duplicate` is the existing tag the first
8
+ * dropped part hit, so the widget can flash that chip. */
9
+ export declare function addTags(current: readonly string[], input: string, opts?: {
10
+ max?: number;
11
+ known?: readonly string[];
12
+ }): {
13
+ tags: string[];
14
+ duplicate: string | null;
15
+ };
16
+ /** Every tag in a set of field values (each a tag array, or anything else), sorted. Where
17
+ * entries disagree on a tag's casing the most used one wins, a tie going to plain string
18
+ * order — never to the order the values arrived in, which is the store's business. */
19
+ export declare function distinctTags(values: readonly unknown[]): string[];
package/dist/tags.js ADDED
@@ -0,0 +1,50 @@
1
+ // The rules of an f.tags() value (spec 2026-09-17 §4) as pure functions:
2
+ // the chip widget, the validator and the suggestions op all go through
3
+ // these, so a tag means the same thing on every side.
4
+ /** Trimmed, inner whitespace runs collapsed to one space. */
5
+ export const normalizeTag = (s) => s.trim().replace(/\s+/g, " ");
6
+ const fold = (s) => s.toLocaleLowerCase();
7
+ /** Tags are one tag when they differ only in case. */
8
+ export const sameTag = (a, b) => fold(a) === fold(b);
9
+ /** Commit typed or pasted text: split on commas, normalize, drop empties and
10
+ * duplicates of what is already there, stop at `max`. A part that matches a
11
+ * `known` tag takes its casing. `duplicate` is the existing tag the first
12
+ * dropped part hit, so the widget can flash that chip. */
13
+ export function addTags(current, input, opts = {}) {
14
+ const tags = [...current];
15
+ let duplicate = null;
16
+ for (const part of input.split(",")) {
17
+ const typed = normalizeTag(part);
18
+ if (typed === "")
19
+ continue;
20
+ const tag = opts.known?.find((k) => sameTag(k, typed)) ?? typed;
21
+ const hit = tags.find((t) => sameTag(t, tag));
22
+ if (hit !== undefined) {
23
+ duplicate ?? (duplicate = hit);
24
+ continue;
25
+ }
26
+ if (typeof opts.max === "number" && tags.length >= opts.max)
27
+ break;
28
+ tags.push(tag);
29
+ }
30
+ return { tags, duplicate };
31
+ }
32
+ /** Every tag in a set of field values (each a tag array, or anything else), sorted. Where
33
+ * entries disagree on a tag's casing the most used one wins, a tie going to plain string
34
+ * order — never to the order the values arrived in, which is the store's business. */
35
+ export function distinctTags(values) {
36
+ const variants = new Map();
37
+ for (const value of values) {
38
+ if (!Array.isArray(value))
39
+ continue;
40
+ for (const tag of value) {
41
+ if (typeof tag !== "string" || tag === "")
42
+ continue;
43
+ const counts = variants.get(fold(tag)) ?? new Map();
44
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
45
+ variants.set(fold(tag), counts);
46
+ }
47
+ }
48
+ const pick = (counts) => [...counts].sort(([a, n], [b, m]) => m - n || (a < b ? -1 : 1))[0][0];
49
+ return [...variants.values()].map(pick).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {