@astrojs/starlight 0.20.0 → 0.21.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 +20 -0
- package/components/Icons.ts +15 -1
- package/components.ts +2 -0
- package/package.json +4 -2
- package/schemas/i18n.ts +4 -0
- package/translations/ar.json +2 -1
- package/translations/cs.json +2 -1
- package/translations/da.json +2 -1
- package/translations/de.json +2 -1
- package/translations/en.json +2 -1
- package/translations/es.json +2 -1
- package/translations/fa.json +2 -1
- package/translations/fr.json +2 -1
- package/translations/gl.json +2 -1
- package/translations/he.json +2 -1
- package/translations/hi.json +2 -1
- package/translations/id.json +2 -1
- package/translations/it.json +2 -1
- package/translations/ja.json +2 -1
- package/translations/ko.json +2 -1
- package/translations/nb.json +2 -1
- package/translations/nl.json +2 -1
- package/translations/pt.json +2 -1
- package/translations/ro.json +2 -1
- package/translations/ru.json +2 -1
- package/translations/sv.json +2 -1
- package/translations/tr.json +2 -1
- package/translations/uk.json +2 -1
- package/translations/vi.json +2 -1
- package/translations/zh-CN.json +2 -1
- package/translations/zh-TW.json +2 -1
- package/user-components/FileTree.astro +139 -0
- package/user-components/Steps.astro +84 -0
- package/user-components/TabItem.astro +4 -2
- package/user-components/Tabs.astro +6 -2
- package/user-components/file-tree-icons.ts +755 -0
- package/user-components/rehype-file-tree.ts +251 -0
- package/user-components/rehype-steps.ts +58 -0
- package/user-components/rehype-tabs.ts +8 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { AstroError } from 'astro/errors';
|
|
2
|
+
import type { Element, ElementContent, Text } from 'hast';
|
|
3
|
+
import { type Child, h, s } from 'hastscript';
|
|
4
|
+
import { select } from 'hast-util-select';
|
|
5
|
+
import { fromHtml } from 'hast-util-from-html';
|
|
6
|
+
import { toString } from 'hast-util-to-string';
|
|
7
|
+
import { rehype } from 'rehype';
|
|
8
|
+
import { CONTINUE, SKIP, visit } from 'unist-util-visit';
|
|
9
|
+
import { Icons } from '../components/Icons';
|
|
10
|
+
import { definitions } from './file-tree-icons';
|
|
11
|
+
|
|
12
|
+
declare module 'vfile' {
|
|
13
|
+
interface DataMap {
|
|
14
|
+
directoryLabel: string;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const folderIcon = makeSVGIcon(Icons['seti:folder']);
|
|
19
|
+
const defaultFileIcon = makeSVGIcon(Icons['seti:default']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Process the HTML for a file tree to create the necessary markup for each file and directory
|
|
23
|
+
* including icons.
|
|
24
|
+
* @param html Inner HTML passed to the `<FileTree>` component.
|
|
25
|
+
* @param directoryLabel The localized label for a directory.
|
|
26
|
+
* @returns The processed HTML for the file tree.
|
|
27
|
+
*/
|
|
28
|
+
export function processFileTree(html: string, directoryLabel: string) {
|
|
29
|
+
const file = fileTreeProcessor.processSync({ data: { directoryLabel }, value: html });
|
|
30
|
+
|
|
31
|
+
return file.toString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rehype processor to extract file tree data and turn each entry into its associated markup. */
|
|
35
|
+
const fileTreeProcessor = rehype()
|
|
36
|
+
.data('settings', { fragment: true })
|
|
37
|
+
.use(function fileTree() {
|
|
38
|
+
return (tree: Element, file) => {
|
|
39
|
+
const { directoryLabel } = file.data;
|
|
40
|
+
|
|
41
|
+
validateFileTree(tree);
|
|
42
|
+
|
|
43
|
+
visit(tree, 'element', (node) => {
|
|
44
|
+
// Strip nodes that only contain newlines.
|
|
45
|
+
node.children = node.children.filter(
|
|
46
|
+
(child) => child.type === 'comment' || child.type !== 'text' || !/^\n+$/.test(child.value)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Skip over non-list items.
|
|
50
|
+
if (node.tagName !== 'li') return CONTINUE;
|
|
51
|
+
|
|
52
|
+
const [firstChild, ...otherChildren] = node.children;
|
|
53
|
+
|
|
54
|
+
// Keep track of comments associated with the current file or directory.
|
|
55
|
+
const comment: Child[] = [];
|
|
56
|
+
|
|
57
|
+
// Extract text comment that follows the file name, e.g. `README.md This is a comment`
|
|
58
|
+
if (firstChild?.type === 'text') {
|
|
59
|
+
const [filename, ...fragments] = firstChild.value.split(' ');
|
|
60
|
+
firstChild.value = filename || '';
|
|
61
|
+
const textComment = fragments.join(' ').trim();
|
|
62
|
+
if (textComment.length > 0) {
|
|
63
|
+
comment.push(fragments.join(' '));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Comments may not always be entirely part of the first child text node,
|
|
68
|
+
// e.g. `README.md This is an __important__ comment` where the `__important__` and `comment`
|
|
69
|
+
// nodes would also be children of the list item node.
|
|
70
|
+
const subTreeIndex = otherChildren.findIndex(
|
|
71
|
+
(child) => child.type === 'element' && child.tagName === 'ul'
|
|
72
|
+
);
|
|
73
|
+
const commentNodes =
|
|
74
|
+
subTreeIndex > -1 ? otherChildren.slice(0, subTreeIndex) : [...otherChildren];
|
|
75
|
+
otherChildren.splice(0, subTreeIndex > -1 ? subTreeIndex : otherChildren.length);
|
|
76
|
+
comment.push(...commentNodes);
|
|
77
|
+
|
|
78
|
+
const firstChildTextContent = firstChild ? toString(firstChild) : '';
|
|
79
|
+
|
|
80
|
+
// Decide a node is a directory if it ends in a `/` or contains another list.
|
|
81
|
+
const isDirectory =
|
|
82
|
+
/\/\s*$/.test(firstChildTextContent) ||
|
|
83
|
+
otherChildren.some((child) => child.type === 'element' && child.tagName === 'ul');
|
|
84
|
+
// A placeholder is a node that only contains 3 dots or an ellipsis.
|
|
85
|
+
const isPlaceholder = /^\s*(\.{3}|…)\s*$/.test(firstChildTextContent);
|
|
86
|
+
// A node is highlighted if its first child is bold text, e.g. `**README.md**`.
|
|
87
|
+
const isHighlighted = firstChild?.type === 'element' && firstChild.tagName === 'strong';
|
|
88
|
+
|
|
89
|
+
// Create an icon for the file or directory (placeholder do not have icons).
|
|
90
|
+
const icon = h('span', isDirectory ? folderIcon : getFileIcon(firstChildTextContent));
|
|
91
|
+
if (isDirectory) {
|
|
92
|
+
// Add a screen reader only label for directories before the icon so that it is announced
|
|
93
|
+
// as such before reading the directory name.
|
|
94
|
+
icon.children.unshift(h('span', { class: 'sr-only' }, directoryLabel));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Add classes and data attributes to the list item node.
|
|
98
|
+
node.properties.class = isDirectory ? 'directory' : 'file';
|
|
99
|
+
if (isPlaceholder) node.properties.class += ' empty';
|
|
100
|
+
|
|
101
|
+
// Create the tree entry node that contains the icon, file name and comment which will end up
|
|
102
|
+
// as the list item’s children.
|
|
103
|
+
const treeEntryChildren: Child[] = [
|
|
104
|
+
h('span', { class: isHighlighted ? 'highlight' : '' }, [
|
|
105
|
+
isPlaceholder ? null : icon,
|
|
106
|
+
firstChild,
|
|
107
|
+
]),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
if (comment.length > 0) {
|
|
111
|
+
treeEntryChildren.push(makeText(' '), h('span', { class: 'comment' }, ...comment));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const treeEntry = h('span', { class: 'tree-entry' }, ...treeEntryChildren);
|
|
115
|
+
|
|
116
|
+
if (isDirectory) {
|
|
117
|
+
const hasContents = otherChildren.length > 0;
|
|
118
|
+
|
|
119
|
+
node.children = [
|
|
120
|
+
h('details', { open: hasContents }, [
|
|
121
|
+
h('summary', treeEntry),
|
|
122
|
+
...(hasContents ? otherChildren : [h('ul', h('li', '…'))]),
|
|
123
|
+
]),
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// Continue down the tree.
|
|
127
|
+
return CONTINUE;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
node.children = [treeEntry, ...otherChildren];
|
|
131
|
+
|
|
132
|
+
// Files can’t contain further files or directories, so skip iterating children.
|
|
133
|
+
return SKIP;
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** Make a text node with the pass string as its contents. */
|
|
139
|
+
function makeText(value = ''): Text {
|
|
140
|
+
return { type: 'text', value };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Make a node containing an SVG icon from the passed HTML string. */
|
|
144
|
+
function makeSVGIcon(svgString: string) {
|
|
145
|
+
return s(
|
|
146
|
+
'svg',
|
|
147
|
+
{
|
|
148
|
+
width: 16,
|
|
149
|
+
height: 16,
|
|
150
|
+
class: 'tree-icon',
|
|
151
|
+
'aria-hidden': 'true',
|
|
152
|
+
viewBox: '0 0 24 24',
|
|
153
|
+
},
|
|
154
|
+
fromHtml(svgString, { fragment: true })
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Return the icon for a file based on its file name. */
|
|
159
|
+
function getFileIcon(fileName: string) {
|
|
160
|
+
const name = getFileIconName(fileName);
|
|
161
|
+
if (!name) return defaultFileIcon;
|
|
162
|
+
if (name in Icons) {
|
|
163
|
+
const path = Icons[name as keyof typeof Icons];
|
|
164
|
+
return makeSVGIcon(path);
|
|
165
|
+
}
|
|
166
|
+
return defaultFileIcon;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Return the icon name for a file based on its file name. */
|
|
170
|
+
function getFileIconName(fileName: string) {
|
|
171
|
+
let icon = definitions.files[fileName];
|
|
172
|
+
if (icon) return icon;
|
|
173
|
+
icon = getFileIconTypeFromExtension(fileName);
|
|
174
|
+
if (icon) return icon;
|
|
175
|
+
for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
|
|
176
|
+
if (fileName.includes(partial)) return partialIcon;
|
|
177
|
+
}
|
|
178
|
+
return icon;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get an icon from a file name based on its extension.
|
|
183
|
+
* Note that an extension in Seti is everything after a dot, so `README.md` would be `.md` and
|
|
184
|
+
* `name.with.dots` will try to look for an icon for `.with.dots` and then `.dots` if the first one
|
|
185
|
+
* is not found.
|
|
186
|
+
*/
|
|
187
|
+
function getFileIconTypeFromExtension(fileName: string) {
|
|
188
|
+
const firstDotIndex = fileName.indexOf('.');
|
|
189
|
+
if (firstDotIndex === -1) return;
|
|
190
|
+
let extension = fileName.slice(firstDotIndex);
|
|
191
|
+
while (extension !== '') {
|
|
192
|
+
const icon = definitions.extensions[extension];
|
|
193
|
+
if (icon) return icon;
|
|
194
|
+
const nextDotIndex = extension.indexOf('.', 1);
|
|
195
|
+
if (nextDotIndex === -1) return;
|
|
196
|
+
extension = extension.slice(nextDotIndex);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Validate that the user provided HTML for a file tree is valid. */
|
|
202
|
+
function validateFileTree(tree: Element) {
|
|
203
|
+
const rootElements = tree.children.filter(isElementNode);
|
|
204
|
+
const [rootElement] = rootElements;
|
|
205
|
+
|
|
206
|
+
if (rootElements.length === 0) {
|
|
207
|
+
throwFileTreeValidationError(
|
|
208
|
+
'The `<FileTree>` component expects its content to be a single unordered list but found no child elements.'
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (rootElements.length !== 1) {
|
|
213
|
+
throwFileTreeValidationError(
|
|
214
|
+
`The \`<FileTree>\` component expects its content to be a single unordered list but found multiple child elements: ${rootElements
|
|
215
|
+
.map((element) => `\`<${element.tagName}>\``)
|
|
216
|
+
.join(' - ')}.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!rootElement || rootElement.tagName !== 'ul') {
|
|
221
|
+
throwFileTreeValidationError(
|
|
222
|
+
`The \`<FileTree>\` component expects its content to be an unordered list but found the following element: \`<${rootElement?.tagName}>\`.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const listItemElement = select('li', rootElement);
|
|
227
|
+
|
|
228
|
+
if (!listItemElement) {
|
|
229
|
+
throwFileTreeValidationError(
|
|
230
|
+
'The `<FileTree>` component expects its content to be an unordered list with at least one list item.'
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isElementNode(node: ElementContent): node is Element {
|
|
236
|
+
return node.type === 'element';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Throw a validation error for a file tree linking to the documentation. */
|
|
240
|
+
function throwFileTreeValidationError(message: string): never {
|
|
241
|
+
throw new AstroError(
|
|
242
|
+
message,
|
|
243
|
+
'To learn more about the `<FileTree>` component, see https://starlight.astro.build/guides/components/#file-tree'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface Definitions {
|
|
248
|
+
files: Record<string, string>;
|
|
249
|
+
extensions: Record<string, string>;
|
|
250
|
+
partials: Record<string, string>;
|
|
251
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AstroError } from 'astro/errors';
|
|
2
|
+
import type { Element, Root } from 'hast';
|
|
3
|
+
import { rehype } from 'rehype';
|
|
4
|
+
|
|
5
|
+
const stepsProcessor = rehype()
|
|
6
|
+
.data('settings', { fragment: true })
|
|
7
|
+
.use(function steps() {
|
|
8
|
+
return (tree: Root) => {
|
|
9
|
+
const rootElements = tree.children.filter((item): item is Element => item.type === 'element');
|
|
10
|
+
const [rootElement] = rootElements;
|
|
11
|
+
|
|
12
|
+
if (!rootElement) {
|
|
13
|
+
throw new StepsError(
|
|
14
|
+
'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found no child elements.'
|
|
15
|
+
);
|
|
16
|
+
} else if (rootElements.length > 1) {
|
|
17
|
+
throw new StepsError(
|
|
18
|
+
'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found multiple child elements: ' +
|
|
19
|
+
rootElements.map((element: Element) => `\`<${element.tagName}>\``).join(', ') +
|
|
20
|
+
'.'
|
|
21
|
+
);
|
|
22
|
+
} else if (rootElement.tagName !== 'ol') {
|
|
23
|
+
throw new StepsError(
|
|
24
|
+
'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found the following element: ' +
|
|
25
|
+
`\`<${rootElement.tagName}>\`.`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Ensure `role="list"` is set on the ordered list.
|
|
30
|
+
// We use `list-style: none` in the styles for this component and need to ensure the list
|
|
31
|
+
// retains its semantics in Safari, which will remove them otherwise.
|
|
32
|
+
rootElement.properties.role = 'list';
|
|
33
|
+
// Add the required CSS class name, preserving existing classes if present.
|
|
34
|
+
if (!Array.isArray(rootElement.properties.className)) {
|
|
35
|
+
rootElement.properties.className = ['sl-steps'];
|
|
36
|
+
} else {
|
|
37
|
+
rootElement.properties.className.push('sl-steps');
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Process steps children: validates the HTML and adds `role="list"` to the ordered list.
|
|
44
|
+
* @param html Inner HTML passed to the `<Steps>` component.
|
|
45
|
+
*/
|
|
46
|
+
export const processSteps = (html: string | undefined) => {
|
|
47
|
+
const file = stepsProcessor.processSync({ value: html });
|
|
48
|
+
return { html: file.toString() };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
class StepsError extends AstroError {
|
|
52
|
+
constructor(message: string) {
|
|
53
|
+
super(
|
|
54
|
+
message,
|
|
55
|
+
'To learn more about the `<Steps>` component, see https://starlight.astro.build/guides/components/#steps'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -2,11 +2,13 @@ import type { Element } from 'hast';
|
|
|
2
2
|
import { select } from 'hast-util-select';
|
|
3
3
|
import { rehype } from 'rehype';
|
|
4
4
|
import { CONTINUE, SKIP, visit } from 'unist-util-visit';
|
|
5
|
+
import { Icons } from '../components/Icons';
|
|
5
6
|
|
|
6
7
|
interface Panel {
|
|
7
8
|
panelId: string;
|
|
8
9
|
tabId: string;
|
|
9
10
|
label: string;
|
|
11
|
+
icon?: keyof typeof Icons;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
declare module 'vfile' {
|
|
@@ -59,15 +61,18 @@ const tabsProcessor = rehype()
|
|
|
59
61
|
return CONTINUE;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
const { dataLabel } = node.properties;
|
|
64
|
+
const { dataLabel, dataIcon } = node.properties;
|
|
63
65
|
const ids = getIDs();
|
|
64
|
-
|
|
66
|
+
const panel: Panel = {
|
|
65
67
|
...ids,
|
|
66
68
|
label: String(dataLabel),
|
|
67
|
-
}
|
|
69
|
+
};
|
|
70
|
+
if (dataIcon) panel.icon = String(dataIcon) as keyof typeof Icons;
|
|
71
|
+
file.data.panels?.push(panel);
|
|
68
72
|
|
|
69
73
|
// Remove `<TabItem>` props
|
|
70
74
|
delete node.properties.dataLabel;
|
|
75
|
+
delete node.properties.dataIcon;
|
|
71
76
|
// Turn into `<section>` with required attributes
|
|
72
77
|
node.tagName = 'section';
|
|
73
78
|
node.properties.id = ids.panelId;
|