@sevenfold/setto-client 0.29.0 → 0.30.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.
@@ -3,6 +3,28 @@ export interface SettoImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement
3
3
  /** i18n key for the public URL path, e.g. `hero.illustrationSrc`. */
4
4
  srcKey: string;
5
5
  }
6
+ /**
7
+ * What the served chrome is handed to draw over an image in edit mode: the
8
+ * picker with stock photographs, the camera, an upload and «prøv lykken».
9
+ *
10
+ * The chrome only ever gives the image a file. Where it came from — a
11
+ * phone's camera or Unsplash — is the chrome's business; how it becomes a
12
+ * draft and a published path is this component's, so every source ends up
13
+ * under `public/images/setto/` the same way.
14
+ */
15
+ export interface ImageToolsProps {
16
+ srcKey: string;
17
+ alt?: string;
18
+ /** The pointer is over the image. */
19
+ hovered: boolean;
20
+ /** An unpublished replacement is showing. */
21
+ hasDraft: boolean;
22
+ busy: boolean;
23
+ /** The rendered image, for its size and its place on the page. */
24
+ imageEl: HTMLImageElement | null;
25
+ applyFile: (file: File) => Promise<void>;
26
+ revert: () => void;
27
+ }
6
28
  /**
7
29
  * Renders an image whose src is stored in i18n. In edit mode, hover to upload
8
30
  * a replacement — saved under `public/images/setto/` on publish.
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { SettoBlock } from './SettoBlock';
11
11
  export { SettoIcon } from './SettoIcon';
12
12
  export type { SettoIconProps, SettoIconRenderProps } from './SettoIcon';
13
13
  export { SettoImage } from './SettoImage';
14
- export type { SettoImageProps } from './SettoImage';
14
+ export type { SettoImageProps, ImageToolsProps } from './SettoImage';
15
15
  export { SettoRepeater } from './SettoRepeater';
16
16
  export type { SettoRepeaterProps } from './SettoRepeater';
17
17
  export { SettoForm, FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS, fieldTypeHasOptions, fieldTypeHasPlaceholder, formFieldsPrefix, isFormFieldType, readLocaleFields, settoSubmissionUrl, } from './modules/forms';
@@ -24,6 +24,12 @@ export type { SettoVideoProps } from './SettoVideo';
24
24
  export { SettoMap } from './SettoMap';
25
25
  export type { SettoMapProps } from './SettoMap';
26
26
  export type { MapLocation, MapToolsProps, MapToolsComponent } from './map-tools';
27
+ export { SettoPage, SettoTextSection, usePageManifest, useSettoMenu, } from './pages/SettoPage';
28
+ export type { SettoPageProps, BuiltinSectionProps } from './pages/SettoPage';
29
+ export { PAGES_PATH, PAGES_MANIFEST_VERSION, BUILTIN_SECTION_TYPES, parseManifest, normalisePath, pageForPath, pageById, findSection, visibleMenu, applyOp, applyOps, builtinKeys, pageTitleKey, serialiseManifest, isSafeHref, ManifestError, } from './pages/manifest';
30
+ export type { PagesManifest, PageEntry, PageSection, MenuItem, MenuList, ManifestOp, ManifestIssue, ManifestParse, BuiltinSectionType, } from './pages/manifest';
31
+ export { PageStore, EMPTY_PAGE_STORE_SNAPSHOT, countChanges } from './lib/page-store';
32
+ export type { PageStoreSnapshot } from './lib/page-store';
27
33
  export { SettoAnimation } from './SettoAnimation';
28
34
  export type { SettoAnimationProps } from './SettoAnimation';
29
35
  export { useSectionTheme } from './use-section-theme';
@@ -0,0 +1,84 @@
1
+ import { type ManifestOp, type PagesManifest } from '../pages/manifest';
2
+ export interface PageStoreSnapshot {
3
+ manifest: PagesManifest | null;
4
+ /** How many pages, sections and links differ from what is published. */
5
+ changes: number;
6
+ version: number;
7
+ }
8
+ export declare const EMPTY_PAGE_STORE_SNAPSHOT: PageStoreSnapshot;
9
+ type Listener = (snap: PageStoreSnapshot) => void;
10
+ /**
11
+ * The page manifest as the owner is editing it.
12
+ *
13
+ * The same shape as `ThemeStore` — current against baseline, a snapshot for
14
+ * `useSyncExternalStore`, revert and commit — so the toolbar counts, discards
15
+ * and publishes it the way it does colours. Unlike the other stores it holds
16
+ * one document rather than a map of drafts: an ordering change is not a set of
17
+ * independent keys, and comparing the whole thing is what makes moving a
18
+ * section down and back up again a draft of nothing.
19
+ *
20
+ * `null` throughout for a site without a manifest — every site built before
21
+ * pages were data. `SettoPage` then renders the sections in the order the site
22
+ * listed them, and the structure tools stay out of the editor.
23
+ */
24
+ export declare class PageStore {
25
+ private data;
26
+ private baseline;
27
+ private version;
28
+ private listeners;
29
+ private cached;
30
+ constructor(initial: PagesManifest | null);
31
+ snapshot(): PageStoreSnapshot;
32
+ subscribe(listener: Listener): () => void;
33
+ get manifest(): PagesManifest | null;
34
+ /** True once the site has a manifest to edit. */
35
+ has(): boolean;
36
+ /**
37
+ * Apply changes as drafts. All of them or none: a list that fails halfway
38
+ * leaves the page exactly as it was and says which one failed.
39
+ */
40
+ apply(ops: readonly ManifestOp[]): {
41
+ ok: true;
42
+ } | {
43
+ ok: false;
44
+ error: string;
45
+ at: number;
46
+ };
47
+ /**
48
+ * Put the draft back to an earlier manifest, keeping the baseline.
49
+ *
50
+ * What «Angre» on one of Pixel's changes does: the panel remembers the
51
+ * manifest from before the change and hands it back here. Checked like
52
+ * anything else, because it has been through a round trip it did not have
53
+ * to survive.
54
+ */
55
+ restore(manifest: PagesManifest): {
56
+ ok: true;
57
+ } | {
58
+ ok: false;
59
+ error: string;
60
+ };
61
+ /**
62
+ * Replace the baseline with what the repository holds, dropping drafts.
63
+ *
64
+ * Checked first: the file in the repository can be newer than this bundle,
65
+ * or hand-edited, and a baseline this store cannot render must not replace
66
+ * the one it can. Returns whether it was taken.
67
+ */
68
+ loadBaseline(next: unknown): boolean;
69
+ revertAll(): void;
70
+ commit(): void;
71
+ /** Number of changes, for the toolbar's count. Zero means nothing to publish. */
72
+ size(): number;
73
+ serialise(): string | null;
74
+ private bump;
75
+ }
76
+ /**
77
+ * Roughly how many things the owner changed: each page whose sections or
78
+ * settings differ, each link that differs, each one added.
79
+ *
80
+ * Only has to be right about zero — that decides whether «Publiser» lights —
81
+ * and plausible otherwise, because it is shown as a count.
82
+ */
83
+ export declare function countChanges(before: PagesManifest | null, after: PagesManifest | null): number;
84
+ export {};
@@ -0,0 +1,63 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ import { type BuiltinSectionType, type MenuItem, type MenuList, type PagesManifest } from './manifest';
3
+ /**
4
+ * The manifest as it stands now, drafts included, re-rendering on change.
5
+ * `null` on a site that has none.
6
+ */
7
+ export declare function usePageManifest(): PagesManifest | null;
8
+ /**
9
+ * The links a menu or footer should show, or `null` when the site has no
10
+ * manifest and its own hard-coded list is still the truth.
11
+ *
12
+ * Already filtered: hidden links, links to hidden pages and anchors to hidden
13
+ * sections are gone. Render each label with `<T k={item.labelKey} />`.
14
+ */
15
+ export declare function useSettoMenu(list?: MenuList): MenuItem[] | null;
16
+ /** What a built-in section's renderer is given. */
17
+ export interface BuiltinSectionProps {
18
+ sectionId: string;
19
+ /** The first visible section on a page gets the page's `h1`. */
20
+ first: boolean;
21
+ }
22
+ export interface SettoPageProps {
23
+ /** The page to render. Wins over `path`. */
24
+ pageId?: string;
25
+ /** Or the one living at this path — the router's pathname, for a catch-all route. */
26
+ path?: string;
27
+ /**
28
+ * The site's own sections, by section id.
29
+ *
30
+ * The manifest decides their order and which are hidden. A component listed
31
+ * here that the manifest does not mention is rendered after the rest, so a
32
+ * section added in code never silently disappears.
33
+ */
34
+ sections?: Record<string, ComponentType>;
35
+ /** A site's own look for a built-in section type. */
36
+ builtins?: Partial<Record<BuiltinSectionType, ComponentType<BuiltinSectionProps>>>;
37
+ /** Shown when no page lives at `path`, or a visitor reaches a hidden one. */
38
+ fallback?: ReactNode;
39
+ }
40
+ /**
41
+ * A page, drawn from the manifest.
42
+ *
43
+ * Two ways in. A page the site wrote in code names itself:
44
+ *
45
+ * <SettoPage pageId="home" sections={{ hero: Hero, services: Services }} />
46
+ *
47
+ * and a catch-all route renders whatever the owner added since:
48
+ *
49
+ * <Route path="*" element={<SettoPage path={pathname} fallback={<NotFound />} />} />
50
+ *
51
+ * Without a manifest it renders `sections` in the order given, which is what
52
+ * the page did before it had one.
53
+ */
54
+ export declare function SettoPage({ pageId, path, sections, builtins, fallback }: SettoPageProps): import("react/jsx-runtime").JSX.Element;
55
+ /**
56
+ * Headings and paragraphs, for terms, privacy and the like.
57
+ *
58
+ * Styled inline and from the page it sits in — its font, its colours — because
59
+ * the library cannot know the site's Tailwind classes, and a Tailwind class
60
+ * written here would be purged from the site's CSS anyway. A site that wants
61
+ * its own look passes `builtins={{ text: MyText }}`.
62
+ */
63
+ export declare function SettoTextSection({ sectionId, first }: BuiltinSectionProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { type PagesManifest } from './manifest';
2
+ /**
3
+ * The manifest the site was built with, checked once.
4
+ *
5
+ * Read on the very first render, before the draft store exists, so the page
6
+ * never flashes its sections in code order — or a hidden one — before the
7
+ * store takes over. A file that fails the check is reported and ignored: the
8
+ * page then renders the way it would without one, which is a working site.
9
+ */
10
+ export declare function bundledManifest(raw: unknown): PagesManifest | null;
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The shape of a site: which pages it has, which sections each page shows and
3
+ * in what order, and what the menu and footer link to.
4
+ *
5
+ * Data rather than TSX, because the owner has to be able to change it. A page
6
+ * written as `<Hero /><Services /><Contact />` can only be reordered by someone
7
+ * who can edit code, so moving a section up, hiding it for the summer or adding
8
+ * a «Vilkår» page used to mean a developer. Kept in
9
+ * `src/content/pages.json`, which the inline editor publishes like any other
10
+ * content file.
11
+ *
12
+ * Nothing is ever deleted from here. «Remove» hides: a section, a page or a
13
+ * menu link that is hidden keeps its place and its text, and showing it again
14
+ * puts it back exactly as it was. The owner asked for undo, and a hidden entry
15
+ * is undo that survives a publish.
16
+ *
17
+ * Every function below is pure and returns a new manifest, so a draft can be
18
+ * compared with its baseline and a failed operation leaves nothing half-done.
19
+ */
20
+ export declare const PAGES_MANIFEST_VERSION = 1;
21
+ /** Where the manifest lives in a site repository. */
22
+ export declare const PAGES_PATH = "src/content/pages.json";
23
+ /**
24
+ * Sections Setto can render on its own, without code from the site.
25
+ *
26
+ * `text` is a heading, an optional intro and a list of paragraphs with
27
+ * optional headings: enough for terms, privacy, an «about» page. It is what
28
+ * lets Pixel add a «Vilkår» page in the chat instead of in a sandbox.
29
+ */
30
+ export declare const BUILTIN_SECTION_TYPES: readonly ["text"];
31
+ export type BuiltinSectionType = (typeof BUILTIN_SECTION_TYPES)[number];
32
+ export interface PageSection {
33
+ /** Unique across the whole site: it is also the theme id and the anchor. */
34
+ id: string;
35
+ /** A built-in section. Absent means a component the site registers. */
36
+ type?: BuiltinSectionType;
37
+ hidden?: boolean;
38
+ /** What the editor calls it («Tjenester»). Falls back to the id. */
39
+ label?: string;
40
+ }
41
+ export interface PageEntry {
42
+ id: string;
43
+ /** `/`, `/vilkar`. Never under `/setto`, which is the editor's. */
44
+ path: string;
45
+ /** Locale key for the page's name, used by the menu and the tab title. */
46
+ titleKey: string;
47
+ hidden?: boolean;
48
+ sections: PageSection[];
49
+ }
50
+ export interface MenuItem {
51
+ id: string;
52
+ /** `/vilkar`, `/#tjenester`, or an absolute `https://` link. */
53
+ href: string;
54
+ labelKey: string;
55
+ hidden?: boolean;
56
+ }
57
+ export type MenuList = 'menu' | 'footer';
58
+ export interface PagesManifest {
59
+ version: typeof PAGES_MANIFEST_VERSION;
60
+ pages: PageEntry[];
61
+ menu: MenuItem[];
62
+ footer: MenuItem[];
63
+ }
64
+ export interface ManifestIssue {
65
+ at: string;
66
+ message: string;
67
+ }
68
+ export type ManifestParse = {
69
+ ok: true;
70
+ manifest: PagesManifest;
71
+ } | {
72
+ ok: false;
73
+ issues: ManifestIssue[];
74
+ };
75
+ /**
76
+ * Check a manifest from disk, from the network or from a model.
77
+ *
78
+ * Strict on what would break a page — duplicate ids, a path the router cannot
79
+ * hold, a section type nothing renders — and silent about fields it does not
80
+ * know, so a newer editor's manifest still renders on an older site.
81
+ */
82
+ export declare function parseManifest(input: unknown): ManifestParse;
83
+ /** A path on this site, an anchor, or an http(s)/mailto/tel link. Never `javascript:`. */
84
+ export declare function isSafeHref(href: string): boolean;
85
+ /** `/vilkar/` and `/vilkar` are one page; the empty path is the front page. */
86
+ export declare function normalisePath(path: string): string;
87
+ export declare function pageForPath(manifest: PagesManifest, path: string): PageEntry | null;
88
+ export declare function pageById(manifest: PagesManifest, id: string): PageEntry | null;
89
+ /** The page a section is on, and where. */
90
+ export declare function findSection(manifest: PagesManifest, sectionId: string): {
91
+ page: PageEntry;
92
+ index: number;
93
+ section: PageSection;
94
+ } | null;
95
+ /**
96
+ * The links a visitor should see.
97
+ *
98
+ * Hidden items go, and so do items that would lead nowhere: a link to a
99
+ * hidden page, or an anchor to a hidden section. Hiding «Tjenester» for the
100
+ * winter should not leave a menu entry that scrolls to nothing.
101
+ */
102
+ export declare function visibleMenu(manifest: PagesManifest, list: MenuList): MenuItem[];
103
+ export declare class ManifestError extends Error {
104
+ readonly code: 'page_not_found' | 'section_not_found' | 'item_not_found' | 'duplicate_id' | 'duplicate_path' | 'invalid';
105
+ constructor(code: 'page_not_found' | 'section_not_found' | 'item_not_found' | 'duplicate_id' | 'duplicate_path' | 'invalid', message: string);
106
+ }
107
+ /**
108
+ * Move a section up (negative) or down (positive) on its page.
109
+ *
110
+ * Skips over hidden neighbours: moving «Om oss» up past a hidden section
111
+ * would otherwise do nothing the owner can see, and look broken. Returns the
112
+ * manifest unchanged at either end rather than throwing — the button is
113
+ * simply at the edge.
114
+ */
115
+ export declare function moveSection(manifest: PagesManifest, sectionId: string, delta: -1 | 1): PagesManifest;
116
+ /** Put a section at an exact position among its page's sections. */
117
+ export declare function placeSection(manifest: PagesManifest, sectionId: string, index: number): PagesManifest;
118
+ export declare function setSectionHidden(manifest: PagesManifest, sectionId: string, hidden: boolean): PagesManifest;
119
+ export declare function addSection(manifest: PagesManifest, pageId: string, section: PageSection, index?: number): PagesManifest;
120
+ export declare function addPage(manifest: PagesManifest, page: PageEntry): PagesManifest;
121
+ export declare function setPageHidden(manifest: PagesManifest, pageId: string, hidden: boolean): PagesManifest;
122
+ /** Add a link, or change the one with the same id. */
123
+ export declare function upsertMenuItem(manifest: PagesManifest, list: MenuList, item: MenuItem, index?: number): PagesManifest;
124
+ export declare function moveMenuItem(manifest: PagesManifest, list: MenuList, itemId: string, delta: -1 | 1): PagesManifest;
125
+ export declare function setMenuItemHidden(manifest: PagesManifest, list: MenuList, itemId: string, hidden: boolean): PagesManifest;
126
+ /**
127
+ * One change, as data.
128
+ *
129
+ * What Pixel's structure tools return and what the editor applies, so a
130
+ * suggestion from the chat and a click in the toolbar go through the same
131
+ * functions — and a change the model gets wrong fails here, in a way the
132
+ * owner never sees half of.
133
+ */
134
+ export type ManifestOp = {
135
+ op: 'move_section';
136
+ sectionId: string;
137
+ delta: -1 | 1;
138
+ } | {
139
+ op: 'place_section';
140
+ sectionId: string;
141
+ index: number;
142
+ } | {
143
+ op: 'hide_section';
144
+ sectionId: string;
145
+ } | {
146
+ op: 'show_section';
147
+ sectionId: string;
148
+ } | {
149
+ op: 'add_section';
150
+ pageId: string;
151
+ section: PageSection;
152
+ index?: number;
153
+ } | {
154
+ op: 'add_page';
155
+ page: PageEntry;
156
+ } | {
157
+ op: 'hide_page';
158
+ pageId: string;
159
+ } | {
160
+ op: 'show_page';
161
+ pageId: string;
162
+ } | {
163
+ op: 'upsert_link';
164
+ list: MenuList;
165
+ item: MenuItem;
166
+ index?: number;
167
+ } | {
168
+ op: 'move_link';
169
+ list: MenuList;
170
+ itemId: string;
171
+ delta: -1 | 1;
172
+ } | {
173
+ op: 'hide_link';
174
+ list: MenuList;
175
+ itemId: string;
176
+ } | {
177
+ op: 'show_link';
178
+ list: MenuList;
179
+ itemId: string;
180
+ };
181
+ export declare function applyOp(manifest: PagesManifest, op: ManifestOp): PagesManifest;
182
+ /** Every op or none: a list that fails halfway returns the error and no manifest. */
183
+ export declare function applyOps(manifest: PagesManifest, ops: readonly ManifestOp[]): {
184
+ ok: true;
185
+ manifest: PagesManifest;
186
+ } | {
187
+ ok: false;
188
+ error: string;
189
+ at: number;
190
+ };
191
+ /** Where a built-in section keeps its words in the locale files. */
192
+ export declare function builtinKeys(sectionId: string): {
193
+ heading: string;
194
+ intro: string;
195
+ items: string;
196
+ itemHeading: (itemKey: string) => string;
197
+ itemText: (itemKey: string) => string;
198
+ };
199
+ /** The default locale key for a page's name. */
200
+ export declare function pageTitleKey(pageId: string): string;
201
+ /** Stable JSON for the file, so an unchanged manifest is an unchanged file. */
202
+ export declare function serialiseManifest(manifest: PagesManifest): string;
@@ -4,6 +4,7 @@ import { type SettoApi } from './lib/api';
4
4
  import { I18nStore } from './lib/i18n-store';
5
5
  import { AssetStore } from './lib/asset-store';
6
6
  import { ThemeStore } from './lib/theme-store';
7
+ import { PageStore } from './lib/page-store';
7
8
  import type { SettoConfig, SiteRow } from './types';
8
9
  export interface SettoContextValue {
9
10
  config: SettoConfig;
@@ -25,6 +26,12 @@ export interface SettoContextValue {
25
26
  store: I18nStore | null;
26
27
  themeStore: ThemeStore | null;
27
28
  assetStore: AssetStore | null;
29
+ /**
30
+ * The page manifest being edited: section order, hidden sections, pages and
31
+ * menu links. Holds `null` inside for a site that has no `pages.json`.
32
+ * Absent on a client older than 0.29, which the chrome has to allow for.
33
+ */
34
+ pageStore: PageStore | null;
28
35
  /** The section the editor has selected, or null. */
29
36
  selectedSectionId: string | null;
30
37
  selectSection: (id: string | null) => void;