@astrojs/starlight 0.5.6 → 0.6.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.
Files changed (76) hide show
  1. package/404.astro +17 -17
  2. package/CHANGELOG.md +40 -2
  3. package/components/CallToAction.astro +32 -35
  4. package/components/ContentPanel.astro +18 -18
  5. package/components/EditLink.astro +23 -23
  6. package/components/FallbackContentNotice.astro +16 -18
  7. package/components/Footer.astro +28 -34
  8. package/components/HeadSEO.astro +69 -75
  9. package/components/Header.astro +47 -51
  10. package/components/Hero.astro +108 -121
  11. package/components/Icons.ts +73 -71
  12. package/components/LanguageSelect.astro +31 -31
  13. package/components/LastUpdated.astro +16 -18
  14. package/components/MarkdownContent.astro +105 -117
  15. package/components/MobileMenuToggle.astro +80 -80
  16. package/components/PrevNextLinks.astro +60 -60
  17. package/components/RightSidebar.astro +17 -17
  18. package/components/RightSidebarPanel.astro +41 -41
  19. package/components/Search.astro +293 -306
  20. package/components/Select.astro +64 -65
  21. package/components/Sidebar.astro +23 -23
  22. package/components/SidebarSublist.astro +96 -95
  23. package/components/SiteTitle.astro +65 -63
  24. package/components/SkipLink.astro +17 -17
  25. package/components/SocialIcons.astro +36 -34
  26. package/components/TableOfContents/MobileTableOfContents.astro +124 -124
  27. package/components/TableOfContents/TableOfContentsList.astro +64 -69
  28. package/components/TableOfContents/generateToC.ts +41 -43
  29. package/components/TableOfContents/starlight-toc.ts +84 -90
  30. package/components/TableOfContents.astro +8 -8
  31. package/components/ThemeProvider.astro +28 -32
  32. package/components/ThemeSelect.astro +66 -71
  33. package/global.d.ts +3 -3
  34. package/index.astro +1 -1
  35. package/index.ts +52 -59
  36. package/integrations/asides.ts +109 -111
  37. package/integrations/sitemap.ts +10 -13
  38. package/integrations/virtual-user-config.ts +37 -37
  39. package/layout/Page.astro +84 -72
  40. package/layout/PageFrame.astro +67 -69
  41. package/layout/TwoColumnContent.astro +40 -42
  42. package/package.json +2 -2
  43. package/schema.ts +126 -113
  44. package/schemas/favicon.ts +40 -0
  45. package/schemas/head.ts +12 -23
  46. package/schemas/i18n.ts +146 -160
  47. package/schemas/logo.ts +22 -22
  48. package/schemas/prevNextLink.ts +14 -14
  49. package/schemas/tableOfContents.ts +14 -18
  50. package/style/asides.css +27 -27
  51. package/style/props.css +168 -172
  52. package/style/reset.css +13 -13
  53. package/style/shiki.css +11 -11
  54. package/style/util.css +30 -30
  55. package/translations/fr.json +21 -21
  56. package/translations/index.ts +4 -4
  57. package/translations/it.json +20 -20
  58. package/translations/tr.json +1 -1
  59. package/translations/zh.json +0 -1
  60. package/user-components/Card.astro +50 -54
  61. package/user-components/CardGrid.astro +21 -21
  62. package/user-components/Icon.astro +19 -21
  63. package/user-components/TabItem.astro +3 -3
  64. package/user-components/Tabs.astro +113 -117
  65. package/user-components/rehype-tabs.ts +72 -72
  66. package/utils/base.ts +6 -6
  67. package/utils/git.ts +55 -55
  68. package/utils/head.ts +56 -54
  69. package/utils/i18n.ts +3 -3
  70. package/utils/localizedUrl.ts +25 -25
  71. package/utils/navigation.ts +225 -203
  72. package/utils/routing.ts +74 -85
  73. package/utils/slugs.ts +41 -51
  74. package/utils/translations.ts +26 -32
  75. package/utils/user-config.ts +278 -278
  76. package/virtual.d.ts +8 -8
@@ -3,43 +3,43 @@ import { rehype } from 'rehype';
3
3
  import { CONTINUE, SKIP, visit } from 'unist-util-visit';
4
4
 
