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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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[] }
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SvgIconProps {
|
||||
class?: string
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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})?$/)
|
||||
Reference in New Issue
Block a user