@lukoweb/apitogo 0.2.53 → 0.2.54
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/README.md +15 -2
- package/dist/cli/cli.js +662 -403
- package/dist/cli/worker.js +7 -1
- package/dist/declarations/cli/common/spawn-command.d.ts +3 -0
- package/dist/declarations/cli/platform/helpers/progress.d.ts +4 -1
- package/dist/declarations/lib/components/SiteTitle.d.ts +1 -0
- package/package.json +1 -1
- package/src/config/v2/docs-navigation.ts +11 -3
- package/src/config/v2/full-server.ts +1 -1
- package/src/config/v2/home-redirect.ts +39 -6
- package/src/config/v2/page-order.ts +73 -0
- package/src/config/v2/services.ts +5 -0
- package/src/config/v2/tsx-pages.ts +10 -1
- package/src/config/v2/wrapper.ts +12 -2
- package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +18 -1
- package/src/lib/components/SiteTitle.tsx +69 -58
- package/src/vite/prerender/worker.ts +132 -118
package/dist/cli/worker.js
CHANGED
|
@@ -115,6 +115,11 @@ var ProtectedRoutesSchema = z.pipe(
|
|
|
115
115
|
|
|
116
116
|
// src/vite/prerender/worker.ts
|
|
117
117
|
var { template, distDir, serverConfigPath, entryServerPath, writeRedirects } = Piscina.workerData;
|
|
118
|
+
var isStaticNavigateWarning = (args) => typeof args[0] === "string" && args[0].includes("<Navigate> must not be used on the initial render");
|
|
119
|
+
var warn = console.warn.bind(console);
|
|
120
|
+
console.warn = (...args) => {
|
|
121
|
+
if (!isStaticNavigateWarning(args)) warn(...args);
|
|
122
|
+
};
|
|
118
123
|
var server = await import(entryServerPath);
|
|
119
124
|
var rawConfig = await import(serverConfigPath).then(
|
|
120
125
|
(m) => m.default
|
|
@@ -186,5 +191,6 @@ var renderPage = async ({ urlPath }) => {
|
|
|
186
191
|
};
|
|
187
192
|
var worker_default = renderPage;
|
|
188
193
|
export {
|
|
189
|
-
worker_default as default
|
|
194
|
+
worker_default as default,
|
|
195
|
+
isStaticNavigateWarning
|
|
190
196
|
};
|
|
@@ -42,7 +42,10 @@ export type ConsoleProgressReporter = ProgressReporter & {
|
|
|
42
42
|
stop(): void;
|
|
43
43
|
};
|
|
44
44
|
export declare function friendlyModuleNames(text: string): string;
|
|
45
|
-
export declare function
|
|
45
|
+
export declare function isInfrastructureMessage(message: string): boolean;
|
|
46
|
+
export declare function createConsoleProgressReporter(stream?: NodeJS.WritableStream, options?: {
|
|
47
|
+
verbose?: boolean;
|
|
48
|
+
}): ConsoleProgressReporter;
|
|
46
49
|
export interface PollableJobValue {
|
|
47
50
|
status: string;
|
|
48
51
|
messages?: JobLogMessage[];
|
|
@@ -5,6 +5,7 @@ type SiteTitleProps = {
|
|
|
5
5
|
export declare const splitBrandWordmark: (title: string) => {
|
|
6
6
|
prefix: string;
|
|
7
7
|
accent: string | null;
|
|
8
|
+
spaced: boolean;
|
|
8
9
|
};
|
|
9
10
|
export declare const SiteTitle: ({ title, className }: SiteTitleProps) => import("react/jsx-runtime").JSX.Element;
|
|
10
11
|
export {};
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { InputNavigationItem } from "../validators/InputNavigationSchema.js";
|
|
2
|
+
import { comparePages, type PageOrders } from "./page-order.js";
|
|
2
3
|
|
|
3
4
|
/** Label of the one header tab that holds the sidebar made from the docs pages. */
|
|
4
5
|
export const DOCS_NAVIGATION_LABEL = "Documentation";
|
|
@@ -19,9 +20,14 @@ export function folderLabel(name: string): string {
|
|
|
19
20
|
|
|
20
21
|
const byName = (a: string, b: string) => a.localeCompare(b);
|
|
21
22
|
|
|
22
|
-
const toItems = (
|
|
23
|
+
const toItems = (
|
|
24
|
+
branch: Branch,
|
|
25
|
+
prefix: string,
|
|
26
|
+
orders: PageOrders,
|
|
27
|
+
): InputNavigationItem[] => {
|
|
28
|
+
// Index first, then the pages' own order, then the usual first pages, then by name (API-125).
|
|
23
29
|
const names = [...branch.pages.keys()].sort((a, b) =>
|
|
24
|
-
a
|
|
30
|
+
comparePages(`/${prefix}${a}`, `/${prefix}${b}`, orders),
|
|
25
31
|
);
|
|
26
32
|
const folders = [...branch.folders.keys()].sort(byName);
|
|
27
33
|
const folderPath = prefix === "" ? "/" : `/${prefix.slice(0, -1)}`;
|
|
@@ -51,6 +57,7 @@ const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
|
|
|
51
57
|
items: toItems(
|
|
52
58
|
branch.folders.get(folder) ?? emptyBranch(),
|
|
53
59
|
`${prefix}${folder}/`,
|
|
60
|
+
orders,
|
|
54
61
|
),
|
|
55
62
|
}),
|
|
56
63
|
),
|
|
@@ -77,6 +84,7 @@ const branchOf = (root: Branch, route: string) => {
|
|
|
77
84
|
export function docsNavigation(
|
|
78
85
|
routes: string[],
|
|
79
86
|
customPages: readonly CustomDocsPage[] = [],
|
|
87
|
+
orders: PageOrders = new Map(),
|
|
80
88
|
): InputNavigationItem[] {
|
|
81
89
|
const root = emptyBranch();
|
|
82
90
|
for (const route of routes) {
|
|
@@ -88,7 +96,7 @@ export function docsNavigation(
|
|
|
88
96
|
if (page && !branch.pages.has(page))
|
|
89
97
|
branch.pages.set(page, { kind: "custom", ...custom });
|
|
90
98
|
}
|
|
91
|
-
const items = toItems(root, "");
|
|
99
|
+
const items = toItems(root, "", orders);
|
|
92
100
|
return items.length === 0
|
|
93
101
|
? []
|
|
94
102
|
: [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
|
|
@@ -49,7 +49,7 @@ const items = [
|
|
|
49
49
|
{ id: "3", name: "Third item" },
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
-
// Lets the portal's API page call this server while you run it locally (\`apitogo
|
|
52
|
+
// Lets the portal's API page call this server while you run it locally (\`apitogo run\`). Once deployed, the gateway
|
|
53
53
|
// answers browsers itself and keeps its own list of allowed sites.
|
|
54
54
|
const cors = {
|
|
55
55
|
"access-control-allow-origin": "*",
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { glob } from "glob";
|
|
3
3
|
import globParent from "glob-parent";
|
|
4
4
|
import { readFrontmatter } from "../../lib/util/readFrontmatter.js";
|
|
5
|
+
import { firstPage, orderValue, type PageOrders } from "./page-order.js";
|
|
5
6
|
import { toGlobPath } from "./resolve.js";
|
|
6
7
|
|
|
7
8
|
export type HomeRedirect = { from: string; to: string };
|
|
@@ -57,11 +58,41 @@ export async function docRoutes(
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** The page a visitor lands on when the site has no home page: the top-most page, then the first by name. */
|
|
60
|
-
export function pickLandingRoute(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
61
|
+
export function pickLandingRoute(
|
|
62
|
+
routes: string[],
|
|
63
|
+
orders?: PageOrders,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
return firstPage(routes, orders);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `order` each Markdown page sets in its frontmatter, by route (API-125). Pages without one are not in the map.
|
|
70
|
+
*/
|
|
71
|
+
export async function docPageOrders(
|
|
72
|
+
patterns: string[],
|
|
73
|
+
): Promise<Map<string, number>> {
|
|
74
|
+
const globs = patterns.map(toGlobPath);
|
|
75
|
+
const excluded = globs
|
|
76
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
77
|
+
.map((pattern) => pattern.slice(1));
|
|
78
|
+
const orders = new Map<string, number>();
|
|
79
|
+
for (const pattern of globs.filter((p) => !p.startsWith("!"))) {
|
|
80
|
+
const parent = globParent(pattern);
|
|
81
|
+
const files = await glob(pattern, {
|
|
82
|
+
absolute: true,
|
|
83
|
+
posix: true,
|
|
84
|
+
ignore: [...IGNORED, ...excluded],
|
|
85
|
+
});
|
|
86
|
+
for (const raw of files) {
|
|
87
|
+
const order = orderValue((await readFrontmatter(raw)).data.order);
|
|
88
|
+
if (order === undefined) continue;
|
|
89
|
+
const route = `/${path.posix
|
|
90
|
+
.relative(parent, raw.replace(/^\/\/\?\//, ""))
|
|
91
|
+
.replace(/\.mdx?$/, "")}`;
|
|
92
|
+
if (!orders.has(route)) orders.set(route, order);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return orders;
|
|
65
96
|
}
|
|
66
97
|
|
|
67
98
|
export type HomeRedirectInput = {
|
|
@@ -77,6 +108,8 @@ export type HomeRedirectInput = {
|
|
|
77
108
|
userPanel: boolean;
|
|
78
109
|
/** The portal has a pricing page, at this path. */
|
|
79
110
|
pricingPath?: string;
|
|
111
|
+
/** The pages' own order (API-125), so `/` opens the first page in the sidebar's order. */
|
|
112
|
+
pageOrders?: PageOrders;
|
|
80
113
|
};
|
|
81
114
|
|
|
82
115
|
/**
|
|
@@ -96,7 +129,7 @@ export function homeRedirect(
|
|
|
96
129
|
if (input.hostsDocs) {
|
|
97
130
|
if (input.docRoutes.some((route) => route === "/" || route === "/index"))
|
|
98
131
|
return undefined;
|
|
99
|
-
const landing = pickLandingRoute(input.docRoutes);
|
|
132
|
+
const landing = pickLandingRoute(input.docRoutes, input.pageOrders);
|
|
100
133
|
if (landing) return { from: "/", to: landing };
|
|
101
134
|
return input.apiReferencePath
|
|
102
135
|
? { from: "/", to: input.apiReferencePath }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order of docs pages (API-125). A page may set it (`order: 2` in the frontmatter of a Markdown page,
|
|
3
|
+
* `export const order = 2` in a React page). Pages with an order come first, by that number; the others follow, the
|
|
4
|
+
* usual first pages (introduction, getting started, ...) before the rest, then by name. A folder's index page is
|
|
5
|
+
* always its first page. Before this the order was by name only, so "Changelog" came first and the header's
|
|
6
|
+
* "Documentation" link opened it.
|
|
7
|
+
*/
|
|
8
|
+
export type PageOrders = ReadonlyMap<string, number>;
|
|
9
|
+
|
|
10
|
+
/** Names that usually open a set of docs, best first. */
|
|
11
|
+
const FIRST_PAGE_NAMES = [
|
|
12
|
+
"introduction",
|
|
13
|
+
"intro",
|
|
14
|
+
"welcome",
|
|
15
|
+
"getting-started",
|
|
16
|
+
"get-started",
|
|
17
|
+
"overview",
|
|
18
|
+
"quickstart",
|
|
19
|
+
"quick-start",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const lastSegment = (route: string) =>
|
|
23
|
+
route.split("/").filter(Boolean).pop() ?? "";
|
|
24
|
+
|
|
25
|
+
const rank = (route: string, orders: PageOrders) => {
|
|
26
|
+
const name = lastSegment(route).toLowerCase();
|
|
27
|
+
if (name === "index" || route === "/") return [0, 0] as const;
|
|
28
|
+
const order = orders.get(route);
|
|
29
|
+
if (order !== undefined) return [1, order] as const;
|
|
30
|
+
const preferred = FIRST_PAGE_NAMES.indexOf(name);
|
|
31
|
+
return [2, preferred === -1 ? FIRST_PAGE_NAMES.length : preferred] as const;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Sorts sibling pages: index, then by `order`, then the usual first pages, then by name. */
|
|
35
|
+
export function comparePages(
|
|
36
|
+
a: string,
|
|
37
|
+
b: string,
|
|
38
|
+
orders: PageOrders = new Map(),
|
|
39
|
+
): number {
|
|
40
|
+
const [groupA, valueA] = rank(a, orders);
|
|
41
|
+
const [groupB, valueB] = rank(b, orders);
|
|
42
|
+
return groupA - groupB || valueA - valueB || a.localeCompare(b);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The page a visitor lands on: the top-most page, then the first in page order. */
|
|
46
|
+
export function firstPage(
|
|
47
|
+
routes: readonly string[],
|
|
48
|
+
orders: PageOrders = new Map(),
|
|
49
|
+
): string | undefined {
|
|
50
|
+
const depth = (route: string) => route.split("/").length;
|
|
51
|
+
return [...routes].sort(
|
|
52
|
+
(a, b) => depth(a) - depth(b) || comparePages(a, b, orders),
|
|
53
|
+
)[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `order: 3` (or `order: "3"`) from a page's frontmatter; undefined when it is missing or not a whole number. */
|
|
57
|
+
export function orderValue(value: unknown): number | undefined {
|
|
58
|
+
const number =
|
|
59
|
+
typeof value === "number"
|
|
60
|
+
? value
|
|
61
|
+
: typeof value === "string" && value.trim() !== ""
|
|
62
|
+
? Number(value)
|
|
63
|
+
: Number.NaN;
|
|
64
|
+
return Number.isInteger(number) ? number : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TSX_ORDER = /export\s+const\s+order\s*(?::\s*number\s*)?=\s*(-?\d+)/;
|
|
68
|
+
|
|
69
|
+
/** `export const order = 3` in a React page. */
|
|
70
|
+
export function tsxPageOrder(source: string): number | undefined {
|
|
71
|
+
const match = TSX_ORDER.exec(source);
|
|
72
|
+
return match?.[1] ? Number(match[1]) : undefined;
|
|
73
|
+
}
|
|
@@ -686,6 +686,11 @@ async function buildGateway(
|
|
|
686
686
|
: {}),
|
|
687
687
|
...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
|
|
688
688
|
...(o.cors?.length ? { cors: o.cors } : {}),
|
|
689
|
+
// The sample server's health check needs no key: monitors and the platform call it, and it is not something a
|
|
690
|
+
// plan sells, so it stays out of the plans' feature lists (API-126).
|
|
691
|
+
...(sampleServer
|
|
692
|
+
? { operations: { public: [`GET ${FULL_SERVER_HEALTH_PATH}`] } }
|
|
693
|
+
: {}),
|
|
689
694
|
};
|
|
690
695
|
|
|
691
696
|
return {
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { glob } from "glob";
|
|
4
4
|
import globParent from "glob-parent";
|
|
5
5
|
import { folderLabel } from "./docs-navigation.js";
|
|
6
|
+
import { tsxPageOrder } from "./page-order.js";
|
|
6
7
|
import { toGlobPath } from "./resolve.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -18,6 +19,8 @@ export type TsxPage = {
|
|
|
18
19
|
file: string;
|
|
19
20
|
/** Sidebar label: `export const title = "..."` in the file, or the file name. */
|
|
20
21
|
label: string;
|
|
22
|
+
/** `export const order = 2` in the file (API-125). */
|
|
23
|
+
order?: number;
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
const IGNORED = ["**/node_modules/**", "**/dist/**", "**/.git/**"];
|
|
@@ -90,7 +93,13 @@ export async function findTsxPages(
|
|
|
90
93
|
const route = `/${relative.replace(/\.tsx$/, "")}`;
|
|
91
94
|
if (found.has(route)) continue;
|
|
92
95
|
const source = await readFile(file, "utf8").catch(() => "");
|
|
93
|
-
|
|
96
|
+
const order = tsxPageOrder(source);
|
|
97
|
+
found.set(route, {
|
|
98
|
+
route,
|
|
99
|
+
file,
|
|
100
|
+
label: tsxPageLabel(source, file),
|
|
101
|
+
...(order !== undefined ? { order } : {}),
|
|
102
|
+
});
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
return [...found.values()].sort((a, b) => a.route.localeCompare(b.route));
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
docRoutes,
|
|
11
11
|
type HomeRedirect,
|
|
12
12
|
homeRedirect,
|
|
13
|
+
docPageOrders,
|
|
13
14
|
pickLandingRoute,
|
|
14
15
|
} from "./home-redirect.js";
|
|
15
16
|
import {
|
|
@@ -677,13 +678,20 @@ export async function preparePortal(
|
|
|
677
678
|
const hostedDocRoutes = [
|
|
678
679
|
...new Set([...markdownRoutes, ...tsxPages.map((page) => page.route)]),
|
|
679
680
|
].sort();
|
|
681
|
+
// The pages' own order (API-125): the sidebar, `/` and the "Documentation" link follow it.
|
|
682
|
+
const pageOrders = plan.docs
|
|
683
|
+
? await docPageOrders(profile.docs.content)
|
|
684
|
+
: new Map<string, number>();
|
|
685
|
+
for (const page of tsxPages)
|
|
686
|
+
if (page.order !== undefined && !pageOrders.has(page.route))
|
|
687
|
+
pageOrders.set(page.route, page.order);
|
|
680
688
|
// The account pages' "Documentation" link goes to a page the portal really has, or is left out.
|
|
681
689
|
if (plan.plansPlugin && plan.docs) {
|
|
682
690
|
const landing = hostedDocRoutes.some(
|
|
683
691
|
(route) => route === "/" || route === "/index",
|
|
684
692
|
)
|
|
685
693
|
? "/"
|
|
686
|
-
: pickLandingRoute(hostedDocRoutes);
|
|
694
|
+
: pickLandingRoute(hostedDocRoutes, pageOrders);
|
|
687
695
|
if (landing)
|
|
688
696
|
plan.plansPlugin.links = { ...plan.plansPlugin.links, docs: landing };
|
|
689
697
|
}
|
|
@@ -702,6 +710,7 @@ export async function preparePortal(
|
|
|
702
710
|
plan.plansPlugin && hasCapability(effective.services.plans, "catalog")
|
|
703
711
|
? CONSOLE_ROUTES.pricing
|
|
704
712
|
: undefined,
|
|
713
|
+
pageOrders,
|
|
705
714
|
});
|
|
706
715
|
const siteConfig = buildSiteConfig(
|
|
707
716
|
resolved,
|
|
@@ -716,8 +725,9 @@ export async function preparePortal(
|
|
|
716
725
|
label: page.label,
|
|
717
726
|
element: wrapperPages[index]?.id ?? "",
|
|
718
727
|
})),
|
|
728
|
+
pageOrders,
|
|
719
729
|
),
|
|
720
|
-
docsPath: pickLandingRoute(hostedDocRoutes),
|
|
730
|
+
docsPath: pickLandingRoute(hostedDocRoutes, pageOrders),
|
|
721
731
|
demoPlans:
|
|
722
732
|
plan.demo && plan.plansPlugin
|
|
723
733
|
? await readDemoPlans(resolved.plans?.file)
|
|
@@ -358,6 +358,19 @@ export function DevPortalRegisterPage({
|
|
|
358
358
|
.finally(() => setConfigLoading(false));
|
|
359
359
|
}, [baseUrl]);
|
|
360
360
|
|
|
361
|
+
// With one sign-in provider and no native sign-up, the button was a needless extra step: the page goes to the
|
|
362
|
+
// provider at once, and the button stays as a fallback (API-126).
|
|
363
|
+
const onlyProvider =
|
|
364
|
+
config && !isNativeAuthEnabled(config) && config.providers.length === 1
|
|
365
|
+
? config.providers[0]
|
|
366
|
+
: undefined;
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (!baseUrl || !onlyProvider) return;
|
|
369
|
+
window.location.assign(
|
|
370
|
+
buildDevPortalOidcChallengeUrl(baseUrl, onlyProvider.id, returnUrl),
|
|
371
|
+
);
|
|
372
|
+
}, [baseUrl, onlyProvider, returnUrl]);
|
|
373
|
+
|
|
361
374
|
const onSubmit = useCallback(
|
|
362
375
|
async (event: React.FormEvent) => {
|
|
363
376
|
event.preventDefault();
|
|
@@ -408,7 +421,11 @@ export function DevPortalRegisterPage({
|
|
|
408
421
|
return (
|
|
409
422
|
<AuthPageShell
|
|
410
423
|
title="Create your account"
|
|
411
|
-
description=
|
|
424
|
+
description={
|
|
425
|
+
onlyProvider
|
|
426
|
+
? "Opening the sign-up form…"
|
|
427
|
+
: "Accounts are created on our secure sign-in provider."
|
|
428
|
+
}
|
|
412
429
|
>
|
|
413
430
|
{config.providers.length ? (
|
|
414
431
|
<OidcProviderButtons
|
|
@@ -1,58 +1,69 @@
|
|
|
1
|
-
import { cn } from "../util/cn.js";
|
|
2
|
-
|
|
3
|
-
type SiteTitleProps = {
|
|
4
|
-
title: string;
|
|
5
|
-
className?: string;
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
export const splitBrandWordmark = (title: string) => {
|
|
9
|
-
const trimmed = title.trim();
|
|
10
|
-
if (!trimmed) {
|
|
11
|
-
return { prefix: title, accent: null as string | null };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
import { cn } from "../util/cn.js";
|
|
2
|
+
|
|
3
|
+
type SiteTitleProps = {
|
|
4
|
+
title: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const splitBrandWordmark = (title: string) => {
|
|
9
|
+
const trimmed = title.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return { prefix: title, accent: null as string | null, spaced: false };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A name of several words keeps its space before the accent: "full portal", not "fullportal" (API-126).
|
|
15
|
+
const words = trimmed.split(/\s+/).filter(Boolean);
|
|
16
|
+
if (words.length >= 2) {
|
|
17
|
+
return {
|
|
18
|
+
prefix: words.slice(0, -1).join(" "),
|
|
19
|
+
accent: words.at(-1) ?? null,
|
|
20
|
+
spaced: true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const word = words[0] ?? trimmed;
|
|
25
|
+
const apiMatch = word.match(/^(.*)(API)$/i);
|
|
26
|
+
if (apiMatch?.[1]) {
|
|
27
|
+
return { prefix: apiMatch[1], accent: apiMatch[2] ?? "API", spaced: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const camelMatch = word.match(/^(.*[a-z])([A-Z][a-z]*)$/);
|
|
31
|
+
if (camelMatch?.[1]) {
|
|
32
|
+
return {
|
|
33
|
+
prefix: camelMatch[1],
|
|
34
|
+
accent: camelMatch[2] ?? null,
|
|
35
|
+
spaced: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (word.length < 2) {
|
|
40
|
+
return { prefix: word, accent: null, spaced: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const splitAt = Math.floor(word.length / 2);
|
|
44
|
+
return {
|
|
45
|
+
prefix: word.slice(0, splitAt),
|
|
46
|
+
accent: word.slice(splitAt),
|
|
47
|
+
spaced: false,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const SiteTitle = ({ title, className }: SiteTitleProps) => {
|
|
52
|
+
const { prefix, accent, spaced } = splitBrandWordmark(title);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
className={cn(
|
|
57
|
+
"font-sans text-[1.4rem] font-extrabold tracking-[-0.03em]",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
style={{ color: "var(--foreground)" }}
|
|
61
|
+
>
|
|
62
|
+
{prefix}
|
|
63
|
+
{accent && spaced ? " " : null}
|
|
64
|
+
{accent ? (
|
|
65
|
+
<span style={{ color: "var(--primary)" }}>{accent}</span>
|
|
66
|
+
) : null}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
};
|