5
5
  interface Panel {
6
- panelId: string;
7
- tabId: string;
8
- label: string;
6
+ panelId: string;
7
+ tabId: string;
8
+ label: string;
9
9
  }
10
10
 
11
11
  declare module 'vfile' {
12
- interface DataMap {
13
- panels: Panel[];
14
- }
12
+ interface DataMap {
13
+ panels: Panel[];
14
+ }
15
15
  }
16
16
 
17
17
  export const TabItemTagname = 'starlight-tab-item';
18
18
 
19
19
  // https://github.com/adobe/react-spectrum/blob/99ca82e87ba2d7fdd54f5b49326fd242320b4b51/packages/%40react-aria/focus/src/FocusScope.tsx#L256-L275
20
20
  const focusableElementSelectors = [
21
- 'input:not([disabled]):not([type=hidden])',
22
- 'select:not([disabled])',
23
- 'textarea:not([disabled])',
24
- 'button:not([disabled])',
25
- 'a[href]',
26
- 'area[href]',
27
- 'summary',
28
- 'iframe',
29
- 'object',
30
- 'embed',
31
- 'audio[controls]',
32
- 'video[controls]',
33
- '[contenteditable]',
34
- '[tabindex]:not([disabled])',
21
+ 'input:not([disabled]):not([type=hidden])',
22
+ 'select:not([disabled])',
23
+ 'textarea:not([disabled])',
24
+ 'button:not([disabled])',
25
+ 'a[href]',
26
+ 'area[href]',
27
+ 'summary',
28
+ 'iframe',
29
+ 'object',
30
+ 'embed',
31
+ 'audio[controls]',
32
+ 'video[controls]',
33
+ '[contenteditable]',
34
+ '[tabindex]:not([disabled])',
35
35
  ]
36
- .map((selector) => `${selector}:not([hidden]):not([tabindex="-1"])`)
37
- .join(',');
36
+ .map((selector) => `${selector}:not([hidden]):not([tabindex="-1"])`)
37
+ .join(',');
38
38
 
39
39
  let count = 0;
40
40
  const getIDs = () => {
41
- const id = count++;
42
- return { panelId: 'tab-panel-' + id, tabId: 'tab-' + id };
41
+ const id = count++;
42
+ return { panelId: 'tab-panel-' + id, tabId: 'tab-' + id };
43
43
  };
44
44
 
45
45
  /**
@@ -48,51 +48,51 @@ const getIDs = () => {
48
48
  * attributes.
49
49
  */
50
50
  const tabsProcessor = rehype()
51
- .data('settings', { fragment: true })
52
- .use(function tabs() {
53
- return (tree, file) => {
54
- file.data.panels = [];
55
- let isFirst = true;
56
- visit(tree, 'element', (node) => {
57
- if (node.tagName !== TabItemTagname || !node.properties) {
58
- return CONTINUE;
59
- }
51
+ .data('settings', { fragment: true })
52
+ .use(function tabs() {
53
+ return (tree, file) => {
54
+ file.data.panels = [];
55
+ let isFirst = true;
56
+ visit(tree, 'element', (node) => {
57
+ if (node.tagName !== TabItemTagname || !node.properties) {
58
+ return CONTINUE;
59
+ }
60
60
 
61
- const { dataLabel } = node.properties;
62
- const ids = getIDs();
63
- file.data.panels?.push({
64
- ...ids,
65
- label: String(dataLabel),
66
- });
61
+ const { dataLabel } = node.properties;
62
+ const ids = getIDs();
63
+ file.data.panels?.push({
64
+ ...ids,
65
+ label: String(dataLabel),
66
+ });
67
67
 
68
- // Remove `<TabItem>` props
69
- delete node.properties.dataLabel;
70
- // Turn into `<section>` with required attributes
71
- node.tagName = 'section';
72
- node.properties.id = ids.panelId;
73
- node.properties['aria-labelledby'] = ids.tabId;
74
- node.properties.role = 'tabpanel';
68
+ // Remove `<TabItem>` props
69
+ delete node.properties.dataLabel;
70
+ // Turn into `<section>` with required attributes
71
+ node.tagName = 'section';
72
+ node.properties.id = ids.panelId;
73
+ node.properties['aria-labelledby'] = ids.tabId;
74
+ node.properties.role = 'tabpanel';
75
75
 
76
- const focusableChild = select(focusableElementSelectors, node);
77
- // If the panel does not contain any focusable elements, include it in
78
- // the tab sequence of the page.
79
- if (!focusableChild) {
80
- node.properties.tabindex = 0;
81
- }
82
-
83
- // Hide all panels except the first
84
- // TODO: make initially visible tab configurable
85
- if (isFirst) {
86
- isFirst = false;
87
- } else {
88
- node.properties.hidden = true;
89
- }
76
+ const focusableChild = select(focusableElementSelectors, node);
77
+ // If the panel does not contain any focusable elements, include it in
78
+ // the tab sequence of the page.
79
+ if (!focusableChild) {
80
+ node.properties.tabindex = 0;
81
+ }
90
82
 
91
- // Skip over the tab panel’s children.
92
- return SKIP;
93
- });
94
- };
95
- });
83
+ // Hide all panels except the first
84
+ // TODO: make initially visible tab configurable
85
+ if (isFirst) {
86
+ isFirst = false;
87
+ } else {
88
+ node.properties.hidden = true;
89
+ }
90
+
91
+ // Skip over the tab panel’s children.
92
+ return SKIP;
93
+ });
94
+ };
95
+ });
96
96
 
