@joai/sites 0.0.0-stage → 0.1.1
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/dist/admin.d.mts +25 -0
- package/dist/admin.mjs +60 -0
- package/dist/editable.d.mts +35 -0
- package/dist/editable.mjs +61 -0
- package/dist/index.d.mts +255 -0
- package/dist/index.mjs +263 -0
- package/dist/session.d.mts +17 -0
- package/dist/session.mjs +30 -0
- package/locales/de.json +6 -0
- package/locales/en.json +6 -0
- package/locales/es.json +6 -0
- package/locales/fr.json +6 -0
- package/locales/it.json +6 -0
- package/locales/ro.json +6 -0
- package/package.json +58 -4
- package/README.md +0 -3
package/dist/admin.d.mts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import * as _$react from "react";
|
|
3
|
+
import { AppEnv } from "@joai/core";
|
|
4
|
+
//#region src/session.d.ts
|
|
5
|
+
type SiteEditingSessionConfig = {
|
|
6
|
+
env?: AppEnv;
|
|
7
|
+
team: string;
|
|
8
|
+
brand?: string;
|
|
9
|
+
sessionStorageKey: string; /** Path for the admin login route (default `/admin-login`). */
|
|
10
|
+
loginPath?: string;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/admin-login.d.ts
|
|
14
|
+
type SiteAdminLoginClientProps = {
|
|
15
|
+
config: SiteEditingSessionConfig; /** Where to send the user after a successful exchange. Defaults to `/`. */
|
|
16
|
+
redirectTo?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
declare function SiteAdminLoginClient({
|
|
20
|
+
config,
|
|
21
|
+
redirectTo,
|
|
22
|
+
className
|
|
23
|
+
}: SiteAdminLoginClientProps): _$react.JSX.Element;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { SiteAdminLoginClient, type SiteAdminLoginClientProps };
|
package/dist/admin.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { getAuthFragmentToken } from "@joai/sdk";
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { getLoginUrl } from "@joai/core";
|
|
6
|
+
import { createSessionTokenStore } from "@joai/react";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
//#region src/session.ts
|
|
9
|
+
function createSiteLoginUrl(config, redirect) {
|
|
10
|
+
const returnUrl = new URL(redirect);
|
|
11
|
+
returnUrl.searchParams.set("platform", "app");
|
|
12
|
+
return getLoginUrl(config.env ?? "mainnet", returnUrl.toString(), {
|
|
13
|
+
team: config.team,
|
|
14
|
+
brand: config.brand
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function createSiteSessionStore(config) {
|
|
18
|
+
return createSessionTokenStore(config.sessionStorageKey);
|
|
19
|
+
}
|
|
20
|
+
async function exchangeSiteEditingSession(config, handoffToken) {
|
|
21
|
+
const { createClient } = await import("@joai/sdk");
|
|
22
|
+
const response = await createClient({
|
|
23
|
+
env: config.env ?? "mainnet",
|
|
24
|
+
team: config.team,
|
|
25
|
+
cache: { enabled: false }
|
|
26
|
+
}).auth.exchangeNativeSession(handoffToken);
|
|
27
|
+
if (!response.ok || !response.data?.token) throw new Error("session_exchange_failed");
|
|
28
|
+
if (!createSiteSessionStore(config).set(response.data.token)) throw new Error("session_store_failed");
|
|
29
|
+
return response.data.token;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/admin-login.tsx
|
|
33
|
+
function SiteAdminLoginClient({ config, redirectTo = "/", className }) {
|
|
34
|
+
const t = useTranslations("sites");
|
|
35
|
+
const [message, setMessage] = useState(t("loginRedirecting"));
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const loginUrl = () => createSiteLoginUrl(config, `${window.location.origin}${window.location.pathname}`);
|
|
38
|
+
const token = getAuthFragmentToken(window.location.href);
|
|
39
|
+
if (!token) {
|
|
40
|
+
window.location.replace(loginUrl());
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
|
|
44
|
+
exchangeSiteEditingSession(config, token).then(() => window.location.replace(redirectTo)).catch(() => {
|
|
45
|
+
setMessage(t("loginFailed"));
|
|
46
|
+
window.setTimeout(() => window.location.replace(loginUrl()), 1200);
|
|
47
|
+
});
|
|
48
|
+
}, [
|
|
49
|
+
config,
|
|
50
|
+
redirectTo,
|
|
51
|
+
t
|
|
52
|
+
]);
|
|
53
|
+
return /* @__PURE__ */ jsx("main", {
|
|
54
|
+
className,
|
|
55
|
+
"data-site-admin-login": "true",
|
|
56
|
+
children: /* @__PURE__ */ jsx("p", { children: message })
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { SiteAdminLoginClient };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { EditableContentProp, EditableImageProp } from "@joai/react";
|
|
3
|
+
import * as _$react from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/editable.d.ts
|
|
6
|
+
type EditableSiteTextProps = Omit<EditableContentProp, 'field'> & {
|
|
7
|
+
field: string;
|
|
8
|
+
fallback: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Editable text with a local fallback while the JoAi content has no value yet.
|
|
12
|
+
* When editing starts, the fallback is seeded into the draft so the field is visible.
|
|
13
|
+
*/
|
|
14
|
+
declare function EditableSiteText({
|
|
15
|
+
field,
|
|
16
|
+
fallback,
|
|
17
|
+
...props
|
|
18
|
+
}: EditableSiteTextProps): _$react.JSX.Element;
|
|
19
|
+
type EditableSiteImageProps = Omit<EditableImageProp, 'field' | 'alt'> & {
|
|
20
|
+
field: string;
|
|
21
|
+
fallback: string;
|
|
22
|
+
alt: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Editable image with a local fallback while the JoAi content has no value yet.
|
|
26
|
+
*/
|
|
27
|
+
declare function EditableSiteImage({
|
|
28
|
+
field,
|
|
29
|
+
fallback,
|
|
30
|
+
alt,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: EditableSiteImageProps): _$react.JSX.Element;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { EditableSiteImage, EditableSiteImageProps, EditableSiteText, EditableSiteTextProps };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
"use client";
|
|
3
|
+
import { EditableContent, EditableImage, useContent, useContentValue } from "@joai/react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
//#region src/editable.tsx
|
|
7
|
+
/**
|
|
8
|
+
* Editable text with a local fallback while the JoAi content has no value yet.
|
|
9
|
+
* When editing starts, the fallback is seeded into the draft so the field is visible.
|
|
10
|
+
*/
|
|
11
|
+
function EditableSiteText({ field, fallback, ...props }) {
|
|
12
|
+
const { isEditing, setValue } = useContent();
|
|
13
|
+
const value = useContentValue(field);
|
|
14
|
+
const text = typeof value === "string" || typeof value === "number" ? String(value) : "";
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (isEditing && value === void 0) setValue(field, fallback);
|
|
17
|
+
}, [
|
|
18
|
+
fallback,
|
|
19
|
+
field,
|
|
20
|
+
isEditing,
|
|
21
|
+
setValue,
|
|
22
|
+
value
|
|
23
|
+
]);
|
|
24
|
+
if (!isEditing && !text) return /* @__PURE__ */ jsx(props.as ?? "span", {
|
|
25
|
+
className: props.className,
|
|
26
|
+
children: fallback
|
|
27
|
+
});
|
|
28
|
+
return /* @__PURE__ */ jsx(EditableContent, {
|
|
29
|
+
field,
|
|
30
|
+
...props
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Editable image with a local fallback while the JoAi content has no value yet.
|
|
35
|
+
*/
|
|
36
|
+
function EditableSiteImage({ field, fallback, alt, className, ...props }) {
|
|
37
|
+
const { isEditing, setValue } = useContent();
|
|
38
|
+
const value = useContentValue(field);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (isEditing && value === void 0) setValue(field, fallback);
|
|
41
|
+
}, [
|
|
42
|
+
fallback,
|
|
43
|
+
field,
|
|
44
|
+
isEditing,
|
|
45
|
+
setValue,
|
|
46
|
+
value
|
|
47
|
+
]);
|
|
48
|
+
if (!isEditing) return /* @__PURE__ */ jsx("img", {
|
|
49
|
+
src: typeof value === "string" && value ? value : fallback,
|
|
50
|
+
alt,
|
|
51
|
+
className
|
|
52
|
+
});
|
|
53
|
+
return /* @__PURE__ */ jsx(EditableImage, {
|
|
54
|
+
field,
|
|
55
|
+
alt,
|
|
56
|
+
className,
|
|
57
|
+
...props
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { EditableSiteImage, EditableSiteText };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as _$_joai_core0 from "@joai/core";
|
|
2
|
+
import { AppEnv, Content, ContentDefinition, ContentValue, Site } from "@joai/core";
|
|
3
|
+
import * as _$_joai_react0 from "@joai/react";
|
|
4
|
+
import { ComponentType, ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
//#region src/types.d.ts
|
|
7
|
+
/** Path key for a theme page (`/` for home, `/about`, …). */
|
|
8
|
+
type SiteThemePath = string;
|
|
9
|
+
type SiteThemePageProps = {
|
|
10
|
+
site: Site;
|
|
11
|
+
content: Content;
|
|
12
|
+
params?: Record<string, string | string[]>;
|
|
13
|
+
searchParams?: Record<string, string | string[] | undefined>;
|
|
14
|
+
};
|
|
15
|
+
type SiteThemeMetadata = {
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
canonicalPath?: string;
|
|
19
|
+
openGraph?: {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
images?: Array<{
|
|
23
|
+
url: string;
|
|
24
|
+
width?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
alt?: string;
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
robots?: {
|
|
30
|
+
index?: boolean;
|
|
31
|
+
follow?: boolean;
|
|
32
|
+
};
|
|
33
|
+
icons?: {
|
|
34
|
+
icon?: string | string[];
|
|
35
|
+
shortcut?: string;
|
|
36
|
+
apple?: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
type SiteThemePage = {
|
|
40
|
+
Component: ComponentType<SiteThemePageProps>;
|
|
41
|
+
metadata?: (ctx: SiteThemePageProps) => SiteThemeMetadata | Promise<SiteThemeMetadata>;
|
|
42
|
+
};
|
|
43
|
+
type SiteThemeLayoutProps = {
|
|
44
|
+
site: Site;
|
|
45
|
+
content: Content;
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Stable theme contract for host apps.
|
|
50
|
+
* `slug` is the theme key from site settings (`settings.theme`), never the brand slug.
|
|
51
|
+
*/
|
|
52
|
+
type SiteThemeLocalConfig = {
|
|
53
|
+
team: string;
|
|
54
|
+
contentSlug: string;
|
|
55
|
+
contentId?: string;
|
|
56
|
+
contentName?: string;
|
|
57
|
+
contentDefinition?: ContentDefinition;
|
|
58
|
+
contentValues?: Record<string, ContentValue>;
|
|
59
|
+
siteName?: string;
|
|
60
|
+
siteUrl?: string;
|
|
61
|
+
};
|
|
62
|
+
type SiteTheme = {
|
|
63
|
+
slug: string;
|
|
64
|
+
pages: Record<SiteThemePath, SiteThemePage>;
|
|
65
|
+
Layout?: ComponentType<SiteThemeLayoutProps>; /** Absolute import id or relative path the host uses to load theme-scoped CSS. */
|
|
66
|
+
styles?: string;
|
|
67
|
+
metadata?: (ctx: {
|
|
68
|
+
site: Site;
|
|
69
|
+
content: Content;
|
|
70
|
+
}) => SiteThemeMetadata | Promise<SiteThemeMetadata>;
|
|
71
|
+
jsonLd?: (ctx: {
|
|
72
|
+
site: Site;
|
|
73
|
+
content: Content;
|
|
74
|
+
}) => Record<string, unknown> | Record<string, unknown>[];
|
|
75
|
+
sitemapPaths?: SiteThemePath[];
|
|
76
|
+
llmsTxt?: (ctx: {
|
|
77
|
+
site: Site;
|
|
78
|
+
content: Content;
|
|
79
|
+
}) => string; /** Used by the host when a local subdomain has no provisioned JoAi site yet. */
|
|
80
|
+
local?: SiteThemeLocalConfig;
|
|
81
|
+
};
|
|
82
|
+
type ResolvedSiteContext = {
|
|
83
|
+
site: Site;
|
|
84
|
+
content: Content;
|
|
85
|
+
themeKey: string;
|
|
86
|
+
contentSlug: string;
|
|
87
|
+
};
|
|
88
|
+
type SiteHostConfig = {
|
|
89
|
+
env?: _$_joai_core0.AppEnv; /** Shared secret expected as Bearer token on the revalidate route. */
|
|
90
|
+
revalidateSecret: string; /** Default ISR window in seconds when a page does not override it. */
|
|
91
|
+
revalidateSeconds?: number;
|
|
92
|
+
};
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/settings.d.ts
|
|
95
|
+
/** Theme key from brand settings (`settings.theme`). Not the site brand slug. */
|
|
96
|
+
declare function getSiteThemeKey(site: Site): string | null;
|
|
97
|
+
/** Public content slug from brand settings (`settings.content`). */
|
|
98
|
+
declare function getSiteContentSlug(site: Site): string | null;
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/resolve.d.ts
|
|
101
|
+
type ResolveSiteOptions = {
|
|
102
|
+
env?: AppEnv;
|
|
103
|
+
team?: string;
|
|
104
|
+
};
|
|
105
|
+
/** Normalize a Host header / URL host to the domain form used by sites.resolve. */
|
|
106
|
+
declare function normalizeSiteHost(host: string): string;
|
|
107
|
+
declare function resolveSiteByHost(host: string, options?: ResolveSiteOptions): Promise<Site | null>;
|
|
108
|
+
declare function loadSiteContent(site: Site, options?: ResolveSiteOptions & {
|
|
109
|
+
slug?: string;
|
|
110
|
+
}): Promise<Content | null>;
|
|
111
|
+
declare function resolveSiteContext(host: string, options?: ResolveSiteOptions): Promise<ResolvedSiteContext | null>;
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/revalidate.d.ts
|
|
114
|
+
type RevalidateHandlerOptions = {
|
|
115
|
+
secret: string; /** Extra work after the default path/tag revalidation (optional). */
|
|
116
|
+
onRevalidate?: (contentSlug: string) => void | Promise<void>; /** Tag prefix used with `revalidateTag`. Defaults to `content`. */
|
|
117
|
+
tagPrefix?: string; /** Paths to revalidate. Defaults to `/` (full site). */
|
|
118
|
+
paths?: string[];
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Secret-protected Next.js route handler for publish-time revalidation pings.
|
|
122
|
+
* Expects POST JSON `{ content: "<slug>" }` and `Authorization: Bearer <secret>`.
|
|
123
|
+
*/
|
|
124
|
+
declare function createRevalidateHandler(options: RevalidateHandlerOptions): (request: Request) => Promise<Response>;
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/session.d.ts
|
|
127
|
+
type SiteEditingSessionConfig = {
|
|
128
|
+
env?: AppEnv;
|
|
129
|
+
team: string;
|
|
130
|
+
brand?: string;
|
|
131
|
+
sessionStorageKey: string; /** Path for the admin login route (default `/admin-login`). */
|
|
132
|
+
loginPath?: string;
|
|
133
|
+
};
|
|
134
|
+
declare function createSiteLoginUrl(config: SiteEditingSessionConfig, redirect: string): string;
|
|
135
|
+
declare function createSiteSessionStore(config: SiteEditingSessionConfig): _$_joai_react0.SessionTokenStore;
|
|
136
|
+
declare function exchangeSiteEditingSession(config: SiteEditingSessionConfig, handoffToken: string): Promise<string>;
|
|
137
|
+
declare function getSiteAdminLoginPath(config: SiteEditingSessionConfig): string;
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/theme.d.ts
|
|
140
|
+
declare function defineSiteTheme(theme: SiteTheme): SiteTheme;
|
|
141
|
+
declare function createThemeRegistry(themes: SiteTheme[]): Map<string, SiteTheme>;
|
|
142
|
+
declare function getThemePage(theme: SiteTheme, path: SiteThemePath): SiteThemePage;
|
|
143
|
+
declare function listThemeSitemapPaths(theme: SiteTheme): SiteThemePath[];
|
|
144
|
+
declare function normalizeThemePath(path: SiteThemePath): SiteThemePath;
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/seo/metadata.d.ts
|
|
147
|
+
type BuildSiteMetadataInput = {
|
|
148
|
+
site: Site;
|
|
149
|
+
baseUrl: string;
|
|
150
|
+
metadata: SiteThemeMetadata;
|
|
151
|
+
locale?: string;
|
|
152
|
+
};
|
|
153
|
+
type SiteMetadataResult = {
|
|
154
|
+
title: string;
|
|
155
|
+
description?: string;
|
|
156
|
+
alternates: {
|
|
157
|
+
canonical: string;
|
|
158
|
+
};
|
|
159
|
+
openGraph: {
|
|
160
|
+
title: string;
|
|
161
|
+
description?: string;
|
|
162
|
+
url: string;
|
|
163
|
+
siteName: string;
|
|
164
|
+
locale?: string;
|
|
165
|
+
type: 'website';
|
|
166
|
+
images?: Array<{
|
|
167
|
+
url: string;
|
|
168
|
+
width?: number;
|
|
169
|
+
height?: number;
|
|
170
|
+
alt?: string;
|
|
171
|
+
}>;
|
|
172
|
+
};
|
|
173
|
+
robots?: {
|
|
174
|
+
index?: boolean;
|
|
175
|
+
follow?: boolean;
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
declare function buildSiteMetadata({
|
|
179
|
+
site,
|
|
180
|
+
baseUrl,
|
|
181
|
+
metadata,
|
|
182
|
+
locale
|
|
183
|
+
}: BuildSiteMetadataInput): SiteMetadataResult;
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/seo/sitemap.d.ts
|
|
186
|
+
type SiteSitemapEntry = {
|
|
187
|
+
url: string;
|
|
188
|
+
lastModified?: Date | string;
|
|
189
|
+
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
190
|
+
priority?: number;
|
|
191
|
+
};
|
|
192
|
+
declare function buildSiteSitemap(baseUrl: string, paths: SiteThemePath[], options?: {
|
|
193
|
+
lastModified?: Date | string;
|
|
194
|
+
changeFrequency?: SiteSitemapEntry['changeFrequency'];
|
|
195
|
+
homePriority?: number;
|
|
196
|
+
defaultPriority?: number;
|
|
197
|
+
}): SiteSitemapEntry[];
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/seo/robots.d.ts
|
|
200
|
+
type SiteRobotsResult = {
|
|
201
|
+
rules: Array<{
|
|
202
|
+
userAgent: string;
|
|
203
|
+
allow?: string | string[];
|
|
204
|
+
disallow?: string | string[];
|
|
205
|
+
}>;
|
|
206
|
+
sitemap: string;
|
|
207
|
+
host?: string;
|
|
208
|
+
};
|
|
209
|
+
declare function buildSiteRobots(baseUrl: string, options?: {
|
|
210
|
+
disallow?: string | string[];
|
|
211
|
+
allow?: string | string[];
|
|
212
|
+
}): SiteRobotsResult;
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/seo/json-ld.d.ts
|
|
215
|
+
type LocalBusinessJsonLdInput = {
|
|
216
|
+
type?: 'LocalBusiness' | 'SportsClub' | 'HealthAndBeautyBusiness' | 'HomeAndConstructionBusiness' | string;
|
|
217
|
+
name: string;
|
|
218
|
+
url: string;
|
|
219
|
+
description?: string;
|
|
220
|
+
telephone?: string;
|
|
221
|
+
email?: string;
|
|
222
|
+
image?: string | string[];
|
|
223
|
+
address?: {
|
|
224
|
+
streetAddress?: string;
|
|
225
|
+
addressLocality?: string;
|
|
226
|
+
postalCode?: string;
|
|
227
|
+
addressCountry?: string;
|
|
228
|
+
addressRegion?: string;
|
|
229
|
+
};
|
|
230
|
+
openingHours?: string[];
|
|
231
|
+
sameAs?: string[];
|
|
232
|
+
geo?: {
|
|
233
|
+
latitude: number;
|
|
234
|
+
longitude: number;
|
|
235
|
+
};
|
|
236
|
+
priceRange?: string;
|
|
237
|
+
};
|
|
238
|
+
declare function buildLocalBusinessJsonLd(input: LocalBusinessJsonLdInput): Record<string, unknown>;
|
|
239
|
+
declare function serializeJsonLd(data: Record<string, unknown> | Record<string, unknown>[]): string;
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/seo/llms-txt.d.ts
|
|
242
|
+
type LlmsTxtPage = {
|
|
243
|
+
title: string;
|
|
244
|
+
path: string;
|
|
245
|
+
summary?: string;
|
|
246
|
+
};
|
|
247
|
+
declare function buildLlmsTxt(input: {
|
|
248
|
+
name: string;
|
|
249
|
+
description?: string;
|
|
250
|
+
baseUrl: string;
|
|
251
|
+
pages: LlmsTxtPage[];
|
|
252
|
+
contact?: string;
|
|
253
|
+
}): string;
|
|
254
|
+
//#endregion
|
|
255
|
+
export { BuildSiteMetadataInput, LlmsTxtPage, LocalBusinessJsonLdInput, ResolveSiteOptions, ResolvedSiteContext, RevalidateHandlerOptions, SiteEditingSessionConfig, SiteHostConfig, SiteMetadataResult, SiteRobotsResult, SiteSitemapEntry, SiteTheme, SiteThemeLayoutProps, SiteThemeLocalConfig, SiteThemeMetadata, SiteThemePage, SiteThemePageProps, SiteThemePath, buildLlmsTxt, buildLocalBusinessJsonLd, buildSiteMetadata, buildSiteRobots, buildSiteSitemap, createRevalidateHandler, createSiteLoginUrl, createSiteSessionStore, createThemeRegistry, defineSiteTheme, exchangeSiteEditingSession, getSiteAdminLoginPath, getSiteContentSlug, getSiteThemeKey, getThemePage, listThemeSitemapPaths, loadSiteContent, normalizeSiteHost, normalizeThemePath, resolveSiteByHost, resolveSiteContext, serializeJsonLd };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createClient } from "@joai/sdk";
|
|
2
|
+
import { revalidatePath, revalidateTag } from "next/cache";
|
|
3
|
+
import { getLoginUrl } from "@joai/core";
|
|
4
|
+
import { createSessionTokenStore } from "@joai/react";
|
|
5
|
+
//#region src/settings.ts
|
|
6
|
+
const asString = (value) => {
|
|
7
|
+
if (typeof value !== "string") return null;
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
return trimmed === "" ? null : trimmed;
|
|
10
|
+
};
|
|
11
|
+
/** Theme key from brand settings (`settings.theme`). Not the site brand slug. */
|
|
12
|
+
function getSiteThemeKey(site) {
|
|
13
|
+
return asString(site.settings?.theme);
|
|
14
|
+
}
|
|
15
|
+
/** Public content slug from brand settings (`settings.content`). */
|
|
16
|
+
function getSiteContentSlug(site) {
|
|
17
|
+
return asString(site.settings?.content);
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/resolve.ts
|
|
21
|
+
function createPublicClient(options = {}) {
|
|
22
|
+
return createClient({
|
|
23
|
+
env: options.env ?? "mainnet",
|
|
24
|
+
team: options.team,
|
|
25
|
+
cache: { enabled: false }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Normalize a Host header / URL host to the domain form used by sites.resolve. */
|
|
29
|
+
function normalizeSiteHost(host) {
|
|
30
|
+
const trimmed = host.trim().toLowerCase();
|
|
31
|
+
if (!trimmed) return "";
|
|
32
|
+
try {
|
|
33
|
+
return (trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`)).hostname;
|
|
34
|
+
} catch {
|
|
35
|
+
return trimmed.split("/")[0]?.split(":")[0] ?? trimmed;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function resolveSiteByHost(host, options = {}) {
|
|
39
|
+
const domain = normalizeSiteHost(host);
|
|
40
|
+
if (!domain) return null;
|
|
41
|
+
const response = await createPublicClient(options).sites.resolveByDomain(domain);
|
|
42
|
+
return response.ok ? response.data : null;
|
|
43
|
+
}
|
|
44
|
+
async function loadSiteContent(site, options = {}) {
|
|
45
|
+
const slug = options.slug ?? getSiteContentSlug(site);
|
|
46
|
+
if (!slug) return null;
|
|
47
|
+
const response = await createPublicClient({
|
|
48
|
+
env: options.env,
|
|
49
|
+
team: options.team ?? site.team?.slug ?? void 0
|
|
50
|
+
}).content.getPublic(slug);
|
|
51
|
+
return response.ok ? response.data : null;
|
|
52
|
+
}
|
|
53
|
+
async function resolveSiteContext(host, options = {}) {
|
|
54
|
+
const site = await resolveSiteByHost(host, options);
|
|
55
|
+
if (!site) return null;
|
|
56
|
+
const themeKey = getSiteThemeKey(site);
|
|
57
|
+
const contentSlug = getSiteContentSlug(site);
|
|
58
|
+
if (!themeKey || !contentSlug) return null;
|
|
59
|
+
const content = await loadSiteContent(site, {
|
|
60
|
+
...options,
|
|
61
|
+
slug: contentSlug
|
|
62
|
+
});
|
|
63
|
+
if (!content) return null;
|
|
64
|
+
return {
|
|
65
|
+
site,
|
|
66
|
+
content,
|
|
67
|
+
themeKey,
|
|
68
|
+
contentSlug
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/revalidate.ts
|
|
73
|
+
function extractBearer(request) {
|
|
74
|
+
const header = request.headers.get("authorization") ?? request.headers.get("Authorization");
|
|
75
|
+
if (!header) return null;
|
|
76
|
+
return /^Bearer\s+(.+)$/i.exec(header.trim())?.[1]?.trim() || null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Secret-protected Next.js route handler for publish-time revalidation pings.
|
|
80
|
+
* Expects POST JSON `{ content: "<slug>" }` and `Authorization: Bearer <secret>`.
|
|
81
|
+
*/
|
|
82
|
+
function createRevalidateHandler(options) {
|
|
83
|
+
return async function POST(request) {
|
|
84
|
+
const token = extractBearer(request);
|
|
85
|
+
if (!options.secret || token !== options.secret) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
86
|
+
let contentSlug = null;
|
|
87
|
+
try {
|
|
88
|
+
const body = await request.json();
|
|
89
|
+
contentSlug = typeof body.content === "string" ? body.content.trim() : null;
|
|
90
|
+
} catch {
|
|
91
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
if (!contentSlug) return Response.json({ error: "Missing content" }, { status: 400 });
|
|
94
|
+
revalidateTag(`${options.tagPrefix ?? "content"}:${contentSlug}`, "max");
|
|
95
|
+
for (const path of options.paths ?? ["/"]) revalidatePath(path, "layout");
|
|
96
|
+
await options.onRevalidate?.(contentSlug);
|
|
97
|
+
return Response.json({
|
|
98
|
+
revalidated: true,
|
|
99
|
+
content: contentSlug
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/session.ts
|
|
105
|
+
function createSiteLoginUrl(config, redirect) {
|
|
106
|
+
const returnUrl = new URL(redirect);
|
|
107
|
+
returnUrl.searchParams.set("platform", "app");
|
|
108
|
+
return getLoginUrl(config.env ?? "mainnet", returnUrl.toString(), {
|
|
109
|
+
team: config.team,
|
|
110
|
+
brand: config.brand
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function createSiteSessionStore(config) {
|
|
114
|
+
return createSessionTokenStore(config.sessionStorageKey);
|
|
115
|
+
}
|
|
116
|
+
async function exchangeSiteEditingSession(config, handoffToken) {
|
|
117
|
+
const { createClient } = await import("@joai/sdk");
|
|
118
|
+
const response = await createClient({
|
|
119
|
+
env: config.env ?? "mainnet",
|
|
120
|
+
team: config.team,
|
|
121
|
+
cache: { enabled: false }
|
|
122
|
+
}).auth.exchangeNativeSession(handoffToken);
|
|
123
|
+
if (!response.ok || !response.data?.token) throw new Error("session_exchange_failed");
|
|
124
|
+
if (!createSiteSessionStore(config).set(response.data.token)) throw new Error("session_store_failed");
|
|
125
|
+
return response.data.token;
|
|
126
|
+
}
|
|
127
|
+
function getSiteAdminLoginPath(config) {
|
|
128
|
+
return config.loginPath ?? "/admin-login";
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/theme.ts
|
|
132
|
+
function defineSiteTheme(theme) {
|
|
133
|
+
return theme;
|
|
134
|
+
}
|
|
135
|
+
function createThemeRegistry(themes) {
|
|
136
|
+
return new Map(themes.map((theme) => [theme.slug, theme]));
|
|
137
|
+
}
|
|
138
|
+
function getThemePage(theme, path) {
|
|
139
|
+
const normalized = normalizeThemePath(path);
|
|
140
|
+
return theme.pages[normalized] ?? theme.pages["/"] ?? null;
|
|
141
|
+
}
|
|
142
|
+
function listThemeSitemapPaths(theme) {
|
|
143
|
+
if (theme.sitemapPaths?.length) return theme.sitemapPaths.map(normalizeThemePath);
|
|
144
|
+
return Object.keys(theme.pages).map(normalizeThemePath);
|
|
145
|
+
}
|
|
146
|
+
function normalizeThemePath(path) {
|
|
147
|
+
if (!path || path === "/") return "/";
|
|
148
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
149
|
+
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/seo/metadata.ts
|
|
153
|
+
function absoluteUrl(baseUrl, path = "/") {
|
|
154
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
155
|
+
if (!path || path === "/") return `${base}/`;
|
|
156
|
+
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
|
157
|
+
}
|
|
158
|
+
function buildSiteMetadata({ site, baseUrl, metadata, locale = "de" }) {
|
|
159
|
+
const title = metadata.title ?? site.name ?? site.team?.name ?? site.slug;
|
|
160
|
+
const description = metadata.description;
|
|
161
|
+
const canonical = absoluteUrl(baseUrl, metadata.canonicalPath ?? "/");
|
|
162
|
+
const ogTitle = metadata.openGraph?.title ?? title;
|
|
163
|
+
const ogDescription = metadata.openGraph?.description ?? description;
|
|
164
|
+
return {
|
|
165
|
+
title,
|
|
166
|
+
description,
|
|
167
|
+
alternates: { canonical },
|
|
168
|
+
openGraph: {
|
|
169
|
+
title: ogTitle,
|
|
170
|
+
description: ogDescription,
|
|
171
|
+
url: canonical,
|
|
172
|
+
siteName: site.name ?? site.team?.name ?? site.slug,
|
|
173
|
+
locale,
|
|
174
|
+
type: "website",
|
|
175
|
+
images: metadata.openGraph?.images
|
|
176
|
+
},
|
|
177
|
+
robots: metadata.robots
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/seo/sitemap.ts
|
|
182
|
+
function buildSiteSitemap(baseUrl, paths, options = {}) {
|
|
183
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
184
|
+
const homePriority = options.homePriority ?? 1;
|
|
185
|
+
const defaultPriority = options.defaultPriority ?? .8;
|
|
186
|
+
return paths.map((path) => {
|
|
187
|
+
const normalized = !path || path === "/" ? "" : path.startsWith("/") ? path : `/${path}`;
|
|
188
|
+
const isHome = normalized === "";
|
|
189
|
+
return {
|
|
190
|
+
url: `${base}${normalized || "/"}`,
|
|
191
|
+
lastModified: options.lastModified,
|
|
192
|
+
changeFrequency: options.changeFrequency ?? (isHome ? "weekly" : "monthly"),
|
|
193
|
+
priority: isHome ? homePriority : defaultPriority
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/seo/robots.ts
|
|
199
|
+
function buildSiteRobots(baseUrl, options = {}) {
|
|
200
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
201
|
+
let host;
|
|
202
|
+
try {
|
|
203
|
+
host = new URL(base).host;
|
|
204
|
+
} catch {
|
|
205
|
+
host = void 0;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
rules: [{
|
|
209
|
+
userAgent: "*",
|
|
210
|
+
allow: options.allow ?? "/",
|
|
211
|
+
disallow: options.disallow ?? ["/admin-login", "/api/"]
|
|
212
|
+
}],
|
|
213
|
+
sitemap: `${base}/sitemap.xml`,
|
|
214
|
+
host
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/seo/json-ld.ts
|
|
219
|
+
function buildLocalBusinessJsonLd(input) {
|
|
220
|
+
const jsonLd = {
|
|
221
|
+
"@context": "https://schema.org",
|
|
222
|
+
"@type": input.type ?? "LocalBusiness",
|
|
223
|
+
name: input.name,
|
|
224
|
+
url: input.url
|
|
225
|
+
};
|
|
226
|
+
if (input.description) jsonLd.description = input.description;
|
|
227
|
+
if (input.telephone) jsonLd.telephone = input.telephone;
|
|
228
|
+
if (input.email) jsonLd.email = input.email;
|
|
229
|
+
if (input.image) jsonLd.image = input.image;
|
|
230
|
+
if (input.openingHours?.length) jsonLd.openingHours = input.openingHours;
|
|
231
|
+
if (input.sameAs?.length) jsonLd.sameAs = input.sameAs;
|
|
232
|
+
if (input.priceRange) jsonLd.priceRange = input.priceRange;
|
|
233
|
+
if (input.geo) jsonLd.geo = {
|
|
234
|
+
"@type": "GeoCoordinates",
|
|
235
|
+
latitude: input.geo.latitude,
|
|
236
|
+
longitude: input.geo.longitude
|
|
237
|
+
};
|
|
238
|
+
if (input.address) jsonLd.address = {
|
|
239
|
+
"@type": "PostalAddress",
|
|
240
|
+
...input.address
|
|
241
|
+
};
|
|
242
|
+
return jsonLd;
|
|
243
|
+
}
|
|
244
|
+
function serializeJsonLd(data) {
|
|
245
|
+
return JSON.stringify(data);
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/seo/llms-txt.ts
|
|
249
|
+
function buildLlmsTxt(input) {
|
|
250
|
+
const base = input.baseUrl.replace(/\/$/, "");
|
|
251
|
+
const lines = [`# ${input.name}`, ""];
|
|
252
|
+
if (input.description) lines.push(input.description, "");
|
|
253
|
+
lines.push("## Pages", "");
|
|
254
|
+
for (const page of input.pages) {
|
|
255
|
+
const url = `${base}${page.path === "/" ? "/" : page.path.startsWith("/") ? page.path : `/${page.path}`}`;
|
|
256
|
+
lines.push(`- [${page.title}](${url})${page.summary ? `: ${page.summary}` : ""}`);
|
|
257
|
+
}
|
|
258
|
+
if (input.contact) lines.push("", "## Contact", "", input.contact);
|
|
259
|
+
lines.push("");
|
|
260
|
+
return lines.join("\n");
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
export { buildLlmsTxt, buildLocalBusinessJsonLd, buildSiteMetadata, buildSiteRobots, buildSiteSitemap, createRevalidateHandler, createSiteLoginUrl, createSiteSessionStore, createThemeRegistry, defineSiteTheme, exchangeSiteEditingSession, getSiteAdminLoginPath, getSiteContentSlug, getSiteThemeKey, getThemePage, listThemeSitemapPaths, loadSiteContent, normalizeSiteHost, normalizeThemePath, resolveSiteByHost, resolveSiteContext, serializeJsonLd };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AppEnv } from "@joai/core";
|
|
2
|
+
import * as _$_joai_react0 from "@joai/react";
|
|
3
|
+
|
|
4
|
+
//#region src/session.d.ts
|
|
5
|
+
type SiteEditingSessionConfig = {
|
|
6
|
+
env?: AppEnv;
|
|
7
|
+
team: string;
|
|
8
|
+
brand?: string;
|
|
9
|
+
sessionStorageKey: string; /** Path for the admin login route (default `/admin-login`). */
|
|
10
|
+
loginPath?: string;
|
|
11
|
+
};
|
|
12
|
+
declare function createSiteLoginUrl(config: SiteEditingSessionConfig, redirect: string): string;
|
|
13
|
+
declare function createSiteSessionStore(config: SiteEditingSessionConfig): _$_joai_react0.SessionTokenStore;
|
|
14
|
+
declare function exchangeSiteEditingSession(config: SiteEditingSessionConfig, handoffToken: string): Promise<string>;
|
|
15
|
+
declare function getSiteAdminLoginPath(config: SiteEditingSessionConfig): string;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { SiteEditingSessionConfig, createSiteLoginUrl, createSiteSessionStore, exchangeSiteEditingSession, getSiteAdminLoginPath };
|
package/dist/session.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getLoginUrl } from "@joai/core";
|
|
2
|
+
import { createSessionTokenStore } from "@joai/react";
|
|
3
|
+
//#region src/session.ts
|
|
4
|
+
function createSiteLoginUrl(config, redirect) {
|
|
5
|
+
const returnUrl = new URL(redirect);
|
|
6
|
+
returnUrl.searchParams.set("platform", "app");
|
|
7
|
+
return getLoginUrl(config.env ?? "mainnet", returnUrl.toString(), {
|
|
8
|
+
team: config.team,
|
|
9
|
+
brand: config.brand
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function createSiteSessionStore(config) {
|
|
13
|
+
return createSessionTokenStore(config.sessionStorageKey);
|
|
14
|
+
}
|
|
15
|
+
async function exchangeSiteEditingSession(config, handoffToken) {
|
|
16
|
+
const { createClient } = await import("@joai/sdk");
|
|
17
|
+
const response = await createClient({
|
|
18
|
+
env: config.env ?? "mainnet",
|
|
19
|
+
team: config.team,
|
|
20
|
+
cache: { enabled: false }
|
|
21
|
+
}).auth.exchangeNativeSession(handoffToken);
|
|
22
|
+
if (!response.ok || !response.data?.token) throw new Error("session_exchange_failed");
|
|
23
|
+
if (!createSiteSessionStore(config).set(response.data.token)) throw new Error("session_store_failed");
|
|
24
|
+
return response.data.token;
|
|
25
|
+
}
|
|
26
|
+
function getSiteAdminLoginPath(config) {
|
|
27
|
+
return config.loginPath ?? "/admin-login";
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { createSiteLoginUrl, createSiteSessionStore, exchangeSiteEditingSession, getSiteAdminLoginPath };
|
package/locales/de.json
ADDED
package/locales/en.json
ADDED
package/locales/es.json
ADDED
package/locales/fr.json
ADDED
package/locales/it.json
ADDED
package/locales/ro.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joai/sites",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Shared helpers for JoAi-managed multi-tenant websites",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"types": "./dist/index.d.mts"
|
|
13
|
+
},
|
|
14
|
+
"./admin": {
|
|
15
|
+
"import": "./dist/admin.mjs",
|
|
16
|
+
"types": "./dist/admin.d.mts"
|
|
17
|
+
},
|
|
18
|
+
"./locales/*": "./locales/*",
|
|
19
|
+
"./editable": {
|
|
20
|
+
"import": "./dist/editable.mjs",
|
|
21
|
+
"types": "./dist/editable.d.mts"
|
|
22
|
+
},
|
|
23
|
+
"./session": {
|
|
24
|
+
"import": "./dist/session.mjs",
|
|
25
|
+
"types": "./dist/session.d.mts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"locales"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsdown",
|
|
34
|
+
"dev": "tsdown --watch",
|
|
35
|
+
"test": "jest --config jest.config.js"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@joai/core": "^5.3.5",
|
|
39
|
+
"@joai/react": "^1.0.4",
|
|
40
|
+
"@joai/sdk": "^12.1.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"next": ">=15.0.0",
|
|
44
|
+
"next-intl": ">=3.0.0",
|
|
45
|
+
"react": ">=18.0.0",
|
|
46
|
+
"react-dom": ">=18.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/jest": "^30.0.0",
|
|
50
|
+
"@types/react": "^19.2.14",
|
|
51
|
+
"jest": "^30.3.0",
|
|
52
|
+
"next": "^16.2.0",
|
|
53
|
+
"next-intl": "^4.8.3",
|
|
54
|
+
"react": "^19.2.4",
|
|
55
|
+
"react-dom": "^19.2.4",
|
|
56
|
+
"ts-jest": "^29.4.6",
|
|
57
|
+
"tsdown": "^0.21.0",
|
|
58
|
+
"typescript": "^5.9.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/README.md
DELETED