@umami/shiso 1.16.0 → 1.17.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/bin/shiso.mjs +0 -0
- package/dist/chunks/App.js +228 -110
- package/dist/chunks/docs.js +180 -180
- package/dist/entry-server.js +1 -1
- package/docs.schema.json +1131 -1003
- package/package.json +1 -1
- package/src/App.tsx +9 -1
- package/src/components/DocContent.tsx +15 -6
- package/src/components/LanguageSwitcher.tsx +10 -3
- package/src/components/PageActions.tsx +134 -0
- package/src/components/docs/Changelog.tsx +22 -8
- package/src/lib/locale.ts +25 -0
- package/src/lib/site-model.ts +2 -0
- package/src/lib/types.ts +24 -0
- package/src/styles/global.css +1 -1
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -12,8 +12,16 @@ import { docsHomeUrl, hasRootStandalonePage, siteModel, standalonePages } from '
|
|
|
12
12
|
import { DocPage } from '@/pages/DocPage';
|
|
13
13
|
import { StandalonePageView } from '@/pages/StandalonePage';
|
|
14
14
|
|
|
15
|
+
// Runtime helpers are public exports, but cannot be rendered as MDX components.
|
|
16
|
+
const {
|
|
17
|
+
mermaidSource: _mermaidSource,
|
|
18
|
+
usePanelContent: _usePanelContent,
|
|
19
|
+
useSetPanelContent: _useSetPanelContent,
|
|
20
|
+
...mdxDocsComponents
|
|
21
|
+
} = docsComponents;
|
|
22
|
+
|
|
15
23
|
const mdxComponents = {
|
|
16
|
-
...
|
|
24
|
+
...mdxDocsComponents,
|
|
17
25
|
img: docsComponents.ZoomableImage,
|
|
18
26
|
pre: CodeBlock,
|
|
19
27
|
};
|
|
@@ -3,6 +3,7 @@ import { ContextualMenu } from '@/components/ContextualMenu';
|
|
|
3
3
|
import { Badge } from '@/components/docs/Badge';
|
|
4
4
|
import { ArrowLeft, ArrowRight, FileText } from '@/components/icons';
|
|
5
5
|
import { OpenApiOperation } from '@/components/OpenApiOperation';
|
|
6
|
+
import { PageActions } from '@/components/PageActions';
|
|
6
7
|
import { getLastModified } from '@/lib/content';
|
|
7
8
|
import { getScopeForPage } from '@/lib/docs-config';
|
|
8
9
|
import { resolveLocale } from '@/lib/locale';
|
|
@@ -135,12 +136,20 @@ export function DocContent({ page, doc, site }: DocContentProps) {
|
|
|
135
136
|
<Content />
|
|
136
137
|
</div>
|
|
137
138
|
{operation && <OpenApiOperation operation={operation} />}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
<PageActions
|
|
140
|
+
key={page.url}
|
|
141
|
+
page={page}
|
|
142
|
+
frontmatter={doc.frontmatter}
|
|
143
|
+
site={site}
|
|
144
|
+
lastUpdated={
|
|
145
|
+
lastModified ? (
|
|
146
|
+
<>
|
|
147
|
+
{site.labels.lastUpdated}{' '}
|
|
148
|
+
<time dateTime={lastModified}>{dateFormat.format(new Date(lastModified))}</time>
|
|
149
|
+
</>
|
|
150
|
+
) : undefined
|
|
151
|
+
}
|
|
152
|
+
/>
|
|
144
153
|
{related.length > 0 && (
|
|
145
154
|
<nav className="mt-8" aria-label={site.labels.relatedTopics} data-pagefind-ignore>
|
|
146
155
|
<div className="text-sm text-muted-foreground">{site.labels.relatedTopics}</div>
|
|
@@ -8,13 +8,15 @@ import {
|
|
|
8
8
|
DropdownMenuTrigger,
|
|
9
9
|
} from '@/components/ui/dropdown-menu';
|
|
10
10
|
import { getLanguageScopes } from '@/lib/docs-config';
|
|
11
|
+
import { getLanguageName, isValidLocale } from '@/lib/locale';
|
|
11
12
|
import { docsSite, getScopeByPathname } from '@/lib/site-config';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Language selector for multi-language sites. Each option is a language's
|
|
15
16
|
* landing scope — its default version — so switching languages always lands
|
|
16
17
|
* on that language's default-version first page. Hidden languages never
|
|
17
|
-
* appear as options.
|
|
18
|
+
* appear as options. Each language is shown by its native name, e.g. "ja"
|
|
19
|
+
* as "日本語".
|
|
18
20
|
*/
|
|
19
21
|
export function LanguageSwitcher() {
|
|
20
22
|
const { pathname } = useLocation();
|
|
@@ -36,7 +38,7 @@ export function LanguageSwitcher() {
|
|
|
36
38
|
/>
|
|
37
39
|
}
|
|
38
40
|
>
|
|
39
|
-
{current.language}
|
|
41
|
+
{getLanguageName(current.language)}
|
|
40
42
|
<ChevronRight className="size-3.5 rotate-90 text-muted-foreground" />
|
|
41
43
|
</DropdownMenuTrigger>
|
|
42
44
|
<DropdownMenuContent align="start" className="min-w-32">
|
|
@@ -49,7 +51,12 @@ export function LanguageSwitcher() {
|
|
|
49
51
|
}
|
|
50
52
|
}}
|
|
51
53
|
>
|
|
52
|
-
<span
|
|
54
|
+
<span
|
|
55
|
+
className="grow"
|
|
56
|
+
lang={isValidLocale(scope.language) ? scope.language : undefined}
|
|
57
|
+
>
|
|
58
|
+
{scope.language ? getLanguageName(scope.language) : null}
|
|
59
|
+
</span>
|
|
53
60
|
{scope.language === current.language ? <Check className="size-3.5" /> : null}
|
|
54
61
|
</DropdownMenuItem>
|
|
55
62
|
))}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { ExternalLink } from '@/components/icons';
|
|
3
|
+
import { toHref } from '@/lib/paths';
|
|
4
|
+
import type { DocFrontmatter, NormalizedDocsPage, SiteModel } from '@/lib/types';
|
|
5
|
+
|
|
6
|
+
function editHref(template: string | undefined, filePath: string): string | undefined {
|
|
7
|
+
if (!template) return undefined;
|
|
8
|
+
const file = filePath.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
|
9
|
+
const href = template.replaceAll('$file', file);
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(href);
|
|
12
|
+
if (url.protocol === 'https:' || url.protocol === 'http:') return url.href;
|
|
13
|
+
} catch {
|
|
14
|
+
// Invalid frontmatter URLs should never become executable links.
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PageActions({
|
|
20
|
+
page,
|
|
21
|
+
frontmatter,
|
|
22
|
+
site,
|
|
23
|
+
lastUpdated,
|
|
24
|
+
}: {
|
|
25
|
+
page: NormalizedDocsPage;
|
|
26
|
+
frontmatter?: DocFrontmatter;
|
|
27
|
+
site: SiteModel;
|
|
28
|
+
lastUpdated?: ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
const [status, setStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
|
|
31
|
+
const [rating, setRating] = useState<boolean>();
|
|
32
|
+
const request = useRef<AbortController | null>(null);
|
|
33
|
+
useEffect(() => () => request.current?.abort(), []);
|
|
34
|
+
|
|
35
|
+
const feedback = frontmatter?.feedback === false ? null : site.feedback;
|
|
36
|
+
const href =
|
|
37
|
+
frontmatter?.editLink === false
|
|
38
|
+
? undefined
|
|
39
|
+
: editHref(
|
|
40
|
+
typeof frontmatter?.editLink === 'string' ? frontmatter.editLink : site.editLink?.url,
|
|
41
|
+
page.filePath,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
async function submit(helpful: boolean) {
|
|
45
|
+
if (!feedback || request.current || status === 'success') return;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
request.current = controller;
|
|
48
|
+
setRating(helpful);
|
|
49
|
+
setStatus('pending');
|
|
50
|
+
const timeout = window.setTimeout(() => controller.abort(), 15000);
|
|
51
|
+
try {
|
|
52
|
+
const endpoint = new URL(toHref(feedback.endpoint), window.location.origin);
|
|
53
|
+
if (!['https:', 'http:'].includes(endpoint.protocol)) throw new Error('Invalid endpoint');
|
|
54
|
+
const response = await fetch(endpoint.href, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
helpful,
|
|
59
|
+
path: toHref(page.url),
|
|
60
|
+
title: frontmatter?.title || page.label,
|
|
61
|
+
language: page.language,
|
|
62
|
+
version: page.version,
|
|
63
|
+
}),
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) throw new Error('Feedback submission failed');
|
|
67
|
+
setStatus('success');
|
|
68
|
+
} catch {
|
|
69
|
+
setStatus('error');
|
|
70
|
+
} finally {
|
|
71
|
+
window.clearTimeout(timeout);
|
|
72
|
+
request.current = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!href && !feedback && !lastUpdated) return null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
{(lastUpdated || href) && (
|
|
81
|
+
<div
|
|
82
|
+
className="mt-8 flex items-baseline gap-4 text-sm text-muted-foreground"
|
|
83
|
+
data-pagefind-ignore
|
|
84
|
+
>
|
|
85
|
+
{lastUpdated && <div className="min-w-0">{lastUpdated}</div>}
|
|
86
|
+
{href && (
|
|
87
|
+
<a
|
|
88
|
+
href={href}
|
|
89
|
+
target="_blank"
|
|
90
|
+
rel="noopener noreferrer"
|
|
91
|
+
className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-right no-underline hover:text-primary"
|
|
92
|
+
>
|
|
93
|
+
{site.editLink?.label || 'Edit this page'}
|
|
94
|
+
<ExternalLink size={14} aria-hidden="true" />
|
|
95
|
+
</a>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
{feedback && (
|
|
100
|
+
<div
|
|
101
|
+
className="mt-8 flex flex-col gap-2 border-t border-border pt-6 text-sm"
|
|
102
|
+
aria-busy={status === 'pending'}
|
|
103
|
+
data-pagefind-ignore
|
|
104
|
+
>
|
|
105
|
+
<fieldset
|
|
106
|
+
aria-label={feedback.prompt || 'Was this page helpful?'}
|
|
107
|
+
className="flex flex-wrap items-center gap-2"
|
|
108
|
+
>
|
|
109
|
+
<span className="mr-2 text-muted-foreground">
|
|
110
|
+
{feedback.prompt || 'Was this page helpful?'}
|
|
111
|
+
</span>
|
|
112
|
+
{[true, false].map(helpful => (
|
|
113
|
+
<button
|
|
114
|
+
key={String(helpful)}
|
|
115
|
+
type="button"
|
|
116
|
+
aria-pressed={rating === helpful}
|
|
117
|
+
disabled={status === 'pending' || status === 'success'}
|
|
118
|
+
onClick={() => void submit(helpful)}
|
|
119
|
+
className="rounded-md border border-border px-3 py-1.5 text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-default disabled:opacity-60 aria-pressed:border-primary aria-pressed:text-primary"
|
|
120
|
+
>
|
|
121
|
+
{helpful ? feedback.helpfulLabel || 'Yes' : feedback.unhelpfulLabel || 'No'}
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</fieldset>
|
|
125
|
+
<div role="status" aria-live="polite" className="text-muted-foreground">
|
|
126
|
+
{status === 'success' && (feedback.successMessage || 'Thanks for your feedback!')}
|
|
127
|
+
{status === 'error' &&
|
|
128
|
+
(feedback.errorMessage || 'Could not send feedback. Please try again.')}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
Fragment,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import { useSearchParams } from 'react-router';
|
|
3
10
|
import { Badge } from './Badge';
|
|
4
11
|
import { styles } from './styles';
|
|
@@ -13,12 +20,17 @@ interface UpdateChildInfo {
|
|
|
13
20
|
tags: string[];
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
interface UpdateChildProps {
|
|
24
|
+
tags?: unknown;
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/** Flattens fragments so <Update> entries are found however MDX nests them. */
|
|
17
|
-
function flatElements(children: React.ReactNode): ReactElement<
|
|
18
|
-
const flat: ReactElement<
|
|
29
|
+
function flatElements(children: React.ReactNode): ReactElement<UpdateChildProps>[] {
|
|
30
|
+
const flat: ReactElement<UpdateChildProps>[] = [];
|
|
19
31
|
|
|
20
32
|
Children.forEach(children, child => {
|
|
21
|
-
if (!isValidElement<
|
|
33
|
+
if (!isValidElement<UpdateChildProps>(child)) {
|
|
22
34
|
return;
|
|
23
35
|
}
|
|
24
36
|
if (child.type === Fragment) {
|
|
@@ -43,9 +55,11 @@ function updateInfo(children: React.ReactNode): Map<string, UpdateChildInfo> {
|
|
|
43
55
|
const key = String(child.key ?? `update-${index}`);
|
|
44
56
|
const raw = child.props.tags;
|
|
45
57
|
const tags = Array.isArray(raw)
|
|
46
|
-
? [
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
? [
|
|
59
|
+
...new Set(
|
|
60
|
+
raw.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0),
|
|
61
|
+
),
|
|
62
|
+
].map(tag => tag.trim())
|
|
49
63
|
: [];
|
|
50
64
|
info.set(key, { key, tags });
|
|
51
65
|
});
|
|
@@ -142,7 +156,7 @@ export function Changelog({ children }: ChangelogProps) {
|
|
|
142
156
|
className={styles.changelogFilterButton}
|
|
143
157
|
data-active={active ? '' : undefined}
|
|
144
158
|
>
|
|
145
|
-
<Badge size="sm"
|
|
159
|
+
<Badge size="sm" tone={active ? 'primary' : undefined}>
|
|
146
160
|
{tag}
|
|
147
161
|
</Badge>
|
|
148
162
|
</button>
|
package/src/lib/locale.ts
CHANGED
|
@@ -37,3 +37,28 @@ export function getTextDirection(locale: string): 'ltr' | 'rtl' {
|
|
|
37
37
|
const primary = locale.split('-')[0]?.toLowerCase() || '';
|
|
38
38
|
return RTL_LANGUAGES.has(primary) ? 'rtl' : 'ltr';
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A language's name written in that language, for language selectors:
|
|
43
|
+
* "ja" -> "日本語", "zh-Hant" -> "繁體中文", "es" -> "Español". Labels that
|
|
44
|
+
* are not valid locale codes (e.g. "English") are returned unchanged.
|
|
45
|
+
*/
|
|
46
|
+
export function getLanguageName(language: string): string {
|
|
47
|
+
if (!isValidLocale(language)) {
|
|
48
|
+
return language;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const locale = Intl.getCanonicalLocales(language)[0];
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const name = new Intl.DisplayNames([locale], { type: 'language', fallback: 'none' }).of(locale);
|
|
55
|
+
|
|
56
|
+
if (!name) {
|
|
57
|
+
return language;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return name.charAt(0).toLocaleUpperCase(locale) + name.slice(1);
|
|
61
|
+
} catch {
|
|
62
|
+
return language;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/lib/site-model.ts
CHANGED
|
@@ -136,6 +136,8 @@ export function resolveSiteModel(
|
|
|
136
136
|
search: resolveSearchConfig(config.search),
|
|
137
137
|
contextualOptions: config.contextual?.options || [],
|
|
138
138
|
error404: { ...config.errors?.['404'], redirect: config.errors?.['404']?.redirect !== false },
|
|
139
|
+
editLink: config.editLink || null,
|
|
140
|
+
feedback: config.feedback || null,
|
|
139
141
|
showTimestamp: config.metadata?.timestamp === true,
|
|
140
142
|
drilldown: config.interaction?.drilldown,
|
|
141
143
|
locale: shiso?.locale || 'en-US',
|
package/src/lib/types.ts
CHANGED
|
@@ -242,6 +242,22 @@ export interface ErrorsConfig {
|
|
|
242
242
|
'404'?: Error404Config;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
export interface EditLinkConfig {
|
|
246
|
+
/** HTTP(S) URL template. $file is the encoded source path relative to the project root. */
|
|
247
|
+
url: string;
|
|
248
|
+
label?: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface FeedbackConfig {
|
|
252
|
+
/** HTTP(S) or site-relative endpoint accepting a JSON POST. */
|
|
253
|
+
endpoint: string;
|
|
254
|
+
prompt?: string;
|
|
255
|
+
helpfulLabel?: string;
|
|
256
|
+
unhelpfulLabel?: string;
|
|
257
|
+
successMessage?: string;
|
|
258
|
+
errorMessage?: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
245
261
|
export interface MetadataConfig {
|
|
246
262
|
/** Show the last-modified date on all pages. Overridable per page via `timestamp` frontmatter. */
|
|
247
263
|
timestamp?: boolean;
|
|
@@ -372,6 +388,8 @@ export interface DocsConfig {
|
|
|
372
388
|
seo?: SeoConfig;
|
|
373
389
|
errors?: ErrorsConfig;
|
|
374
390
|
metadata?: MetadataConfig;
|
|
391
|
+
editLink?: false | EditLinkConfig;
|
|
392
|
+
feedback?: false | FeedbackConfig;
|
|
375
393
|
appearance?: AppearanceConfig;
|
|
376
394
|
styling?: StylingConfig;
|
|
377
395
|
fonts?: FontsConfig;
|
|
@@ -584,6 +602,8 @@ export interface SiteModel {
|
|
|
584
602
|
contextualOptions: ContextualOption[];
|
|
585
603
|
error404: Required<Pick<Error404Config, 'redirect'>> & Error404Config;
|
|
586
604
|
showTimestamp: boolean;
|
|
605
|
+
editLink?: EditLinkConfig | null;
|
|
606
|
+
feedback?: FeedbackConfig | null;
|
|
587
607
|
drilldown?: boolean;
|
|
588
608
|
locale: string;
|
|
589
609
|
labels: ThemeLabels;
|
|
@@ -614,6 +634,10 @@ export interface TocEntry {
|
|
|
614
634
|
export type RelatedEntry = string | { href: string; title?: string };
|
|
615
635
|
|
|
616
636
|
export interface DocFrontmatter {
|
|
637
|
+
/** Override the edit URL, or hide the edit link on this page. */
|
|
638
|
+
editLink?: string | false;
|
|
639
|
+
/** Hide the feedback controls on this page. */
|
|
640
|
+
feedback?: false;
|
|
617
641
|
title?: string;
|
|
618
642
|
description?: string;
|
|
619
643
|
noindex?: boolean;
|
package/src/styles/global.css
CHANGED
|
@@ -183,7 +183,7 @@
|
|
|
183
183
|
border-radius: var(--radius-sm);
|
|
184
184
|
background: color-mix(in srgb, currentColor 4%, transparent);
|
|
185
185
|
padding: 0.1rem 0.3rem;
|
|
186
|
-
font-size: 0.
|
|
186
|
+
font-size: 0.875em;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/* Shared by GFM tables and the PropertiesTable component: rounded outer border,
|