97
97
  /**
98
98
  * Process tab panel items to extract data for the tab links and format
@@ -100,11 +100,11 @@ const tabsProcessor = rehype()
100
100
  * @param html Inner HTML passed to the `<Tabs>` component.
101
101
  */
102
102
  export const processPanels = (html: string) => {
103
- const file = tabsProcessor.processSync({ value: html });
104
- return {
105
- /** Data for each tab panel. */
106
- panels: file.data.panels,
107
- /** Processed HTML for the tab panels. */
108
- html: file.toString(),
109
- };
103
+ const file = tabsProcessor.processSync({ value: html });
104
+ return {
105
+ /** Data for each tab panel. */
106
+ panels: file.data.panels,
107
+ /** Processed HTML for the tab panels. */
108
+ html: file.toString(),
109
+ };
110
110
  };
package/utils/base.ts CHANGED
@@ -2,19 +2,19 @@ const base = stripTrailingSlash(import.meta.env.BASE_URL);
2
2
 
3
3
  /** Get the a root-relative URL path with the site’s `base` prefixed. */
4
4
  export function pathWithBase(path: string) {
5
- path = stripLeadingSlash(stripTrailingSlash(path));
6
- return path ? base + '/' + path + '/' : base + '/';
5
+ path = stripLeadingSlash(stripTrailingSlash(path));
6
+ return path ? base + '/' + path + '/' : base + '/';
7
7
  }
8
8
 
9
9
  /** Get the a root-relative file URL path with the site’s `base` prefixed. */
10
10
  export function fileWithBase(path: string) {
11
- path = stripLeadingSlash(stripTrailingSlash(path));
12
- return path ? base + '/' + path : base;
11
+ path = stripLeadingSlash(stripTrailingSlash(path));
12
+ return path ? base + '/' + path : base;
13
13
  }
14
14
 
