Skip to content
Draft
33 changes: 26 additions & 7 deletions astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import starlight from '@astrojs/starlight';
import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-sections';
import { defineConfig, sharpImageService } from 'astro/config';
import rehypeSlug from 'rehype-slug';
import remarkSmartypants from 'remark-smartypants';
Expand All @@ -10,6 +9,15 @@ import { localesConfig } from './config/locales';
import { starlightPluginSmokeTest } from './config/plugins/smoke-test';
import { rehypeTasklistEnhancer } from './config/plugins/rehype-tasklist-enhancer';
import { remarkFallbackLang } from './config/plugins/remark-fallback-lang';
import { tasklistEnhancerPlugin } from './config/plugins/satteri-tasklist-enhancer';
import { fallbackLangPlugin } from './config/plugins/satteri-fallback-lang';
import {
asidesPlugin,
autolinkHeadingsPlugin,
directivesRestorationPlugin,
rtlCodeSupportPlugin,
} from './config/plugins/starlight';
import mdx from '@astrojs/mdx';

/* https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables */
const NETLIFY_PREVIEW_SITE = process.env.CONTEXT !== 'production' && process.env.DEPLOY_PRIME_URL;
Expand All @@ -27,9 +35,7 @@ export default defineConfig({
]),
starlight({
title: 'Docs',
expressiveCode: {
plugins: [pluginCollapsibleSections()],
},
expressiveCode: false,
components: {
EditLink: './src/components/starlight/EditLink.astro',
Hero: './src/components/starlight/Hero.astro',
Expand Down Expand Up @@ -71,13 +77,13 @@ export default defineConfig({
plugins: [starlightPluginSmokeTest()],
}),
sitemap(),
mdx({
optimize: true,
}),
],
trailingSlash: 'always',
scopedStyleStrategy: 'where',
compressHTML: false,
experimental: {
rustCompiler: true,
},
markdown: {
// Override with our own config
smartypants: false,
Expand All @@ -93,6 +99,19 @@ export default defineConfig({
rehypeTasklistEnhancer(),
],
},
experimental: {
rustCompiler: true,
nativeMarkdown: {
mdastPlugins: [fallbackLangPlugin(), asidesPlugin(), directivesRestorationPlugin()],
hastPlugins: [tasklistEnhancerPlugin(), rtlCodeSupportPlugin(), autolinkHeadingsPlugin()],
features: {
directive: true,
smartPunctuation: {
dashes: false,
},
},
},
},
image: {
domains: ['avatars.githubusercontent.com'],
service: sharpImageService(),
Expand Down
58 changes: 58 additions & 0 deletions config/plugins/satteri-fallback-lang.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import fs from 'node:fs';
import path from 'node:path';
import { defineMdastPlugin } from 'satteri';

const pageSourceDir = path.resolve('./src/content/docs');
const baseUrl = 'https://docs.astro.build/';

export function fallbackLangPlugin() {
return defineMdastPlugin({
name: 'fallback-lang',
link(node, context) {
const pageUrl = mdFilePathToUrl(context.filename, pageSourceDir, baseUrl);
const pageLang = getLanguageCodeFromPathname(pageUrl.pathname);

// Ignore pages without language prefix and English pages
if (!pageLang || pageLang === 'en') return;

const linkUrl = new URL(node.url, pageUrl);

// Ignore external links
if (pageUrl.host !== linkUrl.host) return;

// Ignore link targets without language prefix
const linkLang = getLanguageCodeFromPathname(linkUrl.pathname);
if (!linkLang) return;

// Ignore link targets that have a valid source file
const linkSourceFileName = tryFindSourceFileForPathname(linkUrl.pathname, pageSourceDir);
if (linkSourceFileName) return;

context.appendChild(node, {
type: 'text',
value: '\u00A0(EN)',
});
},
});
}

function mdFilePathToUrl(mdFilePath: string, pageSourceDir: string, baseUrl: string) {
const pathBelowRoot = path.relative(pageSourceDir, mdFilePath);
const pathname = pathBelowRoot.replace(/\\/g, '/').replace(/\.mdx?$/i, '/');
return new URL(pathname, baseUrl);
}

function getLanguageCodeFromPathname(pathname: string) {
const firstPathPart = pathname.split('/')[1];
if (firstPathPart?.match(/^[a-z]{2}(-[a-zA-Z]{2})?$/)) return firstPathPart;
}

function tryFindSourceFileForPathname(pathname: string, pageSourceDir: string) {
const possibleSourceFilePaths = [
path.join(pageSourceDir, pathname, '.') + '.md',
path.join(pageSourceDir, pathname, 'index.md'),
path.join(pageSourceDir, pathname, '.') + '.mdx',
path.join(pageSourceDir, pathname, 'index.mdx'),
];
return possibleSourceFilePaths.find((possiblePath) => fs.existsSync(possiblePath));
}
101 changes: 101 additions & 0 deletions config/plugins/satteri-tasklist-enhancer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { defineHastPlugin } from 'satteri';

import type { Element, ElementContent } from 'hast';

/**
* Satteri HAST plugin to enhance the output of GitHub-Flavored Markdown's task lists.
* This improves possibilities for our `<Checklist>` component.
*
* 1. Wraps checkboxes and siblings in a `<label>` to associate them.
* 2. Wraps sibling nodes after checkboxes in `<span>` to ease styling `:checked ~ *`.
*/
export function tasklistEnhancerPlugin() {
return defineHastPlugin({
name: 'tasklist-enhancer',
element: [
{
filter: ['ul'],
visit(node, ctx) {
if (hasCheckboxChild(node)) {
ctx.setProperty(node, 'className', ['contains-task-list']);
}
},
},
{
filter: ['li'],
visit(node) {
const children = node.children;

const result = findCheckboxInSubtree(node);
if (!result) return;

const { parent, index } = result;
const parentChildren = parent.children;

const head = parentChildren.slice(0, index + 1);
const tail = parentChildren.slice(index + 1);
const label = h('label', {}, [...head, h('span', {}, tail)]);

// Build new li with restructured children
let newChildren: ElementContent[];
if (parent === node) {
// Input was a direct child of the li
newChildren = [label];
} else {
// Input was inside a child element (e.g. <p>) — clone that child
newChildren = children.map((child) =>
child === parent ? h(parent.tagName, parent.properties ?? {}, [label]) : child
);
}

return {
type: 'element',
tagName: node.tagName,
properties: {
...node.properties,
className: ['task-list-item'],
},
children: newChildren,
};
},
},
],
});
}

/** Check if a <ul> has any direct <li> children containing a checkbox. */
function hasCheckboxChild(ul: Element): boolean {
return ul.children.some(
(li) => li.type === 'element' && li.tagName === 'li' && findCheckboxInSubtree(li) !== undefined
);
}

/** Depth-first search for a checkbox `<input>`, returning its direct parent and index. */
function findCheckboxInSubtree(node: Element): { parent: Element; index: number } | undefined {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i]!;
if (
child.type === 'element' &&
child.tagName === 'input' &&
child.properties?.['type'] === 'checkbox'
) {
return { parent: node, index: i };
}
}

for (const child of node.children) {
if (child.type === 'element') {
const result = findCheckboxInSubtree(child);
if (result) return result;
}
}
}

function h(tag: string, properties: Element['properties'], children: ElementContent[]): Element {
return {
type: 'element',
tagName: tag,
properties,
children,
};
}
Loading
Loading