@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.
@@ -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 };
@@ -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 };
@@ -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 };
@@ -0,0 +1,6 @@
1
+ {
2
+ "sites": {
3
+ "loginRedirecting": "Weiterleitung zur sicheren Anmeldung …",
4
+ "loginFailed": "Anmeldung fehlgeschlagen. Weiterleitung wird erneut gestartet …"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "sites": {
3
+ "loginRedirecting": "Redirecting to secure sign-in…",
4
+ "loginFailed": "Sign-in failed. Trying again…"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "sites": {
3
+ "loginRedirecting": "Redirigiendo al inicio de sesión seguro…",
4
+ "loginFailed": "Error al iniciar sesión. Reintentando…"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "sites": {
3
+ "loginRedirecting": "Redirection vers la connexion sécurisée…",
4
+ "loginFailed": "Échec de la connexion. Nouvel essai…"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "sites": {
3
+ "loginRedirecting": "Reindirizzamento all’accesso sicuro…",
4
+ "loginFailed": "Accesso non riuscito. Nuovo tentativo…"
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "sites": {
3
+ "loginRedirecting": "Redirecționare către autentificarea securizată…",
4
+ "loginFailed": "Autentificarea a eșuat. Se reîncearcă…"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@joai/sites",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.