15
15
  function stripLeadingSlash(path: string) {
16
- return path.replace(/^\//, '');
16
+ return path.replace(/^\//, '');
17
17
  }
18
18
  function stripTrailingSlash(path: string) {
19
- return path.replace(/\/$/, '');
19
+ return path.replace(/\/$/, '');
20
20
  }
package/utils/git.ts CHANGED
@@ -28,69 +28,69 @@ class FileNotTrackedError extends Error {}
28
28
  * unexpected text.
29
29
  */
30
30
  export function getFileCommitDate(
31
- file: string,
32
- age: 'oldest' | 'newest' = 'oldest'
31
+ file: string,
32
+ age: 'oldest' | 'newest' = 'oldest'
33
33
  ): {
34
- date: Date;
35
- timestamp: number;
34
+ date: Date;
35
+ timestamp: number;
36
36
  } {
37
- try {
38
- const { stdout } = execaSync('which', ['git']);
39
- if (!stdout) {
40
- throw new GitNotFoundError(
41
- `Failed to retrieve git history for "${file}" because git is not installed.`
42
- );
43
- }
44
- } catch {}
37
+ try {
38
+ const { stdout } = execaSync('which', ['git']);
39
+ if (!stdout) {
40
+ throw new GitNotFoundError(
41
+ `Failed to retrieve git history for "${file}" because git is not installed.`
42
+ );
43
+ }
44
+ } catch {}
45
45
 
46
- try {
47
- const { stdout } = execaSync('test', ['-f', file]);
48
- if (!stdout) {
49
- throw new Error(
50
- `Failed to retrieve git history for "${file}" because the file does not exist.`
51
- );
52
- }
53
- } catch {}
46
+ try {
47
+ const { stdout } = execaSync('test', ['-f', file]);
48
+ if (!stdout) {
49
+ throw new Error(
50
+ `Failed to retrieve git history for "${file}" because the file does not exist.`
51
+ );
52
+ }
53
+ } catch {}
54
54
 
55
- const result = execaSync(
56
- 'git',
57
- [
58
- 'log',
59
- `--format=%ct`,
60
- '--max-count=1',
61
- ...(age === 'oldest' ? ['--follow', '--diff-filter=A'] : []),
62
- '--',
63
- path.basename(file),
64
- ],
65
- {
66
- cwd: path.dirname(file),
67
- }
68
- );
69
- if (result.exitCode !== 0) {
70
- throw new Error(
71
- `Failed to retrieve the git history for file "${file}" with exit code ${result.exitCode}: ${result.stderr}`
72
- );
73
- }
74
- let regex = /^(?<timestamp>\d+)$/;
55
+ const result = execaSync(
56
+ 'git',
57
+ [
58
+ 'log',
59
+ `--format=%ct`,
60
+ '--max-count=1',
61
+ ...(age === 'oldest' ? ['--follow', '--diff-filter=A'] : []),
62
+ '--',
63
+ path.basename(file),
64
+ ],
65
+ {
66
+ cwd: path.dirname(file),
67
+ }
68
+ );
69
+ if (result.exitCode !== 0) {
70
+ throw new Error(
71
+ `Failed to retrieve the git history for file "${file}" with exit code ${result.exitCode}: ${result.stderr}`
72
+ );
73
+ }
74
+ let regex = /^(?<timestamp>\d+)$/;
75
75
 
76
- const output = result.stdout.trim();
76
+ const output = result.stdout.trim();
77
77
 
78
- if (!output) {
79
- throw new FileNotTrackedError(
80
- `Failed to retrieve the git history for file "${file}" because the file is not tracked by git.`
81
- );
82
- }
78
+ if (!output) {
79
+ throw new FileNotTrackedError(
80
+ `Failed to retrieve the git history for file "${file}" because the file is not tracked by git.`
81
+ );
82
+ }
83
83
 
84
- const match = output.match(regex);
84
+ const match = output.match(regex);
85
85
 
86
- if (!match) {
87
- throw new Error(
88
- `Failed to retrieve the git history for file "${file}" with unexpected output: ${output}`
89
- );
90
- }
86
+ if (!match) {
87
+ throw new Error(
88
+ `Failed to retrieve the git history for file "${file}" with unexpected output: ${output}`
89
+ );
90
+ }
91
91
 
92
- const timestamp = Number(match.groups!.timestamp);
93
- const date = new Date(timestamp * 1000);
92
+ const timestamp = Number(match.groups!.timestamp);
93
+ const date = new Date(timestamp * 1000);
94
94
 
95
- return { date, timestamp };
95
+ return { date, timestamp };
96
96
  }
package/utils/head.ts CHANGED
@@ -4,11 +4,11 @@ const HeadSchema = HeadConfigSchema();
4
4
 
5
5
  /** Create a fully parsed, merged, and sorted head entry array from multiple sources. */
6
6
  export function createHead(defaults: HeadUserConfig, ...heads: HeadConfig[]) {
7
- let head = HeadSchema.parse(defaults);
8
- for (const next of heads) {
9
- head = mergeHead(head, next);
10
- }
11
- return sortHead(head);
7
+ let head = HeadSchema.parse(defaults);
8
+ for (const next of heads) {
9
+ head = mergeHead(head, next);
10
+ }
11
+ return sortHead(head);
12
12
  }
13
13
 
14
14
  /**
@@ -20,76 +20,78 @@ export function createHead(defaults: HeadUserConfig, ...heads: HeadConfig[]) {
20
20
  * `property`, and `http-equiv` attributes for `<meta>` tags.
21
21
  */
22
22
  function hasTag(head: HeadConfig, entry: HeadConfig[number]): boolean {
23
- switch (entry.tag) {
24
- case 'title':
25
- return head.some(({ tag }) => tag === 'title');
26
- case 'meta':
27
- return hasOneOf(head, entry, ['name', 'property', 'http-equiv']);
28
- default:
29
- return false;
30
- }
23
+ switch (entry.tag) {
24
+ case 'title':
25
+ return head.some(({ tag }) => tag === 'title');
26
+ case 'meta':
27
+ return hasOneOf(head, entry, ['name', 'property', 'http-equiv']);
28
+ default:
29
+ return false;
30
+ }
31
31
  }
32
32
 
33
33
  /**
34
34
  * Test if a head config object contains a tag of the same type
35
35
  * as `entry` and a matching attribute for one of the passed `keys`.
36
36
  */
37
- function hasOneOf(
38
- head: HeadConfig,
39
- entry: HeadConfig[number],
40
- keys: string[]
41
- ): boolean {
42
- const attr = getAttr(keys, entry);
43
- if (!attr) return false;
44
- const [key, val] = attr;
45
- return head.some(({ tag, attrs }) => tag === entry.tag && attrs[key] === val);
37
+ function hasOneOf(head: HeadConfig, entry: HeadConfig[number], keys: string[]): boolean {
38
+ const attr = getAttr(keys, entry);
39
+ if (!attr) return false;
40
+ const [key, val] = attr;
41
+ return head.some(({ tag, attrs }) => tag === entry.tag && attrs[key] === val);
46
42
  }
47
43
 
48
44
  /** Find the first matching key–value pair in a head entry’s attributes. */
49
45
  function getAttr(
50
- keys: string[],
51
- entry: HeadConfig[number]
46
+ keys: string[],
47
+ entry: HeadConfig[number]
52
48
  ): [key: string, value: string | boolean] | undefined {
53
- let attr: [string, string | boolean] | undefined;
54
- for (const key of keys) {
55
- const val = entry.attrs[key];
56
- if (val) {
57
- attr = [key, val];
58
- break;
59
- }
60
- }
61
- return attr;
49
+ let attr: [string, string | boolean] | undefined;
50
+ for (const key of keys) {
51
+ const val = entry.attrs[key];
52
+ if (val) {
53
+ attr = [key, val];
54
+ break;
55
+ }
56
+ }
57
+ return attr;
62
58
  }
63
59
 
64
60
  /** Merge two heads, overwriting entries in the first head that exist in the second. */
65
61
  function mergeHead(oldHead: HeadConfig, newHead: HeadConfig) {
66
- return [...oldHead.filter((tag) => !hasTag(newHead, tag)), ...newHead];
62
+ return [...oldHead.filter((tag) => !hasTag(newHead, tag)), ...newHead];
67
63
  }
68
64
 
69
65
  /** Sort head tags to place important tags first and relegate “SEO” meta tags. */
70
66
  function sortHead(head: HeadConfig) {
71
- return head.sort((a, b) => {
72
- const aImportance = getImportance(a);
73
- const bImportance = getImportance(b);
74
- return aImportance > bImportance ? -1 : bImportance > aImportance ? 1 : 0;
75
- });
67
+ return head.sort((a, b) => {
68
+ const aImportance = getImportance(a);
69
+ const bImportance = getImportance(b);
70
+ return aImportance > bImportance ? -1 : bImportance > aImportance ? 1 : 0;
71
+ });
76
72
  }
77
73
 
78
74
  /** Get the relative importance of a specific head tag. */
79
75
  function getImportance(entry: HeadConfig[number]) {
80
- // 1. Important meta tags.
81
- if (
82
- entry.tag === 'meta' &&
83
- ('charset' in entry.attrs ||
84
- 'http-equiv' in entry.attrs ||
85
- entry.attrs.name === 'viewport')
86
- ) {
87
- return 100;
88
- }
89
- // 2. Page title
90
- if (entry.tag === 'title') return 90;
91
- // 3. Anything that isn’t an SEO meta tag.
92
- if (entry.tag !== 'meta') return 80;
93
- // 4. SEO meta tags.
94
- return 0;
76
+ // 1. Important meta tags.
77
+ if (
78
+ entry.tag === 'meta' &&
79
+ ('charset' in entry.attrs || 'http-equiv' in entry.attrs || entry.attrs.name === 'viewport')
80
+ ) {
81
+ return 100;
82
+ }
83
+ // 2. Page title
84
+ if (entry.tag === 'title') return 90;
85
+ // 3. Anything that isn’t an SEO meta tag.
86
+ if (entry.tag !== 'meta') {
87
+ // The default favicon should be below any extra icons that the user may have set
88
+ // because if several icons are equally appropriate, the last one is used and we
89
+ // want to use the SVG icon when supported.
90
+ if (entry.tag === 'link' && 'rel' in entry.attrs && entry.attrs.rel === 'shortcut icon') {
91
+ return 70;
92
+ }
93
+ return 80;
94
+ }
95
+ // 4. SEO meta tags.
96
+ return 0;
95
97
  }
package/utils/i18n.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * pickLang({ en: 'Hello', fr: 'Bonjour' }, 'en'); // => 'Hello'
10
10
  */
11
11
  export function pickLang<T extends Record<string, string>>(
12
- dictionary: T,
13
- lang: keyof T
12
+ dictionary: T,
13
+ lang: keyof T
14
14
  ): string | undefined {
15
- return dictionary[lang];
15
+ return dictionary[lang];
16
16
  }
@@ -4,29 +4,29 @@ import config from 'virtual:starlight/user-config';
4
4
  * Get the equivalent of the passed URL for the passed locale.
5
5
  */
6
6
  export function localizedUrl(url: URL, locale: string | undefined): URL {
7
- // Create a new URL object to void mutating the global.
8
- url = new URL(url);
9
- if (!config.locales) {
10
- // i18n is not configured on this site, no localization required.
11
- return url;
12
- }
13
- if (locale === 'root') locale = '';
14
- /** Base URL with trailing `/` stripped. */
15
- const base = import.meta.env.BASE_URL.replace(/\/$/, '');
16
- const hasBase = url.pathname.startsWith(base);
17
- // Temporarily remove base to simplify
18
- if (hasBase) url.pathname = url.pathname.replace(base, '');
19
- const [_leadingSlash, baseSegment] = url.pathname.split('/');
20
- if (baseSegment && baseSegment in config.locales) {
21
- // We’re in a localized route, substitute the new locale (or strip for root lang).
22
- url.pathname = locale
23
- ? url.pathname.replace(baseSegment, locale)
24
- : url.pathname.replace('/' + baseSegment, '');
25
- } else if (locale) {
26
- // We’re in the root language. Inject the new locale if we have one.
27
- url.pathname = '/' + locale + url.pathname;
28
- }
29
- // Restore base
30
- if (hasBase) url.pathname = base + url.pathname;
31
- return url;
7
+ // Create a new URL object to void mutating the global.
8
+ url = new URL(url);
9
+ if (!config.locales) {
10
+ // i18n is not configured on this site, no localization required.
11
+ return url;
12
+ }
13
+ if (locale === 'root') locale = '';
14
+ /** Base URL with trailing `/` stripped. */
15
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
16
+ const hasBase = url.pathname.startsWith(base);
17
+ // Temporarily remove base to simplify
18
+ if (hasBase) url.pathname = url.pathname.replace(base, '');
19
+ const [_leadingSlash, baseSegment] = url.pathname.split('/');
20
+ if (baseSegment && baseSegment in config.locales) {
21
+ // We’re in a localized route, substitute the new locale (or strip for root lang).
22
+ url.pathname = locale
23
+ ? url.pathname.replace(baseSegment, locale)
24
+ : url.pathname.replace('/' + baseSegment, '');
25
+ } else if (locale) {
26
+ // We’re in the root language. Inject the new locale if we have one.
27
+ url.pathname = '/' + locale + url.pathname;
28
+ }
29
+ // Restore base
30
+ if (hasBase) url.pathname = base + url.pathname;
31
+ return url;
32
32
  }