@umami/shiso 0.55.0 → 1.0.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.
- package/CHANGELOG.md +39 -0
- package/README.md +9 -49
- package/bin/shiso.mjs +132 -0
- package/docs.schema.json +896 -0
- package/mdx.config.ts +143 -0
- package/package.json +74 -83
- package/scripts/check-package.mjs +54 -0
- package/scripts/generate-icon-registry.mjs +196 -0
- package/scripts/generate-last-modified.mjs +128 -0
- package/scripts/generate-search-index.mjs +298 -0
- package/scripts/lib/mdast.mjs +23 -0
- package/scripts/lib/slug.mjs +21 -0
- package/scripts/load-docs-config.mjs +244 -0
- package/scripts/prerender.mjs +194 -0
- package/scripts/validate-config.mjs +104 -0
- package/scripts/vite-docs-config.mjs +60 -0
- package/src/App.tsx +38 -0
- package/src/components/Banner.tsx +69 -0
- package/src/components/CodeBlock.tsx +46 -0
- package/src/components/ConfiguredIcon.tsx +15 -0
- package/src/components/ContextualMenu.tsx +93 -0
- package/src/components/DocContent.tsx +105 -0
- package/src/components/Docs.tsx +134 -0
- package/src/components/Footer.tsx +81 -0
- package/src/components/Header.tsx +82 -0
- package/src/components/LanguageSwitcher.tsx +59 -0
- package/src/components/Layout.tsx +24 -0
- package/src/components/PageLinks.tsx +71 -0
- package/src/components/Search.tsx +217 -0
- package/src/components/SideNav.tsx +347 -0
- package/src/components/SocialIcon.tsx +88 -0
- package/src/components/ThemeToggle.tsx +34 -0
- package/src/components/TopNav.tsx +127 -0
- package/src/components/VersionSwitcher.tsx +60 -0
- package/src/components/docs/Accordion.tsx +68 -0
- package/src/components/docs/Badge.tsx +171 -0
- package/src/components/docs/Callout.tsx +73 -0
- package/src/components/docs/Card.tsx +158 -0
- package/src/components/docs/CodeGroup.tsx +73 -0
- package/src/components/docs/Columns.tsx +20 -0
- package/src/components/docs/Expandable.tsx +28 -0
- package/src/components/docs/Frame.tsx +56 -0
- package/src/components/docs/Icon.tsx +30 -0
- package/src/components/docs/ParamField.tsx +45 -0
- package/src/components/docs/PropertiesTable.tsx +84 -0
- package/src/components/docs/ResponseField.tsx +36 -0
- package/src/components/docs/Steps.tsx +47 -0
- package/src/components/docs/Tabs.tsx +116 -0
- package/src/components/docs/Tooltip.tsx +21 -0
- package/src/components/docs/index.ts +15 -0
- package/src/components/docs/styles.ts +82 -0
- package/src/components/docs/utils.ts +118 -0
- package/src/components/icons/index.ts +17 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert.tsx +69 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +88 -0
- package/src/components/ui/collapsible.tsx +15 -0
- package/src/components/ui/command.tsx +173 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/scroll-area.tsx +71 -0
- package/src/components/ui/sheet.tsx +124 -0
- package/src/components/ui/tabs.tsx +73 -0
- package/src/components/ui/tooltip.tsx +52 -0
- package/src/declarations.d.ts +9 -0
- package/src/entry-client.tsx +17 -0
- package/src/entry-server.tsx +89 -0
- package/src/generated/last-modified.ts +2 -0
- package/src/lib/content.ts +44 -0
- package/src/lib/docs-config.ts +986 -0
- package/src/lib/head.ts +231 -0
- package/src/lib/icon-registry.generated.ts +4 -0
- package/src/lib/icons.ts +29 -0
- package/src/lib/inline-markdown.tsx +86 -0
- package/src/lib/locale.ts +39 -0
- package/src/lib/mdast.ts +56 -0
- package/src/lib/paths.ts +86 -0
- package/src/lib/remark-toc.ts +71 -0
- package/src/lib/search/config.ts +43 -0
- package/src/lib/search/provider.ts +85 -0
- package/src/lib/search/providers/local.ts +15 -0
- package/src/lib/search-index.generated.ts +4 -0
- package/src/lib/search.ts +128 -0
- package/src/lib/site-config.ts +117 -0
- package/src/lib/site-model.ts +221 -0
- package/src/lib/slug.ts +38 -0
- package/src/lib/types.ts +515 -0
- package/src/lib/utils.ts +6 -0
- package/src/pages/DocPage.tsx +33 -0
- package/src/styles/global.css +268 -0
- package/src/styles/tokens.css +114 -0
- package/types/client.d.ts +3 -0
- package/types/search.d.ts +27 -0
- package/vite.config.ts +342 -0
- package/LICENSE +0 -21
- package/dist/index.css +0 -189
- package/dist/index.d.ts +0 -57
- package/dist/index.js +0 -464
- package/dist/index.mjs +0 -437
- package/server/index.d.ts +0 -30
- package/server/index.js +0 -189
- package/styles.css +0 -4766
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SearchContext, SearchResult } from '@/lib/search';
|
|
2
|
+
|
|
3
|
+
export interface SearchProvider {
|
|
4
|
+
/**
|
|
5
|
+
* `context` describes the active version/language scope. Providers may
|
|
6
|
+
* ignore it; the built-in local provider uses it to keep results inside
|
|
7
|
+
* the scope being browsed.
|
|
8
|
+
*/
|
|
9
|
+
search(query: string, limit?: number, context?: SearchContext): Promise<SearchResult[]>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type SearchProviderFactory = (
|
|
13
|
+
options: Record<string, unknown>,
|
|
14
|
+
) => SearchProvider | Promise<SearchProvider>;
|
|
15
|
+
|
|
16
|
+
export interface ResolvedSearchProvider {
|
|
17
|
+
provider: SearchProvider;
|
|
18
|
+
providerId: string;
|
|
19
|
+
fellBack: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const factories = new Map<string, SearchProviderFactory>();
|
|
23
|
+
|
|
24
|
+
function normalizeProviderId(id: string): string {
|
|
25
|
+
return id.trim().toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Registers a runtime search provider. Call this before rendering the app.
|
|
30
|
+
* The returned cleanup function only removes this exact registration.
|
|
31
|
+
*/
|
|
32
|
+
export function registerSearchProvider(id: string, factory: SearchProviderFactory): () => void {
|
|
33
|
+
const providerId = normalizeProviderId(id);
|
|
34
|
+
|
|
35
|
+
if (!providerId || providerId === 'local') {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'Search provider ids must be non-empty and cannot replace the built-in "local" provider.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
factories.set(providerId, factory);
|
|
42
|
+
|
|
43
|
+
return () => {
|
|
44
|
+
if (factories.get(providerId) === factory) {
|
|
45
|
+
factories.delete(providerId);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function createLocalProvider(options: Record<string, unknown>): Promise<SearchProvider> {
|
|
51
|
+
const { createLocalSearchProvider } = await import('@/lib/search/providers/local');
|
|
52
|
+
return createLocalSearchProvider(options);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolves a configured provider, falling back to local for unknown ids. */
|
|
56
|
+
export async function resolveSearchProvider(
|
|
57
|
+
id: string,
|
|
58
|
+
options: Record<string, unknown> = {},
|
|
59
|
+
): Promise<ResolvedSearchProvider> {
|
|
60
|
+
const requestedId = normalizeProviderId(id) || 'local';
|
|
61
|
+
|
|
62
|
+
if (requestedId === 'local') {
|
|
63
|
+
return {
|
|
64
|
+
provider: await createLocalProvider(options),
|
|
65
|
+
providerId: 'local',
|
|
66
|
+
fellBack: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const factory = factories.get(requestedId);
|
|
71
|
+
|
|
72
|
+
if (factory) {
|
|
73
|
+
return {
|
|
74
|
+
provider: await factory(options),
|
|
75
|
+
providerId: requestedId,
|
|
76
|
+
fellBack: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
provider: await createLocalProvider({}),
|
|
82
|
+
providerId: 'local',
|
|
83
|
+
fellBack: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SearchRecord } from '@/lib/search';
|
|
2
|
+
import { filterRecordsByScope, searchIndex } from '@/lib/search';
|
|
3
|
+
import type { SearchProvider } from '@/lib/search/provider';
|
|
4
|
+
|
|
5
|
+
/** Built-in provider backed by the section index generated during the build. */
|
|
6
|
+
export function createLocalSearchProvider(_options: Record<string, unknown> = {}): SearchProvider {
|
|
7
|
+
let records: Promise<SearchRecord[]> | null = null;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
async search(query, limit, context) {
|
|
11
|
+
records ||= import('@/lib/search-index.generated').then(module => module.SEARCH_INDEX);
|
|
12
|
+
return searchIndex(filterRecordsByScope(await records, context), query, limit);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side search over the build-time index
|
|
3
|
+
* (see scripts/generate-search-index.mjs).
|
|
4
|
+
*
|
|
5
|
+
* Plain substring scoring, no dependencies: docs sites are small enough that
|
|
6
|
+
* scanning a few hundred sections per keystroke is cheap, and substring
|
|
7
|
+
* matches behave predictably for exact terms like config keys.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface SearchRecord {
|
|
11
|
+
/** Base-relative route of the page. */
|
|
12
|
+
url: string;
|
|
13
|
+
/** Page title. */
|
|
14
|
+
page: string;
|
|
15
|
+
/** Section heading, absent for the page intro. */
|
|
16
|
+
heading?: string;
|
|
17
|
+
/** Heading anchor id, matching the rendered heading. */
|
|
18
|
+
id?: string;
|
|
19
|
+
/** Plain text of the section. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** Navigation scope id, absent for single-scope sites. */
|
|
22
|
+
scopeId?: string;
|
|
23
|
+
/** Language label of the owning scope, when the site defines languages. */
|
|
24
|
+
language?: string;
|
|
25
|
+
/** Version label of the owning scope, when the site defines versions. */
|
|
26
|
+
version?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where a search originates, so providers can stay inside the active scope. */
|
|
30
|
+
export interface SearchContext {
|
|
31
|
+
scopeId?: string;
|
|
32
|
+
language?: string;
|
|
33
|
+
version?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Restricts records to the active scope. Records without a scope id (from
|
|
38
|
+
* single-scope indexes or older caches) always pass.
|
|
39
|
+
*/
|
|
40
|
+
export function filterRecordsByScope(
|
|
41
|
+
records: SearchRecord[],
|
|
42
|
+
context?: SearchContext,
|
|
43
|
+
): SearchRecord[] {
|
|
44
|
+
if (!context?.scopeId) {
|
|
45
|
+
return records;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return records.filter(record => !record.scopeId || record.scopeId === context.scopeId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SearchResult {
|
|
52
|
+
/** Route including the section anchor. */
|
|
53
|
+
url: string;
|
|
54
|
+
/** Page title. */
|
|
55
|
+
page: string;
|
|
56
|
+
/** Section heading, absent for the page intro. */
|
|
57
|
+
heading?: string;
|
|
58
|
+
/** Snippet of section text around the first match, when the text matched. */
|
|
59
|
+
snippet?: string;
|
|
60
|
+
/** Provider-specific relevance score. Use 0 when a provider does not expose one. */
|
|
61
|
+
score: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SNIPPET_RADIUS = 60;
|
|
65
|
+
|
|
66
|
+
function makeSnippet(text: string, index: number, length: number): string {
|
|
67
|
+
const start = Math.max(0, index - SNIPPET_RADIUS);
|
|
68
|
+
const end = Math.min(text.length, index + length + SNIPPET_RADIUS);
|
|
69
|
+
|
|
70
|
+
return `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function searchIndex(records: SearchRecord[], query: string, limit = 10): SearchResult[] {
|
|
74
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
75
|
+
|
|
76
|
+
if (!terms.length) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const results: SearchResult[] = [];
|
|
81
|
+
|
|
82
|
+
for (const record of records) {
|
|
83
|
+
const page = record.page.toLowerCase();
|
|
84
|
+
const heading = (record.heading || '').toLowerCase();
|
|
85
|
+
const text = record.text.toLowerCase();
|
|
86
|
+
|
|
87
|
+
let score = 0;
|
|
88
|
+
let snippetAt = -1;
|
|
89
|
+
let snippetLength = 0;
|
|
90
|
+
let matched = true;
|
|
91
|
+
|
|
92
|
+
for (const term of terms) {
|
|
93
|
+
if (page.includes(term)) {
|
|
94
|
+
score += page === term ? 40 : 20;
|
|
95
|
+
} else if (heading.includes(term)) {
|
|
96
|
+
score += heading === term ? 30 : 15;
|
|
97
|
+
} else {
|
|
98
|
+
const index = text.indexOf(term);
|
|
99
|
+
|
|
100
|
+
if (index === -1) {
|
|
101
|
+
matched = false;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
score += 5;
|
|
106
|
+
|
|
107
|
+
if (snippetAt === -1) {
|
|
108
|
+
snippetAt = index;
|
|
109
|
+
snippetLength = term.length;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!matched || !score) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
results.push({
|
|
119
|
+
url: record.id ? `${record.url}#${record.id}` : record.url,
|
|
120
|
+
page: record.page,
|
|
121
|
+
heading: record.heading,
|
|
122
|
+
snippet: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength) : undefined,
|
|
123
|
+
score,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
128
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import rawConfig from 'virtual:shiso-docs-config';
|
|
2
|
+
import { resolveDocFile } from '@/lib/content';
|
|
3
|
+
import {
|
|
4
|
+
assertDocsConfig,
|
|
5
|
+
getDefaultScope,
|
|
6
|
+
getScopeForPage,
|
|
7
|
+
getPageByPathname as getSitePageByPathname,
|
|
8
|
+
normalizeDocsSite,
|
|
9
|
+
} from '@/lib/docs-config';
|
|
10
|
+
import { getTextDirection, resolveLocale } from '@/lib/locale';
|
|
11
|
+
import { stripBase } from '@/lib/paths';
|
|
12
|
+
import { resolveSiteModel } from '@/lib/site-model';
|
|
13
|
+
import type {
|
|
14
|
+
DocsConfig,
|
|
15
|
+
DocsScope,
|
|
16
|
+
NormalizedDocsConfig,
|
|
17
|
+
NormalizedDocsPage,
|
|
18
|
+
NormalizedDocsSite,
|
|
19
|
+
RedirectRule,
|
|
20
|
+
SeoConfig,
|
|
21
|
+
} from '@/lib/types';
|
|
22
|
+
|
|
23
|
+
assertDocsConfig(rawConfig, 'docs.json');
|
|
24
|
+
|
|
25
|
+
export const siteConfig: DocsConfig = rawConfig;
|
|
26
|
+
|
|
27
|
+
/** The complete normalized site: every version/language scope. */
|
|
28
|
+
export const docsSite: NormalizedDocsSite = normalizeDocsSite(siteConfig, resolveDocFile);
|
|
29
|
+
|
|
30
|
+
/** The default scope's navigation, used where a single navigation is expected. */
|
|
31
|
+
export const docsConfig: NormalizedDocsConfig = getDefaultScope(docsSite).docs;
|
|
32
|
+
|
|
33
|
+
/** Landing page of the default scope: the site-wide "docs home" URL. */
|
|
34
|
+
export const docsHomeUrl = getDefaultScope(docsSite).firstPageUrl;
|
|
35
|
+
|
|
36
|
+
export const siteModel = resolveSiteModel(siteConfig, docsConfig);
|
|
37
|
+
|
|
38
|
+
/** Scope that owns the current pathname; the default scope for unknown paths. */
|
|
39
|
+
export function getScopeByPathname(pathname: string): DocsScope {
|
|
40
|
+
const page = getPageByPathname(pathname);
|
|
41
|
+
return page ? getScopeForPage(docsSite, page) : getDefaultScope(docsSite);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Document language and direction for a pathname, from its scope's language. */
|
|
45
|
+
export function getLocaleByPathname(pathname: string): { lang: string; dir: 'ltr' | 'rtl' } {
|
|
46
|
+
const lang = resolveLocale(getScopeByPathname(pathname).language, siteModel.locale);
|
|
47
|
+
return { lang, dir: getTextDirection(lang) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const siteName = siteModel.name;
|
|
51
|
+
|
|
52
|
+
/** Trailing-slash-insensitive route key for redirect matching. */
|
|
53
|
+
function toRouteKey(routePath: string): string {
|
|
54
|
+
const trimmed = routePath.replace(/\/+$/, '');
|
|
55
|
+
return trimmed || '/';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Redirect rules with exact-match sources. Wildcard patterns are rejected by
|
|
60
|
+
* the schema; this guard covers configs that bypassed validation.
|
|
61
|
+
*/
|
|
62
|
+
export function getRedirects(): RedirectRule[] {
|
|
63
|
+
return (siteConfig.redirects || []).filter(rule => {
|
|
64
|
+
if (!rule?.source || !rule.destination) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (/[:*]/.test(rule.source)) {
|
|
69
|
+
console.warn(
|
|
70
|
+
`[shiso] Redirect source "${rule.source}" uses a wildcard pattern, which is not ` +
|
|
71
|
+
'supported — it will be skipped. Use an exact source path.',
|
|
72
|
+
);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const redirectBySource = new Map(
|
|
81
|
+
getRedirects().map(rule => [toRouteKey(rule.source), rule.destination]),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/** Destination for a base-relative route covered by a redirect rule, if any. */
|
|
85
|
+
export function matchRedirect(routePath: string): string | null {
|
|
86
|
+
return redirectBySource.get(toRouteKey(routePath)) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getSeo(): SeoConfig {
|
|
90
|
+
const seo = siteConfig.seo || {};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
metatags: seo.metatags || {},
|
|
94
|
+
indexing: seo.indexing === 'all' ? 'all' : 'navigable',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when the last-modified timestamp should show for a page. */
|
|
99
|
+
export function showTimestamp(frontmatterValue: unknown): boolean {
|
|
100
|
+
if (typeof frontmatterValue === 'boolean') {
|
|
101
|
+
return frontmatterValue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return siteModel.showTimestamp;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getPageByPathname(pathname: string): NormalizedDocsPage | null {
|
|
108
|
+
return getSitePageByPathname(docsSite, stripBase(pathname));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getPageTitle(pageTitle?: string): string {
|
|
112
|
+
if (pageTitle && siteName) {
|
|
113
|
+
return `${pageTitle} – ${siteName}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return pageTitle || siteName || '';
|
|
117
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { toAbsoluteUrl, toHref } from '@/lib/paths';
|
|
2
|
+
import { resolveSearchConfig } from '@/lib/search/config';
|
|
3
|
+
import type {
|
|
4
|
+
ConfigLink,
|
|
5
|
+
ContextualOption,
|
|
6
|
+
ContextualOptionObject,
|
|
7
|
+
DocsConfig,
|
|
8
|
+
LinkTarget,
|
|
9
|
+
NormalizedDocsConfig,
|
|
10
|
+
NormalizedDocsPage,
|
|
11
|
+
NormalizedFooter,
|
|
12
|
+
NormalizedLink,
|
|
13
|
+
NormalizedNavbar,
|
|
14
|
+
ResolvedContextualOption,
|
|
15
|
+
SiteModel,
|
|
16
|
+
ThemeLabels,
|
|
17
|
+
} from '@/lib/types';
|
|
18
|
+
|
|
19
|
+
const SHISO_THEME_LABELS: ThemeLabels = {
|
|
20
|
+
menu: 'Menu',
|
|
21
|
+
documentationNavigation: 'Documentation navigation',
|
|
22
|
+
sections: 'Sections',
|
|
23
|
+
tableOfContents: 'On this page',
|
|
24
|
+
tableOfContentsNavigation: 'Table of contents',
|
|
25
|
+
searchTitle: 'Search',
|
|
26
|
+
searching: 'Searching...',
|
|
27
|
+
searchUnavailable: 'Search unavailable',
|
|
28
|
+
noResults: 'No results',
|
|
29
|
+
lastUpdated: 'Last updated on',
|
|
30
|
+
notFound: 'Page not found',
|
|
31
|
+
dismissBanner: 'Dismiss banner',
|
|
32
|
+
toggleTheme: 'Toggle theme',
|
|
33
|
+
moreOptions: 'More options',
|
|
34
|
+
copied: 'Copied',
|
|
35
|
+
expand: 'Expand',
|
|
36
|
+
collapse: 'Collapse',
|
|
37
|
+
copyPage: 'Copy page',
|
|
38
|
+
copyPageDescription: 'Copy this page as Markdown',
|
|
39
|
+
viewMarkdown: 'View as Markdown',
|
|
40
|
+
viewMarkdownDescription: 'Open this page as plain Markdown',
|
|
41
|
+
openInChatGPT: 'Open in ChatGPT',
|
|
42
|
+
openInClaude: 'Open in Claude',
|
|
43
|
+
openInPerplexity: 'Open in Perplexity',
|
|
44
|
+
askQuestionsAboutPage: 'Ask questions about this page',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function isInternalHref(href: string): boolean {
|
|
48
|
+
return /^(?:#|\/|\.\.?\/)/.test(href);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveLinkTarget(href: string, target?: LinkTarget): LinkTarget {
|
|
52
|
+
return target || (isInternalHref(href) ? '_self' : '_blank');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeLink(link: ConfigLink): NormalizedLink {
|
|
56
|
+
return {
|
|
57
|
+
href: link.href,
|
|
58
|
+
label: link.label?.trim() || undefined,
|
|
59
|
+
ariaLabel: link.ariaLabel?.trim() || undefined,
|
|
60
|
+
icon: link.icon?.trim() || undefined,
|
|
61
|
+
target: resolveLinkTarget(link.href, link.target),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeNavbar(config: DocsConfig['navbar']): NormalizedNavbar | null {
|
|
66
|
+
if (!config) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const links = (config.links || []).filter(link => !!link?.href).map(normalizeLink);
|
|
71
|
+
const primary = config.primary?.href ? normalizeLink(config.primary) : undefined;
|
|
72
|
+
|
|
73
|
+
return links.length || primary ? { links, primary } : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeFooter(config: DocsConfig['footer']): NormalizedFooter | null {
|
|
77
|
+
const socials = (config?.socials || []).filter(link => !!link?.href).map(normalizeLink);
|
|
78
|
+
const links = (config?.links || [])
|
|
79
|
+
.filter(column => column?.items?.length)
|
|
80
|
+
.map(column => ({
|
|
81
|
+
...column,
|
|
82
|
+
items: column.items.map(item => ({
|
|
83
|
+
...item,
|
|
84
|
+
target: resolveLinkTarget(item.href, item.target),
|
|
85
|
+
})),
|
|
86
|
+
}));
|
|
87
|
+
const attribution = config?.attribution !== false;
|
|
88
|
+
|
|
89
|
+
return socials.length || links.length || attribution ? { socials, links, attribution } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveSiteModel(config: DocsConfig, docs: NormalizedDocsConfig): SiteModel {
|
|
93
|
+
const appearance = config.appearance || {};
|
|
94
|
+
const logo = config.logo
|
|
95
|
+
? typeof config.logo === 'string'
|
|
96
|
+
? { light: config.logo, dark: config.logo }
|
|
97
|
+
: {
|
|
98
|
+
light: config.logo.light || config.logo.dark,
|
|
99
|
+
dark: config.logo.dark || config.logo.light,
|
|
100
|
+
href: config.logo.href,
|
|
101
|
+
target: config.logo.href
|
|
102
|
+
? resolveLinkTarget(config.logo.href, config.logo.target)
|
|
103
|
+
: undefined,
|
|
104
|
+
}
|
|
105
|
+
: null;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
name: config.name?.trim() || undefined,
|
|
109
|
+
logo,
|
|
110
|
+
navbar: normalizeNavbar(config.navbar),
|
|
111
|
+
footer: normalizeFooter(config.footer),
|
|
112
|
+
banner: config.banner?.content?.trim()
|
|
113
|
+
? { content: config.banner.content.trim(), dismissible: config.banner.dismissible === true }
|
|
114
|
+
: null,
|
|
115
|
+
appearance: {
|
|
116
|
+
default:
|
|
117
|
+
appearance.default === 'light' || appearance.default === 'dark'
|
|
118
|
+
? appearance.default
|
|
119
|
+
: 'system',
|
|
120
|
+
strict: appearance.strict === true,
|
|
121
|
+
},
|
|
122
|
+
styling: {
|
|
123
|
+
eyebrows: config.styling?.eyebrows === 'breadcrumbs' ? 'breadcrumbs' : 'section',
|
|
124
|
+
},
|
|
125
|
+
search: resolveSearchConfig(config.search),
|
|
126
|
+
contextualOptions: config.contextual?.options || [],
|
|
127
|
+
error404: { ...config.errors?.['404'], redirect: config.errors?.['404']?.redirect !== false },
|
|
128
|
+
showTimestamp: config.metadata?.timestamp === true,
|
|
129
|
+
drilldown: config.interaction?.drilldown,
|
|
130
|
+
locale: config.$shiso?.locale?.trim() || 'en-US',
|
|
131
|
+
labels: SHISO_THEME_LABELS,
|
|
132
|
+
docs,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function aiPrompt(mdUrl: string): string {
|
|
137
|
+
return `Read ${mdUrl} so I can ask questions about it.`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolveContextualOptions(
|
|
141
|
+
options: ContextualOption[],
|
|
142
|
+
page: NormalizedDocsPage,
|
|
143
|
+
labels: ThemeLabels = SHISO_THEME_LABELS,
|
|
144
|
+
): ResolvedContextualOption[] {
|
|
145
|
+
const mdHref = `${toHref(page.url)}.md`;
|
|
146
|
+
const absolutePageUrl = toAbsoluteUrl(page.url);
|
|
147
|
+
const mdUrl = absolutePageUrl ? `${absolutePageUrl}.md` : undefined;
|
|
148
|
+
const resolved: ResolvedContextualOption[] = [];
|
|
149
|
+
|
|
150
|
+
for (const option of options) {
|
|
151
|
+
if (typeof option !== 'string') {
|
|
152
|
+
const custom = option as ContextualOptionObject;
|
|
153
|
+
const href = custom.href.replaceAll('$path', page.url).replaceAll('$page', mdUrl || mdHref);
|
|
154
|
+
|
|
155
|
+
resolved.push({
|
|
156
|
+
key: custom.title,
|
|
157
|
+
title: custom.title,
|
|
158
|
+
description: custom.description,
|
|
159
|
+
icon: custom.icon,
|
|
160
|
+
action: 'link',
|
|
161
|
+
href,
|
|
162
|
+
target: resolveLinkTarget(href, custom.target),
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (option === 'copy') {
|
|
168
|
+
resolved.push({
|
|
169
|
+
key: option,
|
|
170
|
+
title: labels.copyPage,
|
|
171
|
+
description: labels.copyPageDescription,
|
|
172
|
+
icon: 'copy',
|
|
173
|
+
action: 'copy',
|
|
174
|
+
href: mdHref,
|
|
175
|
+
target: '_self',
|
|
176
|
+
});
|
|
177
|
+
} else if (option === 'view') {
|
|
178
|
+
resolved.push({
|
|
179
|
+
key: option,
|
|
180
|
+
title: labels.viewMarkdown,
|
|
181
|
+
description: labels.viewMarkdownDescription,
|
|
182
|
+
icon: 'external-link',
|
|
183
|
+
action: 'link',
|
|
184
|
+
href: mdHref,
|
|
185
|
+
target: '_blank',
|
|
186
|
+
});
|
|
187
|
+
} else if (mdUrl && option === 'chatgpt') {
|
|
188
|
+
resolved.push({
|
|
189
|
+
key: option,
|
|
190
|
+
title: labels.openInChatGPT,
|
|
191
|
+
description: labels.askQuestionsAboutPage,
|
|
192
|
+
icon: 'external-link',
|
|
193
|
+
action: 'link',
|
|
194
|
+
href: `https://chatgpt.com/?q=${encodeURIComponent(aiPrompt(mdUrl))}`,
|
|
195
|
+
target: '_blank',
|
|
196
|
+
});
|
|
197
|
+
} else if (mdUrl && option === 'claude') {
|
|
198
|
+
resolved.push({
|
|
199
|
+
key: option,
|
|
200
|
+
title: labels.openInClaude,
|
|
201
|
+
description: labels.askQuestionsAboutPage,
|
|
202
|
+
icon: 'external-link',
|
|
203
|
+
action: 'link',
|
|
204
|
+
href: `https://claude.ai/new?q=${encodeURIComponent(aiPrompt(mdUrl))}`,
|
|
205
|
+
target: '_blank',
|
|
206
|
+
});
|
|
207
|
+
} else if (mdUrl && option === 'perplexity') {
|
|
208
|
+
resolved.push({
|
|
209
|
+
key: option,
|
|
210
|
+
title: labels.openInPerplexity,
|
|
211
|
+
description: labels.askQuestionsAboutPage,
|
|
212
|
+
icon: 'external-link',
|
|
213
|
+
action: 'link',
|
|
214
|
+
href: `https://www.perplexity.ai/search?q=${encodeURIComponent(aiPrompt(mdUrl))}`,
|
|
215
|
+
target: '_blank',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return resolved;
|
|
221
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import GithubSlugger, { slug as slugifyOnce } from 'github-slugger';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single slug algorithm in the repo.
|
|
5
|
+
*
|
|
6
|
+
* Heading anchors are produced in two independent places — `remarkToc` (which
|
|
7
|
+
* builds the on-page table of contents) and `rehype-slug` (which sets the `id`
|
|
8
|
+
* on the rendered heading). Both must agree or every TOC link breaks, so both
|
|
9
|
+
* go through github-slugger here rather than reimplementing it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a stateful slugger. Call `.slug(text)` once per heading in document
|
|
14
|
+
* order; repeated headings get `-1`, `-2`, ... suffixes exactly as rehype-slug
|
|
15
|
+
* does, because it is the same implementation.
|
|
16
|
+
*/
|
|
17
|
+
export function createSlugger(): GithubSlugger {
|
|
18
|
+
return new GithubSlugger();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Stateless slugify for one-off ids that do not need de-duplication.
|
|
23
|
+
*
|
|
24
|
+
* github-slugger does not trim, so padded input would otherwise produce leading
|
|
25
|
+
* and trailing dashes. Heading text is trimmed upstream by `headingText`; this
|
|
26
|
+
* makes the guarantee hold for every other caller too.
|
|
27
|
+
*/
|
|
28
|
+
export function slugify(value: string): string {
|
|
29
|
+
return slugifyOnce(value.trim());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Slugifies a label into a config-level identifier (tab ids, section ids).
|
|
34
|
+
* Falls back when the value contains no slug-able characters.
|
|
35
|
+
*/
|
|
36
|
+
export function slugifyId(value: string, fallback: string): string {
|
|
37
|
+
return slugify(value) || fallback;
|
|
38
|
+
}
|