2
0
mirror of https://github.com/tenrok/bootstrap.git synced 2026-06-14 18:42:30 +03:00

Docs: migration from Hugo to Astro (#41251)

Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Co-authored-by: Mark Otto <markdotto@gmail.com>
This commit is contained in:
Julien Déramond
2025-04-15 18:37:47 +02:00
committed by GitHub
parent 99a0dc628a
commit a8ab19955b
580 changed files with 25487 additions and 17633 deletions
+195
View File
@@ -0,0 +1,195 @@
import fs from 'node:fs'
import path from 'node:path'
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
import mdx from '@astrojs/mdx'
import sitemap from '@astrojs/sitemap'
import type { AstroIntegration } from 'astro'
import autoImport from 'astro-auto-import'
import type { Element } from 'hast'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import { getConfig } from './config'
import { rehypeBsTable } from './rehype'
import { remarkBsConfig, remarkBsDocsref } from './remark'
import { configurePrism } from './prism'
import {
docsDirectory,
getDocsFsPath,
getDocsPublicFsPath,
getDocsStaticFsPath,
validateVersionedDocsPaths
} from './path'
// A list of directories in `src/components` that contains components that will be auto imported in all pages for
// convenience.
// Note: adding a new component to one of the existing directories requires a restart of the dev server.
const autoImportedComponentDirectories = ['shortcodes']
// A list of static file paths that will be aliased to a different path.
const staticFileAliases = {
'/docs/[version]/assets/img/favicons/apple-touch-icon.png': '/apple-touch-icon.png',
'/docs/[version]/assets/img/favicons/favicon.ico': '/favicon.ico'
}
// A list of pages that will be excluded from the sitemap.
const sitemapExcludes = ['/404', '/docs', `/docs/${getConfig().docs_version}`]
const headingsRangeRegex = new RegExp(`^h[${getConfig().anchors.min}-${getConfig().anchors.max}]$`)
export function bootstrap(): AstroIntegration[] {
const sitemapExcludedUrls = sitemapExcludes.map((url) => `${getConfig().baseURL}${url}/`)
configurePrism()
return [
bootstrap_auto_import(),
{
name: 'bootstrap-integration',
hooks: {
'astro:config:setup': ({ addWatchFile, updateConfig }) => {
// Reload the config when the integration is modified.
addWatchFile(path.join(getDocsFsPath(), 'src/libs/astro.ts'))
// Add the remark and rehype plugins.
updateConfig({
markdown: {
rehypePlugins: [
rehypeHeadingIds,
[
rehypeAutolinkHeadings,
{
behavior: 'append',
content: [{ type: 'text', value: ' ' }],
properties: { class: 'anchor-link' },
test: (element: Element) => element.tagName.match(headingsRangeRegex)
}
],
rehypeBsTable
],
remarkPlugins: [remarkBsConfig, remarkBsDocsref]
}
})
},
'astro:config:done': () => {
cleanPublicDirectory()
copyBootstrap()
copyStatic()
aliasStatic()
},
'astro:build:done': ({ dir }) => {
validateVersionedDocsPaths(dir)
}
}
},
// https://github.com/withastro/astro/issues/6475
mdx() as AstroIntegration,
sitemap({
filter: (page) => sitemapFilter(page, sitemapExcludedUrls)
})
]
}
function bootstrap_auto_import() {
const autoImportedComponents: string[] = []
const autoImportedComponentDefinitions: string[] = []
for (const autoImportedComponentDirectory of autoImportedComponentDirectories) {
const components = fs.readdirSync(path.join(getDocsFsPath(), 'src/components', autoImportedComponentDirectory), {
withFileTypes: true
})
for (const component of components) {
if (component.isFile()) {
autoImportedComponents.push(
`./${path.posix.join(docsDirectory, 'src/components', autoImportedComponentDirectory, component.name)}`
)
if (component.name.endsWith('.astro')) {
autoImportedComponentDefinitions.push(
`export const ${component.name.replace('.astro', '')}: typeof import('@shortcodes/${
component.name
}').default`
)
}
}
}
}
const autoImportedComponentDefinition = `/**
* DO NOT EDIT THIS FILE MANUALLY.
*
* This file is automatically generated by the Boostrap Astro Integration.
* It contains the type definitions for the components that are auto imported in all pages.
* @see site/src/libs/astro.ts
*/
export declare global {
${autoImportedComponentDefinitions.join('\n ')}
}
`
fs.writeFileSync(path.join(getDocsFsPath(), 'src/types/auto-import.d.ts'), autoImportedComponentDefinition)
return autoImport({
imports: autoImportedComponents
})
}
function cleanPublicDirectory() {
fs.rmSync(getDocsPublicFsPath(), { force: true, recursive: true })
}
// Copy the `dist` folder from the root of the repo containing the latest version of Bootstrap to make it available from
// the `/docs/${docs_version}/dist` URL.
function copyBootstrap() {
const source = path.join(process.cwd(), 'dist')
const destination = path.join(getDocsPublicFsPath(), 'docs', getConfig().docs_version, 'dist')
fs.mkdirSync(destination, { recursive: true })
fs.cpSync(source, destination, { recursive: true })
}
// Copy the content as-is of the `static` folder to make it available from the `/` URL.
// A folder named `[version]` will automatically be renamed to the current version of the docs extracted from the
// `config.yml` file.
function copyStatic() {
const source = getDocsStaticFsPath()
const destination = path.join(getDocsPublicFsPath())
copyStaticRecursively(source, destination)
}
// Alias (copy) some static files to different paths.
function aliasStatic() {
const source = getDocsStaticFsPath()
const destination = path.join(getDocsPublicFsPath())
for (const [aliasSource, aliasDestination] of Object.entries(staticFileAliases)) {
fs.cpSync(path.join(source, aliasSource), path.join(destination, aliasDestination))
}
}
// See `copyStatic()` for more details.
function copyStaticRecursively(source: string, destination: string) {
const entries = fs.readdirSync(source, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile()) {
fs.cpSync(path.join(source, entry.name), replacePathVersionPlaceholder(path.join(destination, entry.name)))
} else if (entry.isDirectory()) {
fs.mkdirSync(replacePathVersionPlaceholder(path.join(destination, entry.name)), { recursive: true })
copyStaticRecursively(path.join(source, entry.name), path.join(destination, entry.name))
}
}
}
function replacePathVersionPlaceholder(name: string) {
return name.replace('[version]', getConfig().docs_version)
}
function sitemapFilter(page: string, excludedUrls: string[]) {
if (excludedUrls.includes(page)) {
return false
}
return true
}
+48
View File
@@ -0,0 +1,48 @@
import type { HTMLAttributes } from 'astro/types'
import { getConfig } from '@libs/config'
import { getVersionedDocsPath } from '@libs/path'
export function getVersionedBsCssProps(direction: 'rtl' | undefined) {
let bsCssLinkHref = '/dist/css/bootstrap'
if (direction === 'rtl') {
bsCssLinkHref = `${bsCssLinkHref}.rtl`
}
if (import.meta.env.PROD) {
bsCssLinkHref = `${bsCssLinkHref}.min`
}
bsCssLinkHref = `${bsCssLinkHref}.css`
const bsCssLinkProps: HTMLAttributes<'link'> = {
href: getVersionedDocsPath(bsCssLinkHref),
rel: 'stylesheet'
}
if (import.meta.env.PROD) {
bsCssLinkProps.integrity = direction === 'rtl' ? getConfig().cdn.css_rtl_hash : getConfig().cdn.css_hash
}
return bsCssLinkProps
}
export function getVersionedBsJsProps() {
let bsJsScriptSrc = '/dist/js/bootstrap.bundle'
if (import.meta.env.PROD) {
bsJsScriptSrc = `${bsJsScriptSrc}.min`
}
bsJsScriptSrc = `${bsJsScriptSrc}.js`
const bsJsLinkProps: HTMLAttributes<'script'> = {
src: getVersionedDocsPath(bsJsScriptSrc)
}
if (import.meta.env.PROD) {
bsJsLinkProps.integrity = getConfig().cdn.js_bundle_hash
}
return bsJsLinkProps
}
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs'
import yaml from 'js-yaml'
import { z } from 'zod'
import { zPrefixedVersionSemver, zVersionMajorMinor, zVersionSemver } from './validation'
// The config schema used to validate the config file content and ensure all values required by the site are valid.
const configSchema = z.object({
algolia: z.object({
api_key: z.string(),
app_id: z.string(),
index_name: z.string()
}),
analytics: z.object({
fathom_site: z.string()
}),
anchors: z.object({
min: z.number(),
max: z.number()
}),
authors: z.string(),
baseURL: z.string().url(),
blog: z.string().url(),
cdn: z.object({
css: z.string().url(),
css_rtl: z.string().url(),
css_hash: z.string(),
css_rtl_hash: z.string(),
js: z.string().url(),
js_hash: z.string(),
js_bundle: z.string().url(),
js_bundle_hash: z.string(),
popper: z.string().url(),
popper_esm: z.string().url(),
popper_hash: z.string()
}),
current_version: zVersionSemver,
current_ruby_version: zVersionSemver,
description: z.string(),
docs_version: zVersionMajorMinor,
docsDir: z.string(),
download: z.object({
dist: z.string().url(),
dist_examples: z.string().url(),
source: z.string().url()
}),
github_org: z.string().url(),
icons: z.string().url(),
opencollective: z.string().url(),
repo: z.string().url(),
rfs_version: zPrefixedVersionSemver,
subtitle: z.string(),
swag: z.string().url(),
themes: z.string().url(),
title: z.string(),
toc: z.object({
min: z.number(),
max: z.number()
}),
x: z.string()
})
let config: Config | undefined
// A helper to get the config loaded fom the `config.yml` file. If the config does not match the `configSchema`, an
// error is thrown to indicate that the config file is invalid and some action is required.
export function getConfig(): Config {
if (config) {
// Returns the config if it has already been loaded.
return config
}
try {
// Load the config from the `config.yml` file.
const rawConfig = yaml.load(fs.readFileSync('./config.yml', 'utf8'))
// Parse the config using the config schema to validate its content and get back a fully typed config object.
config = configSchema.parse(rawConfig)
return config
} catch (error) {
if (error instanceof z.ZodError) {
console.error('The `config.yml` file content is invalid:', error.issues)
}
throw new Error('Failed to load configuration from `config.yml`', { cause: error })
}
}
type Config = z.infer<typeof configSchema>
+12
View File
@@ -0,0 +1,12 @@
import { getCollection, getEntryBySlug } from 'astro:content'
export const docsPages = await getCollection('docs')
export const callouts = await getCollection('callouts')
export const aliasedDocsPages = await getCollection('docs', ({ data }) => {
return data.aliases !== undefined
})
export function getCalloutByName(name: string) {
return getEntryBySlug('callouts', name)
}
+146
View File
@@ -0,0 +1,146 @@
import fs from 'node:fs'
import yaml from 'js-yaml'
import { z } from 'zod'
import {
zHexColor,
zLanguageCode,
zNamedHexColors,
zPxSizeOrEmpty,
zVersionMajorMinor,
zVersionSemver
} from './validation'
import { capitalizeFirstLetter } from './utils'
// An object containing all the data types and their associated schema. The key should match the name of the data file
// in the `./site/data/` directory.
const dataDefinitions = {
breakpoints: z
.object({
breakpoint: z.string(),
abbr: z.string(),
name: z.string(),
'min-width': zPxSizeOrEmpty,
container: zPxSizeOrEmpty
})
.array(),
colors: zNamedHexColors(13),
'core-team': z
.object({
name: z.string(),
user: z.string()
})
.array(),
'docs-versions': z
.object({
group: z.string(),
baseurl: z.string().url(),
description: z.string(),
versions: z.union([zVersionSemver, zVersionMajorMinor]).array()
})
.array(),
examples: z
.object({
category: z.string(),
external: z.boolean().optional(),
description: z.string(),
examples: z
.object({
description: z.string(),
indexPath: z.string().optional(),
name: z.string(),
url: z.string().optional()
})
.array()
})
.array(),
grays: zNamedHexColors(9),
icons: z.object({
preferred: z
.object({
name: z.string(),
website: z.string().url()
})
.array(),
more: z
.object({
name: z.string(),
website: z.string().url()
})
.array()
}),
plugins: z
.object({
description: z.string(),
link: z.string().startsWith('components/'),
name: z.string()
})
.array(),
sidebar: z
.object({
title: z.string(),
icon: z.string().optional(),
icon_color: z.string().optional(),
pages: z
.object({
title: z.string()
})
.array()
.optional()
})
.array(),
'theme-colors': z
.object({
name: z.string(),
hex: zHexColor,
contrast_color: z.union([z.literal('dark'), z.literal('white')]).optional()
})
.array()
.transform((val) => {
// Add a `title` property to each theme color object being the capitalized version of the `name` property.
return val.map((themeColor) => ({ ...themeColor, title: capitalizeFirstLetter(themeColor.name) }))
}),
translations: z
.object({
name: z.string(),
code: zLanguageCode,
description: z.string(),
url: z.string().url()
})
.array()
} satisfies Record<string, DataSchema>
let data = new Map<DataType, z.infer<DataSchema>>()
// A helper to get data loaded fom a yml file in the `./site/data/` directory. If the data does not match its associated
// schema from `dataDefinitions`, an error is thrown to indicate that the data file is invalid and some action is
// required.
export function getData<TType extends DataType>(type: TType): z.infer<(typeof dataDefinitions)[TType]> {
if (data.has(type)) {
// Returns the data if it has already been loaded.
return data.get(type)
}
const dataPath = `./site/data/${type}.yml`
try {
// Load the data from the yml file.
const rawData = yaml.load(fs.readFileSync(dataPath, 'utf8'))
// Parse the data using the data schema to validate its content and get back a fully typed data object.
const parsedData = dataDefinitions[type].parse(rawData)
// Cache the data.
data.set(type, parsedData)
return parsedData
} catch (error) {
if (error instanceof z.ZodError) {
console.error(`The \`${dataPath}\` file content is invalid:`, error.issues)
}
throw new Error(`Failed to load data from \`${dataPath}\``, { cause: error })
}
}
type DataType = keyof typeof dataDefinitions
type DataSchema = z.ZodTypeAny
+74
View File
@@ -0,0 +1,74 @@
import type { AstroInstance } from 'astro'
import fs from 'node:fs'
import path from 'node:path'
import { z } from 'zod'
import { getDocsFsPath } from './path'
export const exampleFrontmatterSchema = z.object({
body_class: z.string().optional(),
direction: z.literal('rtl').optional(),
extra_css: z.string().array().optional(),
extra_js: z
.object({
async: z.boolean().optional(),
integrity: z.string().optional(),
src: z.string()
})
.array()
.optional(),
html_class: z.string().optional(),
include_js: z.boolean().optional(),
title: z.string()
})
export type ExampleFrontmatter = z.infer<typeof exampleFrontmatterSchema>
export function getExamplesAssets() {
const source = path.join(getDocsFsPath(), 'src/assets/examples')
return getExamplesAssetsRecursively(source)
}
export function getAliasedExamplesPages(pages: AstroInstance[]) {
return pages.filter(isAliasedAstroInstance)
}
export function getExampleNameFromPagePath(examplePath: string) {
const matches = examplePath.match(/([^/]+)\/(?:[^/]+)\.astro$/)
if (!matches || !matches[1]) {
throw new Error(`Failed to get example name from path: '${examplePath}'.`)
}
return matches[1]
}
function getExamplesAssetsRecursively(source: string, assets: string[] = []) {
const entries = fs.readdirSync(source, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && !entry.name.endsWith('.astro')) {
assets.push(sanitizeAssetPath(path.join(source, entry.name)))
} else if (entry.isDirectory()) {
getExamplesAssetsRecursively(path.join(source, entry.name), assets)
}
}
return assets
}
function sanitizeAssetPath(assetPath: string) {
const matches = assetPath.match(/([^\/]+\/[^\/]+\.\w+)$/)
if (!matches || !matches[1]) {
throw new Error(`Failed to get example asset path from path: '${assetPath}'.`)
}
return matches[1]
}
function isAliasedAstroInstance(page: AstroInstance): page is AliasedAstroInstance {
return (page as AliasedAstroInstance).aliases !== undefined
}
type AliasedAstroInstance = AstroInstance & { aliases: string | string[] }
+5
View File
@@ -0,0 +1,5 @@
export interface SvgIconProps {
class?: string
height: number
width: number
}
+13
View File
@@ -0,0 +1,13 @@
import path from 'node:path'
import sizeOf from 'image-size'
import { getDocsStaticFsPath } from './path'
export function getStaticImageSize(imagePath: string) {
const size = sizeOf(path.join(getDocsStaticFsPath(), imagePath))
if (!size.height || !size.width) {
throw new Error(`Failed to get size of static image at '${imagePath}'.`)
}
return { height: size.height, width: size.width }
}
+7
View File
@@ -0,0 +1,7 @@
import type { HTMLAttributes, HTMLTag } from 'astro/types'
export type Layout = 'docs' | 'examples' | 'single' | undefined
export type LayoutOverridesHTMLAttributes<TTag extends HTMLTag> = HTMLAttributes<TTag> & {
[key in `data-${string}`]: string
}
+71
View File
@@ -0,0 +1,71 @@
import fs from 'node:fs'
import path from 'node:path'
import { getConfig } from './config'
// The docs directory path relative to the root of the project.
export const docsDirectory = getConfig().docsDir
// A list of all the docs paths that were generated during a build.
const generatedVersionedDocsPaths: string[] = []
export function getVersionedDocsPath(docsPath: string): string {
const { docs_version } = getConfig()
const sanitizedDocsPath = docsPath.replace(/^\//, '')
if (import.meta.env.PROD) {
generatedVersionedDocsPaths.push(sanitizedDocsPath)
}
return `/docs/${docs_version}/${sanitizedDocsPath}`
}
// Validate that all the generated versioned docs paths point to an existing page or asset.
// This is useful to catch typos in docs paths.
// Note: this function is only called during a production build.
// Note: this could at some point be refactored to use Astro list of generated `routes` accessible in the
// `astro:build:done` integration hook. Altho as of 03/14/2023, this is not possible due to the route's data only
// containing informations regarding the last page generated page for dynamic routes.
// @see https://github.com/withastro/astro/issues/5802
export function validateVersionedDocsPaths(distUrl: URL) {
const { docs_version } = getConfig()
for (const docsPath of generatedVersionedDocsPaths) {
const sanitizedDocsPath = sanitizeVersionedDocsPathForValidation(docsPath)
const absoluteDocsPath = path.join(distUrl.pathname, 'docs', docs_version, sanitizedDocsPath)
const docsPathExists = fs.existsSync(absoluteDocsPath)
if (!docsPathExists) {
throw new Error(`A versioned docs path was generated but does not point to a valid page or asset: '${docsPath}'.`)
}
}
}
export function getDocsRelativePath(docsPath: string) {
return path.join(docsDirectory, docsPath)
}
export function getDocsStaticFsPath() {
return path.join(getDocsFsPath(), 'static')
}
export function getDocsPublicFsPath() {
return path.join(getDocsFsPath(), 'public')
}
export function getDocsFsPath() {
return path.join(process.cwd(), docsDirectory)
}
function sanitizeVersionedDocsPathForValidation(docsPath: string) {
// Remove the hash part of the path if any.
let sanitizedDocsPath = docsPath.split('#')[0]
// Append the `index.html` part if the path doesn't have an extension.
if (!sanitizedDocsPath.includes('.')) {
sanitizedDocsPath = path.join(sanitizedDocsPath, 'index.html')
}
return sanitizedDocsPath
}
+251
View File
@@ -0,0 +1,251 @@
import type { HTMLAttributes } from 'astro/types'
import * as htmlparser2 from 'htmlparser2'
import { getData } from './data'
const placeholderRegex = /<Placeholder\s+([^>]+)\/>/g
/**
* Generates all the placeholder attributes and options required to render a placeholder.
* @see src/components/shortcodes/Placeholder.astro
*/
export function getPlaceholder(userOptions: Partial<PlaceholderOptions>): Placeholder {
const options = getOptionsWithDefaults(userOptions)
const { class: className, height, markup, text, title, width } = options
const showText = text !== false
const showTitle = title !== false
const placeholderClassList = ['bd-placeholder-img', className].join(' ')
const placeholderRole = showTitle || showText ? 'img' : undefined
const placeholderAriaHidden = !showText && !showTitle ? 'true' : undefined
const placeholderLabel =
showText || showTitle
? `${showTitle ? title : ''}${showTitle && showText ? ': ' : ''}${showText ? text : ''}`
: undefined
const optionsWithVisibilities = { ...options, showText, showTitle }
if (markup === 'img') {
return {
type: 'img',
options: optionsWithVisibilities,
props: {
alt: placeholderLabel,
class: placeholderClassList,
height,
src: getPlaceholderSrc(showTitle, showText, options),
width
}
}
}
return {
type: 'svg',
options: optionsWithVisibilities,
props: {
'aria-hidden': placeholderAriaHidden,
'aria-label': placeholderLabel,
class: placeholderClassList,
height,
preserveAspectRatio: 'xMidYMid slice',
role: placeholderRole,
width,
xmlns: 'http://www.w3.org/2000/svg'
}
}
}
/**
* Replaces placeholders described using the `<Placeholder />` component in HTML markup with the expected HTML content.
* This is useful to render examples that have a pretty large set of constraints:
*
* - The provided HTML code is not valid MDX (e.g. unclosed void elements like <img>) but can contain the
* `<Placeholder />` Astro component. This means that we cannot use an Astro slot for example that requires valid
* MDX.
* - The provided HTML code cannot be parsed in a forgiving way with XML mode enabled (to not lose the structure due
* to self-closing MDX or Astro components) and serialized back to a string while closing all known void elements
* in order to render it as MDX using `@mdx-js/mdx` & `astro/jsx-runtime`. This works perfectly (tested) but the
* DOM needs to contains the exact same HTML markup (even indentation) provided to the example as it is used on the
* client to send the example to StackBlitz with the same indentation as the original example.
*
* If you are not sure if you need to use this function, you probably don't.
*/
export function replacePlaceholdersInHtml(html: string) {
return html.replace(placeholderRegex, (match) => {
const document = htmlparser2.parseDocument(match, { xmlMode: true })
const placeholderElement = document.firstChild
if (
document.children.length > 1 ||
!placeholderElement ||
placeholderElement.type !== htmlparser2.ElementType.Tag ||
placeholderElement.name !== 'Placeholder'
) {
throw new Error('Invalid placeholder element.')
}
const placeholder = getPlaceholder(sanitizeHtmlAttributesFromMdx(placeholderElement.attribs))
return renderPlaceholderToString(placeholder)
})
}
function renderPlaceholderToString(placeholder: Placeholder) {
let placeholderStr = `<${placeholder.type}`
for (const [key, value] of Object.entries(placeholder.props)) {
if (value === undefined) {
continue
}
placeholderStr = `${placeholderStr} ${key}="${value}"`
}
if (placeholder.type === 'img') {
return `${placeholderStr} />`
}
placeholderStr = `${placeholderStr}>`
if (placeholder.options.showTitle) {
placeholderStr = `${placeholderStr}<title>${placeholder.options.title}</title>`
}
placeholderStr = `${placeholderStr}<rect width="100%" height="100%" fill="${placeholder.options.background}" />`
if (placeholder.options.showText) {
placeholderStr = `${placeholderStr}<text x="50%" y="50%" fill="${placeholder.options.color}" dy=".3em">${placeholder.options.text}</text>`
}
return `${placeholderStr}</${placeholder.type}>`
}
function getOptionsWithDefaults(options: Partial<PlaceholderOptions>) {
const optionsWithDefaults = Object.assign(
{},
{
background: getData('grays')[5].hex,
color: getData('grays')[2].hex,
height: '180',
markup: 'svg',
title: 'Placeholder',
width: '100%'
},
options
)
if (optionsWithDefaults.text === undefined) {
optionsWithDefaults.text = `${optionsWithDefaults.width}x${optionsWithDefaults.height}`
}
return optionsWithDefaults as PlaceholderOptions
}
function getPlaceholderSrc(
showTitle: boolean,
showText: boolean,
{ background, color, text, title }: PlaceholderOptions
) {
// Sanitize the background and text colors by removing the leading hash if any.
const bgColor = background.replace(/^#/, '')
const textColor = color.replace(/^#/, '')
// Build the raw SVG string first
let svg = `<svg style='font-size: 1.125rem; font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; -webkit-user-select: none; -moz-user-select: none; user-select: none; text-anchor: middle;' width='200' height='200' xmlns='http://www.w3.org/2000/svg'>`
if (showTitle) {
svg += `<title>${title}</title>`
}
svg += `<rect width='100%' height='100%' fill='#${bgColor}'></rect>`
if (showText) {
svg += `<text x='50%' y='50%' fill='#${textColor}' dy='.3em'>${text}</text>`
}
svg += `</svg>`
const encodedSvg = encodeURIComponent(svg)
return `data:image/svg+xml,${encodedSvg}`
}
function sanitizeHtmlAttributesFromMdx(attributes: Record<string, unknown>) {
const sanitizedAttributes: typeof attributes = {}
for (const [key, value] of Object.entries(attributes)) {
if (value === undefined) {
continue
} else if (value === '{false}') {
sanitizedAttributes[key] = false
} else if (value === '{true}') {
sanitizedAttributes[key] = true
} else {
sanitizedAttributes[key] = value
}
}
return sanitizedAttributes
}
export interface PlaceholderOptions {
/**
* The SVG background color.
* @default "#868e96"
*/
background: string
/**
* CSS classes to append to `bd-placeholder-img` for the `svg` or `img` elements.
*/
class?: string
/**
* The text color (foreground).
* @default "#dee2e6"
*/
color: string
/**
* The placeholder height.
* @default "180"
*/
height: string
/**
* If it should render `svg` or `img` tags.
* @default "svg"
*/
markup: 'img' | 'svg'
/**
* The text to show in the image. You can explicitely pass the `false` boolean value (and not the string "false") to
* hide the text.
* @default "${width}x{$height)"
*/
text: string | false
/**
* Used in the SVG `title` tag. You can explicitely pass the `false` boolean value (and not the string "false") to
* hide the title.
* @default "Placeholder"
*/
title: string | false
/**
* The placeholder width.
* @default "100%"
*/
width: string
}
interface PlaceholderVisibilities {
showText: boolean
showTitle: boolean
}
type Placeholder =
| {
type: 'img'
options: PlaceholderOptions & PlaceholderVisibilities
props: HTMLAttributes<'img'>
}
| {
type: 'svg'
options: PlaceholderOptions & PlaceholderVisibilities
props: HTMLAttributes<'svg'>
}
+93
View File
@@ -0,0 +1,93 @@
import Prism, { type hooks } from 'prismjs'
const { Token } = Prism
let isPrismConfigured = false
export function configurePrism() {
if (isPrismConfigured) {
return
}
isPrismConfigured = true
Prism.hooks.add('after-tokenize', lineWrapPlugin)
}
// A plugin to wrap each line in a .line span, except for comments and empty lines
function lineWrapPlugin(env: hooks.HookEnvironmentMap['after-tokenize']) {
// Skip processing if the language isn't one we want to modify
if (env.language !== 'bash' && env.language !== 'sh' && env.language !== 'powershell') {
return
}
// First, split tokens into lines
const lines: (string | Prism.Token)[][] = [[]]
for (let i = 0; i < env.tokens.length; i++) {
const token = env.tokens[i]
if (typeof token === 'string') {
// Split string tokens by newlines
const parts = token.split('\n')
for (let j = 0; j < parts.length; j++) {
if (j > 0) {
// Start a new line after each newline
lines.push([])
}
if (parts[j]) {
lines[lines.length - 1].push(parts[j])
}
}
} else {
lines[lines.length - 1].push(token)
}
}
// Now rebuild tokens with the line structure
env.tokens = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Check if this is an empty line
const isEmptyLine = line.length === 0 || (line.length === 1 && typeof line[0] === 'string' && line[0].trim() === '')
// Check if this is a comment-only line
const isCommentLine = line.every((token) => {
if (typeof token === 'string') {
return token.trim() === ''
}
return token.type === 'comment'
})
if (isEmptyLine || isCommentLine) {
// For comment or empty lines, just add the tokens without a wrapper
env.tokens.push(...line)
// Add a newline after each line (except the last)
if (i < lines.length - 1) {
env.tokens.push('\n')
}
} else {
// For normal lines, wrap with .line class
const lineToken = new Token('span', '', ['line'])
const lineChildren: (string | Prism.Token)[] = []
// Add the line content
lineChildren.push(...line)
// For the last token in the line, append a newline
if (i < lines.length - 1) {
lineChildren.push('\n')
}
// Set line content
lineToken.content = lineChildren
// Add the entire structure to tokens
env.tokens.push(lineToken)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { Root } from 'hast'
import type { Plugin } from 'unified'
import { SKIP, visit } from 'unist-util-visit'
// A rehype plugin to apply CSS classes to tables rendered in markdown (or MDX) files when wrapped in a `<BsTable />`
// component.
export const rehypeBsTable: Plugin<[], Root> = function () {
return function rehypeBsTablePlugin(ast) {
visit(ast, 'element', (node, _index, parent) => {
if (node.tagName !== 'table' || parent?.type !== 'mdxJsxFlowElement' || parent.name !== 'BsTable') {
return SKIP
}
const classAttribute = parent.attributes.find(
(attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'class'
)
if (classAttribute && typeof classAttribute.value !== 'string') {
return SKIP
}
const tableClass = typeof classAttribute?.value === 'string' ? classAttribute.value : 'table'
if (!node.properties) {
node.properties = {}
}
node.properties = {
...node.properties,
class: tableClass
}
})
}
}
+161
View File
@@ -0,0 +1,161 @@
import type { Root } from 'mdast'
import type { MdxJsxAttribute, MdxJsxExpressionAttribute } from 'mdast-util-mdx-jsx'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
import { getConfig } from './config'
import { getVersionedDocsPath } from './path'
// [[config:foo]]
// [[config:foo.bar]]
const configRegExp = /\[\[config:(?<name>[\w\.]+)]]/g
// [[docsref:/foo]]
// [[docsref:/foo/bar#baz]]
const docsrefRegExp = /\[\[docsref:(?<path>[\w\.\/#-]+)]]/g
// A remark plugin to replace config values embedded in markdown (or MDX) files.
// For example, [[config:foo]] will be replaced with the value of the `foo` key in the `config.yml` file.
// Nested values are also supported, e.g. [[config:foo.bar]].
// Note: this also works in frontmatter.
// Note: this plugin is meant to facilitate the migration from Hugo to Astro while keeping the differences to a minimum.
// At some point, this plugin should maybe be removed and embrace a more MDX-friendly syntax.
export const remarkBsConfig: Plugin<[], Root> = function () {
return function remarkBsConfigPlugin(ast, file) {
if (containsFrontmatter(file.data.astro)) {
replaceInFrontmatter(file.data.astro.frontmatter, replaceConfigInText)
}
// https://github.com/syntax-tree/mdast#nodes
// https://github.com/syntax-tree/mdast-util-mdx-jsx#nodes
visit(ast, ['code', 'definition', 'image', 'inlineCode', 'link', 'mdxJsxFlowElement', 'text'], (node) => {
switch (node.type) {
case 'code':
case 'inlineCode':
case 'text': {
node.value = replaceConfigInText(node.value)
break
}
case 'image': {
if (node.alt) {
node.alt = replaceConfigInText(node.alt)
}
node.url = replaceConfigInText(node.url)
break
}
case 'definition':
case 'link': {
node.url = replaceConfigInText(node.url)
break
}
case 'mdxJsxFlowElement': {
node.attributes = replaceConfigInAttributes(node.attributes)
break
}
}
})
}
}
// A remark plugin to add versionned docs links in markdown (or MDX) files.
// For example, [[docsref:/foo]] will be replaced with the `/docs/${docs_version}/foo` value with the `docs_version`
// value being read from the `config.yml` file.
// Note: this also works in frontmatter.
// Note: this plugin is meant to facilitate the migration from Hugo to Astro while keeping the differences to a minimum.
// At some point, this plugin should maybe be removed and embrace a more MDX-friendly syntax.
export const remarkBsDocsref: Plugin<[], Root> = function () {
return function remarkBsDocsrefPlugin(ast, file) {
if (containsFrontmatter(file.data.astro)) {
replaceInFrontmatter(file.data.astro.frontmatter, replaceDocsrefInText)
}
// https://github.com/syntax-tree/mdast#nodes
// https://github.com/syntax-tree/mdast-util-mdx-jsx#nodes
visit(ast, ['definition', 'link', 'mdxJsxTextElement'], (node) => {
switch (node.type) {
case 'definition':
case 'link': {
node.url = replaceDocsrefInText(node.url)
break
}
case 'mdxJsxTextElement': {
node.attributes = replaceDocsrefInAttributes(node.attributes)
break
}
}
})
}
}
export function replaceConfigInText(text: string) {
return text.replace(configRegExp, (_match, path) => {
const value = getConfigValueAtPath(path)
if (!value) {
throw new Error(`Failed to find a valid configuration value for '${path}'.`)
}
return value
})
}
function replaceConfigInAttributes(attributes: (MdxJsxAttribute | MdxJsxExpressionAttribute)[]) {
return attributes.map((attribute) => {
if (attribute.type === 'mdxJsxAttribute' && typeof attribute.value === 'string') {
attribute.value = replaceConfigInText(attribute.value)
}
return attribute
})
}
function replaceDocsrefInText(text: string) {
return text.replace(docsrefRegExp, (_match, path) => {
return getVersionedDocsPath(path)
})
}
function replaceDocsrefInAttributes(attributes: (MdxJsxAttribute | MdxJsxExpressionAttribute)[]) {
return attributes.map((attribute) => {
if (attribute.type === 'mdxJsxAttribute' && typeof attribute.value === 'string') {
attribute.value = replaceDocsrefInText(attribute.value)
}
return attribute
})
}
function getConfigValueAtPath(path: string) {
const config = getConfig()
const value = path.split('.').reduce((values, part) => {
if (!values || typeof values !== 'object') {
return undefined
}
return (values as any)?.[part]
}, config as unknown)
return typeof value === 'string' ? value : undefined
}
function replaceInFrontmatter(record: Record<string, unknown>, replacer: (value: string) => string) {
for (const [key, value] of Object.entries(record)) {
if (typeof value === 'string') {
record[key] = replacer(value)
} else if (Array.isArray(value)) {
record[key] = value.map((arrayValue) => {
return typeof arrayValue === 'string'
? replacer(arrayValue)
: typeof arrayValue === 'object'
? replaceInFrontmatter(arrayValue, replacer)
: arrayValue
})
}
}
return record
}
function containsFrontmatter(data: unknown): data is { frontmatter: Record<string, unknown> } {
return data != undefined && typeof data === 'object' && 'frontmatter' in data
}
+42
View File
@@ -0,0 +1,42 @@
import type { MarkdownHeading } from 'astro'
import { getConfig } from './config'
// Generate a tree like structure from a list of headings.
export function generateToc(allHeadings: MarkdownHeading[]) {
const headings = allHeadings.filter(
(heading) => heading.depth >= getConfig().toc.min && heading.depth <= getConfig().toc.max
)
const toc: TocEntry[] = []
for (const heading of headings) {
if (toc.length === 0) {
toc.push({ ...heading, children: [] })
continue
}
const previousEntry = toc[toc.length - 1]
if (heading.depth === previousEntry.depth) {
toc.push({ ...heading, children: [] })
continue
}
const children = getEntryChildrenAtDepth(previousEntry, heading.depth - previousEntry.depth)
children.push({ ...heading, children: [] })
}
return toc
}
function getEntryChildrenAtDepth(entry: TocEntry, depth: number): TocEntry['children'] {
if (!entry) {
return []
}
return depth === 1 ? entry.children : getEntryChildrenAtDepth(entry.children[entry.children.length - 1], depth - 1)
}
export interface TocEntry extends MarkdownHeading {
children: TocEntry[]
}
+44
View File
@@ -0,0 +1,44 @@
import { slug } from 'github-slugger'
import fromMarkdown from 'mdast-util-from-markdown'
import toString from 'mdast-util-to-string'
import { remark } from 'remark'
import remarkHtml from 'remark-html'
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export function getSequence(start: number, end: number, step = 1) {
const sequence = []
for (let i = start; i <= end; i += step) {
sequence.push(i)
}
return sequence
}
// This function is used in the docs sidebar to generate partial slugs and properly order the sidebar entries and also
// to generate docs frontmatter sections slugs.
// Note: this should be refactored and removed, the sidebar ordering defined in `site/data/sidebar.yml` should not rely
// on slugified custom titles that are expected to generate a string matching the actual file names on disk, this is
// error prone. Instead, custom sidebar titles should be defined in the frontmatter of the MDX files when needed and
// `site/data/sidebar.yml` should only reference the actual file names and slug extracted from the docs content
// collection. Same goes for the docs frontmatter sections.
export function getSlug(str: string) {
return slug(str).replace(/--+/g, '-')
}
export function trimLeadingAndTrailingSlashes(str: string) {
return str.replace(/^\/+|\/+$/g, '')
}
export function stripMarkdown(str: string) {
return toString(fromMarkdown(str))
}
export function processMarkdownToHtml(markdown: string): string {
// Use remark to process markdown to HTML
const result = remark().use(remarkHtml).processSync(markdown)
return result.toString()
}
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod'
export const zVersionMajorMinor = z.string().regex(/^\d+\.\d+$/)
// https://ihateregex.io/expr/semver/
const unboundSemverRegex =
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/
export const zVersionSemver = z.string().regex(new RegExp(`^${unboundSemverRegex.source}$`))
export const zPrefixedVersionSemver = z.string().regex(new RegExp(`^v${unboundSemverRegex.source}$`))
export const zHexColor = z.string().regex(/^#(?:[0-9a-fA-F]{3}){1,2}$/)
export const zNamedHexColors = (count: number) => {
return z
.object({
name: z.union([z.string(), z.number()]),
hex: zHexColor
})
.array()
.length(count)
}
export const zPxSizeOrEmpty = z.string().regex(/^(?:\d+px)?$/)
export const zLanguageCode = z.string().regex(/^[a-z]{2}(?:-[a-zA-Z]{2})?$/)