@umami/shiso 1.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Open-source documentation framework for Markdown and MDX sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -372,7 +372,12 @@ export function normalizeOperations(spec) {
372
372
  const serverUrl = operation.servers?.[0]?.url || spec.servers?.[0]?.url || FALLBACK_SERVER;
373
373
  const normalized = {
374
374
  id: slugify(
375
- operation.operationId || `${method}-${pathName}`,
375
+ operation.operationId
376
+ ? operation.operationId
377
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
378
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
379
+ .replace(/[_\s]+/g, '-')
380
+ : `${method}-${pathName}`,
376
381
  `${method}-${slugify(pathName, 'root')}`,
377
382
  ),
378
383
  key: `${upper} ${pathName}`,
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
- ...docsComponents,
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
- {lastModified && (
139
- <div className="mt-8 text-sm text-muted-foreground">
140
- {site.labels.lastUpdated}{' '}
141
- <time dateTime={lastModified}>{dateFormat.format(new Date(lastModified))}</time>
142
- </div>
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 className="grow">{scope.language}</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 { Children, Fragment, isValidElement, type ReactElement, useMemo } from 'react';
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<{ tags?: unknown }>[] {
18
- const flat: ReactElement<{ tags?: unknown }>[] = [];
29
+ function flatElements(children: React.ReactNode): ReactElement<UpdateChildProps>[] {
30
+ const flat: ReactElement<UpdateChildProps>[] = [];
19
31
 
20
32
  Children.forEach(children, child => {
21
- if (!isValidElement<{ tags?: unknown }>(child)) {
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
- ? [...new Set(raw.filter((tag): tag is string => typeof tag === 'string' && tag.trim()))].map(
47
- tag => tag.trim(),
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" color={active ? 'primary' : undefined}>
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
+ }
@@ -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;
@@ -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.875rem;
186
+ font-size: 0.875em;
187
187
  }
188
188
 
189
189
  /* Shared by GFM tables and the PropertiesTable component: rounded outer border,