Merge branch 'main' into tinyglobby

This commit is contained in:
sebastien 2025-04-08 15:46:37 +02:00
commit bbce79b5eb
146 changed files with 4755 additions and 1686 deletions

View File

@ -23,6 +23,7 @@
"CHANGELOG.md",
"patches",
"packages/docusaurus-theme-translations/locales",
"packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy",
"package.json",
"yarn.lock",
"project-words.txt",

View File

@ -22,3 +22,6 @@ packages/create-docusaurus/templates/facebook
website/_dogfooding/_swizzle_theme_tests
website/_dogfooding/_asset-tests/badSyntax.js
packages/docusaurus-plugin-ideal-image/src/theme/IdealImageLegacy

2
.eslintrc.js vendored
View File

@ -298,7 +298,7 @@ module.exports = {
'jest/expect-expect': OFF,
'jest/no-large-snapshots': [
WARNING,
{maxSize: Infinity, inlineMaxSize: 10},
{maxSize: Infinity, inlineMaxSize: 50},
],
'jest/no-test-return-statement': ERROR,
'jest/prefer-expect-resolves': WARNING,

View File

@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*

View File

@ -24,7 +24,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -27,7 +27,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -43,7 +43,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn
@ -75,7 +75,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -24,7 +24,7 @@ jobs:
with:
fetch-depth: 0 # Needed to get the commit number with "git rev-list --count HEAD"
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -15,4 +15,4 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Dependency Review
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # 4.5.0
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # 4.6.0

View File

@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*

View File

@ -22,7 +22,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -24,7 +24,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -43,7 +43,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: ${{ matrix.node }}
cache: yarn
@ -84,7 +84,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js LTS
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn
@ -153,7 +153,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js LTS
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn
@ -193,7 +193,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js LTS
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -28,7 +28,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node LTS
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: lts/*
cache: yarn

View File

@ -34,7 +34,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: ${{ matrix.node }}
- name: Installation

View File

@ -32,7 +32,7 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
with:
node-version: ${{ matrix.node }}
cache: yarn

3
.gitignore vendored
View File

@ -45,3 +45,6 @@ website/i18n/**/*
#!website/i18n/fr/**/*
.netlify
website/rspack-tracing.json
website/bundler-cpu-profile.json

View File

@ -9,9 +9,9 @@ import fs from 'fs-extra';
import path from 'path';
import {fileURLToPath} from 'url';
import {program} from 'commander';
import logger from '@docusaurus/logger';
import {logger} from '@docusaurus/logger';
import sharp from 'sharp';
import imageSize from 'image-size';
import {imageSizeFromFile} from 'image-size/fromFile';
// You can use it as:
//
@ -64,7 +64,7 @@ program
await Promise.all(
images.map(async (imgPath) => {
const {width, height} = imageSize(imgPath);
const {width, height} = await imageSizeFromFile(imgPath);
const targetWidth =
options.width ?? (imgPath.includes(showcasePath) ? 640 : 1000);
const targetHeight =

View File

@ -15,6 +15,7 @@
"scripts": {
"start": "yarn build:packages && yarn start:website",
"start:website": "yarn workspace website start",
"start:website:profile": "DOCUSAURUS_BUNDLER_CPU_PROFILE=true DOCUSAURUS_RSPACK_TRACE=true yarn workspace website start",
"start:website:baseUrl": "yarn workspace website start:baseUrl",
"start:website:blogOnly": "yarn workspace website start:blogOnly",
"start:website:deployPreview": "cross-env NETLIFY=true CONTEXT='deploy-preview' yarn workspace website start",
@ -22,6 +23,7 @@
"build": "yarn build:packages && yarn build:website",
"build:packages": "lerna run build --no-private",
"build:website": "yarn workspace website build",
"build:website:profile": "DOCUSAURUS_BUNDLER_CPU_PROFILE=true DOCUSAURUS_RSPACK_TRACE=true yarn workspace website build",
"build:website:baseUrl": "yarn workspace website build:baseUrl",
"build:website:blogOnly": "yarn workspace website build:blogOnly",
"build:website:deployPreview:testWrap": "yarn workspace website test:swizzle:wrap:ts",
@ -102,7 +104,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-regexp": "^1.15.0",
"husky": "^8.0.3",
"image-size": "^1.0.2",
"image-size": "^2.0.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-serializer-ansi-escapes": "^3.0.0",

View File

@ -19,7 +19,7 @@
"license": "MIT",
"dependencies": {
"@docusaurus/types": "3.7.0",
"@rspack/core": "^1.2.5",
"@rspack/core": "^1.3.3",
"@swc/core": "^1.7.39",
"@swc/html": "^1.7.39",
"browserslist": "^4.24.2",

View File

@ -11,6 +11,16 @@ import browserslist from 'browserslist';
import {minify as swcHtmlMinifier} from '@swc/html';
import type {JsMinifyOptions, Options as SwcOptions} from '@swc/core';
// See https://rspack.dev/contribute/development/profiling
// File can be opened with https://ui.perfetto.dev/
if (process.env.DOCUSAURUS_RSPACK_TRACE) {
Rspack.experiments.globalTrace.register(
'trace',
'chrome',
'./rspack-tracing.json',
);
}
export const swcLoader = require.resolve('swc-loader');
export const getSwcLoaderOptions = ({

View File

@ -27,7 +27,7 @@
"estree-util-value-to-estree": "^3.0.1",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.1",
"image-size": "^1.0.2",
"image-size": "^2.0.2",
"mdast-util-mdx": "^3.0.0",
"mdast-util-to-string": "^4.0.0",
"rehype-raw": "^7.0.0",

View File

@ -0,0 +1,126 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {toHeadingHTMLValue} from '../utils';
import type {Heading} from 'mdast';
describe('toHeadingHTMLValue', () => {
async function convert(heading: Heading): Promise<string> {
const {toString} = await import('mdast-util-to-string');
return toHeadingHTMLValue(heading, toString);
}
it('converts a simple heading', async () => {
const heading: Heading = {
type: 'heading',
depth: 2,
children: [
{
type: 'text',
value: 'Some heading text',
},
],
};
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
`"Some heading text"`,
);
});
it('converts a heading with b tag', async () => {
const heading: Heading = {
type: 'heading',
depth: 2,
children: [
{
type: 'mdxJsxTextElement',
name: 'b',
attributes: [],
children: [
{
type: 'text',
value: 'Some title',
},
],
},
],
};
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
`"<b>Some title</b>"`,
);
});
it('converts a heading with span tag + className', async () => {
const heading: Heading = {
type: 'heading',
depth: 2,
children: [
{
type: 'mdxJsxTextElement',
name: 'span',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'className',
value: 'my-class',
},
],
children: [
{
type: 'text',
value: 'Some title',
},
],
},
],
};
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
`"<span class="my-class">Some title</span>"`,
);
});
it('converts a heading - remove img tag', async () => {
const heading: Heading = {
type: 'heading',
depth: 2,
children: [
{
type: 'mdxJsxTextElement',
name: 'img',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'src',
value: '/img/slash-introducing.svg',
},
{
type: 'mdxJsxAttribute',
name: 'height',
value: '32',
},
{
type: 'mdxJsxAttribute',
name: 'alt',
value: 'test',
},
],
children: [],
},
{
type: 'text',
value: ' Some title',
},
],
};
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
`"Some title"`,
);
});
});

View File

@ -5,9 +5,13 @@
* LICENSE file in the root directory of this source tree.
*/
import {toValue} from '../utils';
import type {Node} from 'unist';
import type {MdxjsEsm} from 'mdast-util-mdx';
import escapeHtml from 'escape-html';
import type {Node, Parent} from 'unist';
import type {
MdxjsEsm,
MdxJsxAttribute,
MdxJsxTextElement,
} from 'mdast-util-mdx';
import type {TOCHeading, TOCItem, TOCItems, TOCSlice} from './types';
import type {
Program,
@ -15,6 +19,7 @@ import type {
ImportDeclaration,
ImportSpecifier,
} from 'estree';
import type {Heading, PhrasingContent} from 'mdast';
export function getImportDeclarations(program: Program): ImportDeclaration[] {
return program.body.filter(
@ -118,7 +123,7 @@ export async function createTOCExportNodeAST({
const {toString} = await import('mdast-util-to-string');
const {valueToEstree} = await import('estree-util-value-to-estree');
const value: TOCItem = {
value: toValue(heading, toString),
value: toHeadingHTMLValue(heading, toString),
id: heading.data!.id!,
level: heading.depth,
};
@ -172,3 +177,73 @@ export async function createTOCExportNodeAST({
},
};
}
function stringifyChildren(
node: Parent,
toString: (param: unknown) => string, // TODO temporary, due to ESM
): string {
return (node.children as PhrasingContent[])
.map((item) => toHeadingHTMLValue(item, toString))
.join('')
.trim();
}
// TODO This is really a workaround, and not super reliable
// For now we only support serializing tagName, className and content
// Can we implement the TOC with real JSX nodes instead of html strings later?
function mdxJsxTextElementToHtml(
element: MdxJsxTextElement,
toString: (param: unknown) => string, // TODO temporary, due to ESM
): string {
const tag = element.name;
// See https://github.com/facebook/docusaurus/issues/11003#issuecomment-2733925363
if (tag === 'img') {
return '';
}
const attributes = element.attributes.filter(
(child): child is MdxJsxAttribute => child.type === 'mdxJsxAttribute',
);
const classAttribute =
attributes.find((attr) => attr.name === 'className') ??
attributes.find((attr) => attr.name === 'class');
const classAttributeString = classAttribute
? `class="${escapeHtml(String(classAttribute.value))}"`
: ``;
const allAttributes = classAttributeString ? ` ${classAttributeString}` : '';
const content = stringifyChildren(element, toString);
return `<${tag}${allAttributes}>${content}</${tag}>`;
}
export function toHeadingHTMLValue(
node: PhrasingContent | Heading | MdxJsxTextElement,
toString: (param: unknown) => string, // TODO temporary, due to ESM
): string {
switch (node.type) {
case 'mdxJsxTextElement': {
return mdxJsxTextElementToHtml(node as MdxJsxTextElement, toString);
}
case 'text':
return escapeHtml(node.value);
case 'heading':
return stringifyChildren(node, toString);
case 'inlineCode':
return `<code>${escapeHtml(node.value)}</code>`;
case 'emphasis':
return `<em>${stringifyChildren(node, toString)}</em>`;
case 'strong':
return `<strong>${stringifyChildren(node, toString)}</strong>`;
case 'delete':
return `<del>${stringifyChildren(node, toString)}</del>`;
case 'link':
return stringifyChildren(node, toString);
default:
return toString(node);
}
}

View File

@ -8,7 +8,6 @@
import path from 'path';
import url from 'url';
import fs from 'fs-extra';
import {promisify} from 'util';
import {
toMessageRelativeFilePath,
posixPath,
@ -17,7 +16,7 @@ import {
getFileLoaderUtils,
} from '@docusaurus/utils';
import escapeHtml from 'escape-html';
import sizeOf from 'image-size';
import {imageSizeFromFile} from 'image-size/fromFile';
import logger from '@docusaurus/logger';
import {assetRequireAttributeValue, transformNode} from '../utils';
import type {Plugin, Transformer} from 'unified';
@ -80,7 +79,7 @@ async function toImageRequireNode(
}
try {
const size = (await promisify(sizeOf)(imagePath))!;
const size = (await imageSizeFromFile(imagePath))!;
if (size.width) {
attributes.push({
type: 'mdxJsxAttribute',

View File

@ -5,14 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
import escapeHtml from 'escape-html';
import type {Parent, Node} from 'unist';
import type {PhrasingContent, Heading} from 'mdast';
import type {
MdxJsxAttribute,
MdxJsxAttributeValueExpression,
MdxJsxTextElement,
} from 'mdast-util-mdx';
import type {Node} from 'unist';
import type {MdxJsxAttributeValueExpression} from 'mdast-util-mdx';
/**
* Util to transform one node type to another node type
@ -35,70 +29,6 @@ export function transformNode<NewNode extends Node>(
return node as NewNode;
}
export function stringifyContent(
node: Parent,
toString: (param: unknown) => string, // TODO weird but works
): string {
return (node.children as PhrasingContent[])
.map((item) => toValue(item, toString))
.join('');
}
// TODO This is really a workaround, and not super reliable
// For now we only support serializing tagName, className and content
// Can we implement the TOC with real JSX nodes instead of html strings later?
function mdxJsxTextElementToHtml(
element: MdxJsxTextElement,
toString: (param: unknown) => string, // TODO weird but works
): string {
const tag = element.name;
const attributes = element.attributes.filter(
(child): child is MdxJsxAttribute => child.type === 'mdxJsxAttribute',
);
const classAttribute =
attributes.find((attr) => attr.name === 'className') ??
attributes.find((attr) => attr.name === 'class');
const classAttributeString = classAttribute
? `class="${escapeHtml(String(classAttribute.value))}"`
: ``;
const allAttributes = classAttributeString ? ` ${classAttributeString}` : '';
const content = stringifyContent(element, toString);
return `<${tag}${allAttributes}>${content}</${tag}>`;
}
export function toValue(
node: PhrasingContent | Heading | MdxJsxTextElement,
toString: (param: unknown) => string, // TODO weird but works
): string {
switch (node.type) {
case 'mdxJsxTextElement': {
return mdxJsxTextElementToHtml(node as MdxJsxTextElement, toString);
}
case 'text':
return escapeHtml(node.value);
case 'heading':
return stringifyContent(node, toString);
case 'inlineCode':
return `<code>${escapeHtml(node.value)}</code>`;
case 'emphasis':
return `<em>${stringifyContent(node, toString)}</em>`;
case 'strong':
return `<strong>${stringifyContent(node, toString)}</strong>`;
case 'delete':
return `<del>${stringifyContent(node, toString)}</del>`;
case 'link':
return stringifyContent(node, toString);
default:
return toString(node);
}
}
export function assetRequireAttributeValue(
requireString: string,
hash: string,

View File

@ -17,7 +17,7 @@
"@types/react": "*",
"@types/react-router-config": "*",
"@types/react-router-dom": "*",
"react-helmet-async": "npm:@slorber/react-helmet-async@*",
"react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0",
"react-loadable": "npm:@docusaurus/react-loadable@6.0.0"
},
"peerDependencies": {

View File

@ -71,7 +71,7 @@
<div class="blog-posts">
<xsl:for-each select="atom:feed/atom:entry">
<div class="blog-post">
<h3><a href="{atom:link[@rel='alternate']/@href}"><xsl:value-of
<h3><a href="{atom:link/@href}"><xsl:value-of
select="atom:title"
/></a></h3>
<div class="blog-post-date">

View File

@ -23,7 +23,6 @@ describe('normalizeSocials', () => {
mastodon: 'Mastodon',
};
// eslint-disable-next-line jest/no-large-snapshots
expect(normalizeSocials(socials)).toMatchInlineSnapshot(`
{
"bluesky": "https://bsky.app/profile/gingergeek.co.uk",

View File

@ -42,19 +42,16 @@ describe('validateSidebars', () => {
});
it('sidebar category wrong label', () => {
expect(
() =>
validateSidebars({
docs: [
{
type: 'category',
label: true,
items: [{type: 'doc', id: 'doc1'}],
},
],
}),
// eslint-disable-next-line jest/no-large-snapshots
expect(() =>
validateSidebars({
docs: [
{
type: 'category',
label: true,
items: [{type: 'doc', id: 'doc1'}],
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
"type": "category",

View File

@ -26,8 +26,6 @@
"@docusaurus/theme-translations": "3.7.0",
"@docusaurus/types": "3.7.0",
"@docusaurus/utils-validation": "3.7.0",
"@slorber/react-ideal-image": "^0.0.14",
"react-waypoint": "^10.3.0",
"sharp": "^0.32.3",
"tslib": "^2.6.0",
"webpack": "^5.88.1"

View File

@ -1,124 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/// <reference types="@docusaurus/module-type-aliases" />
/**
* @see https://github.com/endiliey/react-ideal-image/blob/master/index.d.ts
* Note: the original type definition is WRONG. getIcon & getMessage receive
* full state object.
*/
declare module '@slorber/react-ideal-image' {
import type {
ComponentProps,
ComponentType,
CSSProperties,
ReactNode,
} from 'react';
export type LoadingState = 'initial' | 'loading' | 'loaded' | 'error';
export type State = {
pickedSrc: {
size: number;
};
loadInfo: 404 | null;
loadState: LoadingState;
};
export type IconKey =
| 'load'
| 'loading'
| 'loaded'
| 'error'
| 'noicon'
| 'offline';
export type SrcType = {
width: number;
src?: string;
size?: number;
format?: 'webp' | 'jpeg' | 'png' | 'gif';
};
type ThemeKey = 'placeholder' | 'img' | 'icon' | 'noscript';
export interface ImageProps
extends Omit<ComponentProps<'img'>, 'srcSet' | 'placeholder'> {
/**
* This function decides what icon to show based on the current state of the
* component.
*/
getIcon?: (state: State) => IconKey;
/**
* This function decides what message to show based on the icon (returned
* from `getIcon` prop) and the current state of the component.
*/
getMessage?: (icon: IconKey, state: State) => string | null;
/**
* This function is called as soon as the component enters the viewport and
* is used to generate urls based on width and format if `props.srcSet`
* doesn't provide `src` field.
*/
getUrl?: (srcType: SrcType) => string;
/**
* The Height of the image in px.
*/
height: number;
/**
* This provides a map of the icons. By default, the component uses icons
* from material design, Implemented as React components with the SVG
* element. You can customize icons
*/
icons?: Partial<{[icon in IconKey]: ComponentType}>;
/**
* This prop takes one of the 2 options, xhr and image.
* Read more about it:
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download
*/
loader?: 'xhr' | 'image';
/**
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip
*/
placeholder: {color: string} | {lqip: string};
/**
* This function decides if image should be downloaded automatically. The
* default function returns false for a 2g network, for a 3g network it
* decides based on `props.threshold` and for a 4g network it returns true
* by default.
*/
shouldAutoDownload?: (options: {
connection?: 'slow-2g' | '2g' | '3g' | '4g';
size?: number;
threshold?: number;
possiblySlowNetwork?: boolean;
}) => boolean;
/**
* This provides an array of sources of different format and size of the
* image. Read more about it:
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset
*/
srcSet: SrcType[];
/**
* This provides a theme to the component. By default, the component uses
* inline styles, but it is also possible to use CSS modules and override
* all styles.
*/
theme?: Partial<{[key in ThemeKey]: string | CSSProperties}>;
/**
* Tells how much to wait in milliseconds until consider the download to be
* slow.
*/
threshold?: number;
/**
* Width of the image in px.
*/
width: number;
}
export default function IdealImage(props: ImageProps): ReactNode;
}

View File

@ -5,6 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
/// <reference types="@docusaurus/module-type-aliases" />
declare module '@docusaurus/plugin-ideal-image' {
export type PluginOptions = {
/**
@ -74,3 +76,122 @@ declare module '@theme/IdealImage' {
}
export default function IdealImage(props: Props): ReactNode;
}
declare module '@theme/IdealImageLegacy' {
/**
* @see https://github.com/endiliey/react-ideal-image/blob/master/index.d.ts
* Note: the original type definition is WRONG. getIcon & getMessage receive
* full state object.
*/
import type {
ComponentProps,
ComponentType,
CSSProperties,
ReactNode,
} from 'react';
export type LoadingState = 'initial' | 'loading' | 'loaded' | 'error';
export type State = {
pickedSrc: {
size: number;
};
loadInfo: 404 | null;
loadState: LoadingState;
};
export type IconKey =
| 'load'
| 'loading'
| 'loaded'
| 'error'
| 'noicon'
| 'offline';
export type SrcType = {
width: number;
src?: string;
size?: number;
format?: 'webp' | 'jpeg' | 'png' | 'gif';
};
type ThemeKey = 'placeholder' | 'img' | 'icon' | 'noscript';
export interface ImageProps
extends Omit<ComponentProps<'img'>, 'srcSet' | 'placeholder'> {
/**
* This function decides what icon to show based on the current state of the
* component.
*/
getIcon?: (state: State) => IconKey;
/**
* This function decides what message to show based on the icon (returned
* from `getIcon` prop) and the current state of the component.
*/
getMessage?: (icon: IconKey, state: State) => string | null;
/**
* This function is called as soon as the component enters the viewport and
* is used to generate urls based on width and format if `props.srcSet`
* doesn't provide `src` field.
*/
getUrl?: (srcType: SrcType) => string;
/**
* The Height of the image in px.
*/
height: number;
/**
* This provides a map of the icons. By default, the component uses icons
* from material design, Implemented as React components with the SVG
* element. You can customize icons
*/
icons?: Partial<{[icon in IconKey]: ComponentType}>;
/**
* This prop takes one of the 2 options, xhr and image.
* Read more about it:
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download
*/
loader?: 'xhr' | 'image';
/**
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip
*/
placeholder: {color: string} | {lqip: string};
/**
* This function decides if image should be downloaded automatically. The
* default function returns false for a 2g network, for a 3g network it
* decides based on `props.threshold` and for a 4g network it returns true
* by default.
*/
shouldAutoDownload?: (options: {
connection?: 'slow-2g' | '2g' | '3g' | '4g';
size?: number;
threshold?: number;
possiblySlowNetwork?: boolean;
}) => boolean;
/**
* This provides an array of sources of different format and size of the
* image. Read more about it:
* https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset
*/
srcSet: SrcType[];
/**
* This provides a theme to the component. By default, the component uses
* inline styles, but it is also possible to use CSS modules and override
* all styles.
*/
theme?: Partial<{[key in ThemeKey]: string | CSSProperties}>;
/**
* Tells how much to wait in milliseconds until consider the download to be
* slow.
*/
threshold?: number;
/**
* Width of the image in px.
*/
width: number;
}
export interface Props extends ImageProps {}
export default function IdealImageLegacy(props: ImageProps): ReactNode;
}

View File

@ -6,11 +6,11 @@
*/
import React, {type ReactNode} from 'react';
import {translate} from '@docusaurus/Translate';
import ReactIdealImage, {
type IconKey,
type State,
} from '@slorber/react-ideal-image';
import {translate} from '@docusaurus/Translate';
} from '@theme/IdealImageLegacy';
import type {Props} from '@theme/IdealImage';
@ -93,7 +93,6 @@ export default function IdealImage(props: Props): ReactNode {
return (
<ReactIdealImage
{...propsRest}
height={img.src.height ?? 100}
width={img.src.width ?? 100}
placeholder={{lqip: img.preSrc}}

View File

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2017 stereobooster
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,13 @@
# Legacy React IdealImage lib
This is legacy code from an npm package we forked, then internalized
See also:
- https://github.com/slorber/docusaurus-react-ideal-image
- https://github.com/endiliey/react-ideal-image
- https://github.com/stereobooster/react-ideal-image
---
TODO: we need to clean it up, remove what we don't need, and maintain it up to date

View File

@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Download icon Should render a snapshot that is good 1`] = `
<svg
height={24}
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0z"
fill="none"
/>
<path
d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"
fill="#000"
/>
</svg>
`;
exports[`Loading icon Should render a snapshot that is good 1`] = `
<svg
height={24}
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0z"
fill="none"
/>
<path
d="M6,2V8H6V8L10,12L6,16V16H6V22H18V16H18V16L14,12L18,8V8H18V2H6M16,16.5V20H8V16.5L12,12.5L16,16.5M12,11.5L8,7.5V4H16V7.5L12,11.5Z"
fill="#000"
/>
</svg>
`;
exports[`Offline icon Should render a snapshot that is good 1`] = `
<svg
height={24}
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0z"
fill="none"
/>
<path
d="M19.35 10.04C18.67 6.59 15.64 4 12 4c-1.48 0-2.85.43-4.01 1.17l1.46 1.46C10.21 6.23 11.08 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3 0 1.13-.64 2.11-1.56 2.62l1.45 1.45C23.16 18.16 24 16.68 24 15c0-2.64-2.05-4.78-4.65-4.96zM3 5.27l2.75 2.74C2.56 8.15 0 10.77 0 14c0 3.31 2.69 6 6 6h11.73l2 2L21 20.73 4.27 4 3 5.27zM7.73 10l8 8H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h1.73z"
fill="#000"
/>
</svg>
`;
exports[`Warning icon Should render a snapshot that is good 1`] = `
<svg
height={24}
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0h24v24H0z"
fill="none"
/>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
fill="#000"
/>
</svg>
`;

View File

@ -0,0 +1,32 @@
import compose from '../components/composeStyle';
describe('composeStyle', () => {
it('Should combine object classes into one className string', () => {
const theme = {
base: 'base',
element: 'base__element',
};
const result = compose(theme.base, theme.element);
expect(result.className).toEqual(`${theme.base} ${theme.element}`);
});
it('Should return a styles object unmodified', () => {
const style = {
color: 'blue',
margin: '1em 0',
};
const result = compose(style);
const expected = style;
expect(result.style).toEqual(expected);
});
it('Should throw an error if given a parameter not an object or string', () => {
const number = 1;
try {
compose(number);
expect(true).toBe(false);
} catch (e) {
expect(e.message).toBe(`Unexpected value ${number}`);
}
});
});

View File

@ -0,0 +1,185 @@
import {
// guessMaxImageWidth,
bytesToSize,
selectSrc,
fallbackParams,
} from '../components/helpers';
/*
describe('guessMaxImageWidth', () => {
it('Should calculate the maximum image width', () => {
const dimensions = {
width: 400,
height: 100,
}
const mockedWindow = {
screen: {
width: 100,
},
innerWidth: 1024,
innerHeight: 768,
}
const expected = dimensions.width
const result = guessMaxImageWidth(dimensions, mockedWindow)
expect(result).toEqual(expected)
})
it('Should calculate the maximum image width with screen changes', () => {
const dimensions = {
width: 400,
height: 100,
}
const mockedWindow = {
screen: {
width: 100,
},
innerWidth: 50,
innerHeight: 30,
}
const expected =
(dimensions.width / mockedWindow.innerWidth) * mockedWindow.screen.width
const result = guessMaxImageWidth(dimensions, mockedWindow)
expect(result).toEqual(expected)
})
it('Should calculate the maximum image width with screen changes and scroll', () => {
const body = document.getElementsByTagName('body')[0]
Object.defineProperty(body, 'clientHeight', {
get: () => {
return 400
},
})
const dimensions = {
width: 400,
height: 100,
}
const mockedWindow = {
screen: {
width: 100,
},
innerWidth: 50,
innerHeight: 100,
}
const expected = 450
const result = guessMaxImageWidth(dimensions, mockedWindow)
expect(result).toEqual(expected)
})
})
*/
describe('bytesToSize', () => {
const bitsInKB = 1024;
const bitsInMB = bitsInKB * bitsInKB;
it('Should correctly calculate size less than a single byte', () => {
const bytes = 4;
const result = bytesToSize(bytes);
expect(result).toEqual(`${bytes} Bytes`);
});
it('Should correctly calculate size one bit less than a kilobyte', () => {
const bytes = bitsInKB - 1;
const result = bytesToSize(bytes);
expect(result).toEqual(`${bytes} Bytes`);
});
it('Should correctly calculate size of exactly a kilobyte', () => {
const expected = '1.0 KB';
const result = bytesToSize(bitsInKB);
expect(result).toEqual(expected);
});
it('Should correctly calculate decimal value of exactly a kilobyte plus 100 bits', () => {
const expected = '1.1 KB';
const result = bytesToSize(bitsInKB + 100);
expect(result).toEqual(expected);
});
it('Should correctly calculate size of exactly a megabybte', () => {
const expected = '1.0 MB';
const result = bytesToSize(bitsInMB);
expect(result).toEqual(expected);
});
});
describe('selectSrc', () => {
it('Should throw if provided no srcSet', () => {
const props = {
srcSet: [],
};
try {
selectSrc(props);
expect(true).toBe(false);
} catch (e) {
expect(e.message).toBe('Need at least one item in srcSet');
}
});
it('Should throw if provided no supported formats in srcSet', () => {
const props = {
srcSet: [{format: 'webp'}],
};
try {
selectSrc(props);
expect(true).toBe(false);
} catch (e) {
expect(e.message).toBe(
'Need at least one supported format item in srcSet',
);
}
});
it('Should select the right source with an image greater than the max width', () => {
const srcThatShouldBeSelected = {format: 'jpeg', width: 100};
const props = {
srcSet: [srcThatShouldBeSelected],
maxImageWidth: 100,
};
const expected = srcThatShouldBeSelected;
const result = selectSrc(props);
expect(result).toEqual(expected);
});
it('Should select the right source with an image less than the max width', () => {
const srcThatShouldBeSelected = {format: 'jpeg', width: 99};
const srcThatShouldNotBeSelected = {format: 'jpeg', width: 98};
const props = {
srcSet: [srcThatShouldBeSelected, srcThatShouldNotBeSelected],
maxImageWidth: 100,
};
const expected = srcThatShouldBeSelected;
const result = selectSrc(props);
expect(result).toEqual(expected);
});
it('Should use webp images if supported', () => {
const srcThatShouldBeSelected = {format: 'webp', width: 99};
const srcThatShouldNotBeSelected = {format: 'webp', width: 98};
const props = {
srcSet: [srcThatShouldBeSelected, srcThatShouldNotBeSelected],
supportsWebp: true,
maxImageWidth: 100,
};
const expected = srcThatShouldBeSelected;
const result = selectSrc(props);
expect(result).toEqual(expected);
});
});
/*
describe('fallbackParams', () => {
it('Should return an empty object when run in the browser environment', () => {
const result = fallbackParams({
srcSet: [
{
format: 'webp',
},
],
});
expect(result).toEqual({});
});
});
*/

View File

@ -0,0 +1,42 @@
/**
* @jest-environment node
*/
import {guessMaxImageWidth, fallbackParams} from '../components/helpers';
describe('guessMaxImageWidth', () => {
const expected = 0;
it(`Should return ${expected} when run in the node environment`, () => {
const result = guessMaxImageWidth({width: 100});
expect(result).toEqual(expected);
});
});
describe('FallbackParams', () => {
const props = {
srcSet: [
{
format: 'webp',
},
{
format: 'jpeg',
},
{
format: 'png',
},
],
getUrl: jest.fn(),
};
it('Should return an object when run in the node environment', () => {
const result = fallbackParams(props);
expect(result).not.toEqual({});
expect(props.getUrl).toHaveBeenCalledWith({
format: 'jpeg',
});
expect(props.getUrl).toHaveBeenCalledWith({
format: 'png',
});
expect(result.ssr).toEqual(true);
});
});

View File

@ -0,0 +1,36 @@
import React from 'react';
import renderer from 'react-test-renderer';
import Download from '../components/Icon/Download';
import Loading from '../components/Icon/Loading';
import Offline from '../components/Icon/Offline';
import Warning from '../components/Icon/Warning';
const snapshotTestDescription = 'Should render a snapshot that is good';
describe('Download icon', () => {
it(snapshotTestDescription, () => {
const download = renderer.create(<Download />).toJSON();
expect(download).toMatchSnapshot();
});
});
describe('Loading icon', () => {
it(snapshotTestDescription, () => {
const loading = renderer.create(<Loading />).toJSON();
expect(loading).toMatchSnapshot();
});
});
describe('Offline icon', () => {
it(snapshotTestDescription, () => {
const offline = renderer.create(<Offline />).toJSON();
expect(offline).toMatchSnapshot();
});
});
describe('Warning icon', () => {
it(snapshotTestDescription, () => {
const warning = renderer.create(<Warning />).toJSON();
expect(warning).toMatchSnapshot();
});
});

View File

@ -0,0 +1,15 @@
// This icon is from "material design icons"
// It is licensed under Apache License 2.0
// Full text is available here
// https://github.com/google/material-design-icons/blob/master/LICENSE
import React from 'react';
import Icon from './index';
const Download = (props) => (
<Icon
{...props}
path="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"
/>
);
export default Download;

View File

@ -0,0 +1,15 @@
// This icon is from "material design icons"
// It is licensed under Apache License 2.0
// Full text is available here
// https://github.com/google/material-design-icons/blob/master/LICENSE
import React from 'react';
import Icon from './index';
const Loading = (props) => (
<Icon
{...props}
path="M6,2V8H6V8L10,12L6,16V16H6V22H18V16H18V16L14,12L18,8V8H18V2H6M16,16.5V20H8V16.5L12,12.5L16,16.5M12,11.5L8,7.5V4H16V7.5L12,11.5Z"
/>
);
export default Loading;

View File

@ -0,0 +1,15 @@
// This icon is from "material design icons"
// It is licensed under Apache License 2.0
// Full text is available here
// https://github.com/google/material-design-icons/blob/master/LICENSE
import React from 'react';
import Icon from './index';
const Offline = (props) => (
<Icon
{...props}
path="M19.35 10.04C18.67 6.59 15.64 4 12 4c-1.48 0-2.85.43-4.01 1.17l1.46 1.46C10.21 6.23 11.08 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3 0 1.13-.64 2.11-1.56 2.62l1.45 1.45C23.16 18.16 24 16.68 24 15c0-2.64-2.05-4.78-4.65-4.96zM3 5.27l2.75 2.74C2.56 8.15 0 10.77 0 14c0 3.31 2.69 6 6 6h11.73l2 2L21 20.73 4.27 4 3 5.27zM7.73 10l8 8H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h1.73z"
/>
);
export default Offline;

View File

@ -0,0 +1,15 @@
// This icon is from "material design icons"
// It is licensed under Apache License 2.0
// Full text is available here
// https://github.com/google/material-design-icons/blob/master/LICENSE
import React from 'react';
import Icon from './index';
const Warning = (props) => (
<Icon
{...props}
path="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
/>
);
export default Warning;

View File

@ -0,0 +1,25 @@
import React from 'react';
// import PropTypes from 'prop-types'
const Icon = ({size = 24, fill = '#000', className, path}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
className={className}>
<path d="M0 0h24v24H0z" fill="none" />
<path fill={fill} d={path} />
</svg>
);
/*
Icon.propTypes = {
size: PropTypes.number,
fill: PropTypes.string,
className: PropTypes.string,
path: PropTypes.string.isRequired,
}
*/
export default Icon;

View File

@ -0,0 +1,352 @@
import React, {Component} from 'react';
import {Waypoint} from './waypoint';
import Media from '../Media';
import {icons, loadStates} from '../constants';
import {xhrLoader, imageLoader, timeout, combineCancel} from '../loaders';
import {
guessMaxImageWidth,
bytesToSize,
supportsWebp,
ssr,
nativeConnection,
selectSrc,
fallbackParams,
} from '../helpers';
const {initial, loading, loaded, error} = loadStates;
const defaultShouldAutoDownload = ({
connection,
size,
threshold,
possiblySlowNetwork,
}) => {
if (possiblySlowNetwork) return false;
if (!connection) return true;
const {downlink, rtt, effectiveType} = connection;
switch (effectiveType) {
case 'slow-2g':
case '2g':
return false;
case '3g':
if (downlink && size && threshold) {
return (size * 8) / (downlink * 1000) + rtt < threshold;
}
return false;
case '4g':
default:
return true;
}
};
const defaultGetMessage = (icon, state) => {
switch (icon) {
case icons.noicon:
case icons.loaded:
return null;
case icons.loading:
return 'Loading...';
case icons.load:
// we can show `alt` here
const {pickedSrc} = state;
const {size} = pickedSrc;
if (size) {
return [
'Click to load (',
<nobr key="nb">{bytesToSize(size)}</nobr>,
')',
];
} else {
return 'Click to load';
}
case icons.offline:
return 'Your browser is offline. Image not loaded';
case icons.error:
const {loadInfo} = state;
if (loadInfo === 404) {
return '404. Image not found';
} else {
return 'Error. Click to reload';
}
default:
throw new Error(`Wrong icon: ${icon}`);
}
};
const defaultGetIcon = (state) => {
const {loadState, onLine, overThreshold, userTriggered} = state;
if (ssr) return icons.noicon;
switch (loadState) {
case loaded:
return icons.loaded;
case loading:
return overThreshold ? icons.loading : icons.noicon;
case initial:
if (onLine) {
const {shouldAutoDownload} = state;
if (shouldAutoDownload === undefined) return icons.noicon;
return userTriggered || !shouldAutoDownload ? icons.load : icons.noicon;
} else {
return icons.offline;
}
case error:
return onLine ? icons.error : icons.offline;
default:
throw new Error(`Wrong state: ${loadState}`);
}
};
export default class IdealImage extends Component {
constructor(props) {
super(props);
// TODO: validate props.srcSet
this.state = {
loadState: initial,
connection: nativeConnection
? {
downlink: navigator.connection.downlink, // megabits per second
rtt: navigator.connection.rtt, // ms
effectiveType: navigator.connection.effectiveType, // 'slow-2g', '2g', '3g', or '4g'
}
: null,
onLine: true,
overThreshold: false,
inViewport: false,
userTriggered: false,
possiblySlowNetwork: false,
};
}
/*
static propTypes = {
/!** how much to wait in ms until concider download to slow *!/
threshold: PropTypes.number,
/!** function to generate src based on width and format *!/
getUrl: PropTypes.func,
/!** array of sources *!/
srcSet: PropTypes.arrayOf(
PropTypes.shape({
width: PropTypes.number.isRequired,
src: PropTypes.string,
size: PropTypes.number,
format: PropTypes.oneOf(['jpeg', 'jpg', 'webp', 'png', 'gif']),
}),
).isRequired,
/!** function which decides if image should be downloaded *!/
shouldAutoDownload: PropTypes.func,
/!** function which decides what message to show *!/
getMessage: PropTypes.func,
/!** function which decides what icon to show *!/
getIcon: PropTypes.func,
/!** type of loader *!/
loader: PropTypes.oneOf(['image', 'xhr']),
/!** Width of the image in px *!/
width: PropTypes.number.isRequired,
/!** Height of the image in px *!/
height: PropTypes.number.isRequired,
placeholder: PropTypes.oneOfType([
PropTypes.shape({
/!** Solid color placeholder *!/
color: PropTypes.string.isRequired,
}),
PropTypes.shape({
/!**
* [Low Quality Image Placeholder](https://github.com/zouhir/lqip)
* [SVG-Based Image Placeholder](https://github.com/technopagan/sqip)
* base64 encoded image of low quality
*!/
lqip: PropTypes.string.isRequired,
}),
]).isRequired,
/!** Map of icons *!/
icons: PropTypes.object.isRequired,
/!** theme object - CSS Modules or React styles *!/
theme: PropTypes.object.isRequired,
}*/
static defaultProps = {
shouldAutoDownload: defaultShouldAutoDownload,
getMessage: defaultGetMessage,
getIcon: defaultGetIcon,
loader: 'xhr',
};
componentDidMount() {
if (nativeConnection) {
this.updateConnection = () => {
if (!navigator.onLine) return;
if (this.state.loadState === initial) {
this.setState({
connection: {
effectiveType: navigator.connection.effectiveType,
downlink: navigator.connection.downlink,
rtt: navigator.connection.rtt,
},
});
}
};
navigator.connection.addEventListener('onchange', this.updateConnection);
} else if (this.props.threshold) {
this.possiblySlowNetworkListener = (e) => {
if (this.state.loadState !== initial) return;
const {possiblySlowNetwork} = e.detail;
if (!this.state.possiblySlowNetwork && possiblySlowNetwork) {
this.setState({possiblySlowNetwork});
}
};
window.document.addEventListener(
'possiblySlowNetwork',
this.possiblySlowNetworkListener,
);
}
this.updateOnlineStatus = () => this.setState({onLine: navigator.onLine});
this.updateOnlineStatus();
window.addEventListener('online', this.updateOnlineStatus);
window.addEventListener('offline', this.updateOnlineStatus);
}
componentWillUnmount() {
this.clear();
if (nativeConnection) {
navigator.connection.removeEventListener(
'onchange',
this.updateConnection,
);
} else if (this.props.threshold) {
window.document.removeEventListener(
'possiblySlowNetwork',
this.possiblySlowNetworkListener,
);
}
window.removeEventListener('online', this.updateOnlineStatus);
window.removeEventListener('offline', this.updateOnlineStatus);
}
onClick = () => {
const {loadState, onLine, overThreshold} = this.state;
if (!onLine) return;
switch (loadState) {
case loading:
if (overThreshold) this.cancel(true);
return;
case loaded:
// nothing
return;
case initial:
case error:
this.load(true);
return;
default:
throw new Error(`Wrong state: ${loadState}`);
}
};
clear() {
if (this.loader) {
this.loader.cancel();
this.loader = undefined;
}
}
cancel(userTriggered) {
if (loading !== this.state.loadState) return;
this.clear();
this.loadStateChange(initial, userTriggered);
}
loadStateChange(loadState, userTriggered, loadInfo = null) {
this.setState({
loadState,
overThreshold: false,
userTriggered: !!userTriggered,
loadInfo,
});
}
load = (userTriggered) => {
const {loadState, url} = this.state;
if (ssr || loaded === loadState || loading === loadState) return;
this.loadStateChange(loading, userTriggered);
const {threshold} = this.props;
const loader =
this.props.loader === 'xhr' ? xhrLoader(url) : imageLoader(url);
loader
.then(() => {
this.clear();
this.loadStateChange(loaded, false);
})
.catch((e) => {
this.clear();
if (e.status === 404) {
this.loadStateChange(error, false, 404);
} else {
this.loadStateChange(error, false);
}
});
if (threshold) {
const timeoutLoader = timeout(threshold);
timeoutLoader.then(() => {
if (!this.loader) return;
window.document.dispatchEvent(
new CustomEvent('possiblySlowNetwork', {
detail: {possiblySlowNetwork: true},
}),
);
this.setState({overThreshold: true});
if (!this.state.userTriggered) this.cancel(true);
});
this.loader = combineCancel(loader, timeoutLoader);
} else {
this.loader = loader;
}
};
onEnter = () => {
if (this.state.inViewport) return;
this.setState({inViewport: true});
const pickedSrc = selectSrc({
srcSet: this.props.srcSet,
maxImageWidth:
this.props.srcSet.length > 1
? guessMaxImageWidth(this.state.dimensions) // eslint-disable-line react/no-access-state-in-setstate
: 0,
supportsWebp,
});
const {getUrl} = this.props;
const url = getUrl ? getUrl(pickedSrc) : pickedSrc.src;
const shouldAutoDownload = this.props.shouldAutoDownload({
...this.state, // eslint-disable-line react/no-access-state-in-setstate
size: pickedSrc.size,
});
this.setState({pickedSrc, shouldAutoDownload, url}, () => {
if (shouldAutoDownload) this.load(false);
});
};
onLeave = () => {
if (this.state.loadState === loading && !this.state.userTriggered) {
this.setState({inViewport: false});
this.cancel(false);
}
};
render() {
const icon = this.props.getIcon(this.state);
const message = this.props.getMessage(icon, this.state);
return (
<Waypoint onEnter={this.onEnter} onLeave={this.onLeave}>
<Media
{...this.props}
{...fallbackParams(this.props)}
onClick={this.onClick}
icon={icon}
src={this.state.url || ''}
onDimensions={(dimensions) => this.setState({dimensions})}
message={message}
/>
</Waypoint>
);
}
}

View File

@ -0,0 +1,254 @@
/*
This is a slimmed down copy of https://github.com/civiccc/react-waypoint
The MIT License (MIT)
Copyright (c) 2015 Brigade
*/
import React, {createRef, ReactNode} from 'react';
type ScrollContainer = Window | HTMLElement;
function addEventListener(
element: ScrollContainer,
type: 'scroll' | 'resize',
listener: () => void,
options: AddEventListenerOptions,
) {
element.addEventListener(type, listener, options);
return () => element.removeEventListener(type, listener, options);
}
// Because waypoint may fire before the setState() updates due to batching
// queueMicrotask is a better option than setTimeout() or React.flushSync()
// See https://github.com/facebook/docusaurus/issues/11018
// See https://github.com/civiccc/react-waypoint/blob/0905ac5a073131147c96dd0694bd6f1b6ee8bc97/src/onNextTick.js
function subscribeMicrotask(callback: () => void) {
let subscribed = true;
queueMicrotask(() => {
if (subscribed) callback();
});
return () => (subscribed = false);
}
type Position = 'above' | 'inside' | 'below' | 'invisible';
type Props = {
topOffset: number;
bottomOffset: number;
onEnter: () => void;
onLeave: () => void;
children: ReactNode;
};
export function Waypoint(props: Props) {
return typeof window !== 'undefined' ? (
<WaypointClient {...props}>{props.children}</WaypointClient>
) : (
props.children
);
}
// TODO maybe replace this with IntersectionObserver later?
// IntersectionObserver doesn't support the "fast scroll" thing
// but it's probably not a big deal
class WaypointClient extends React.Component<Props> {
static defaultProps = {
topOffset: 0,
bottomOffset: 0,
onEnter() {},
onLeave() {},
};
scrollableAncestor?: ScrollContainer;
previousPosition: Position | null = null;
unsubscribe?: () => void;
innerRef = createRef<HTMLElement>();
override componentDidMount() {
this.scrollableAncestor = findScrollableAncestor(this.innerRef.current!);
const unsubscribeScroll = addEventListener(
this.scrollableAncestor!,
'scroll',
this._handleScroll,
{passive: true},
);
const unsubscribeResize = addEventListener(
window,
'resize',
this._handleScroll,
{passive: true},
);
const unsubscribeInitialScroll = subscribeMicrotask(() => {
this._handleScroll();
});
this.unsubscribe = () => {
unsubscribeScroll();
unsubscribeResize();
unsubscribeInitialScroll();
};
}
override componentDidUpdate() {
this._handleScroll();
}
override componentWillUnmount() {
this.unsubscribe?.();
}
_handleScroll = () => {
const node = this.innerRef.current;
const {topOffset, bottomOffset, onEnter, onLeave} = this.props;
const bounds = getBounds({
node: node!,
scrollableAncestor: this.scrollableAncestor!,
topOffset,
bottomOffset,
});
const currentPosition = getCurrentPosition(bounds);
const previousPosition = this.previousPosition;
this.previousPosition = currentPosition;
if (previousPosition === currentPosition) {
return;
}
if (currentPosition === 'inside') {
onEnter();
} else if (previousPosition === 'inside') {
onLeave();
}
const isRapidScrollDown =
previousPosition === 'below' && currentPosition === 'above';
const isRapidScrollUp =
previousPosition === 'above' && currentPosition === 'below';
if (isRapidScrollDown || isRapidScrollUp) {
onEnter();
onLeave();
}
};
override render() {
// @ts-expect-error: fix this implicit API
return React.cloneElement(this.props.children, {innerRef: this.innerRef});
}
}
/**
* Traverses up the DOM to find an ancestor container which has an overflow
* style that allows for scrolling.
*
* @return {Object} the closest ancestor element with an overflow style that
* allows for scrolling. If none is found, the `window` object is returned
* as a fallback.
*/
function findScrollableAncestor(inputNode: HTMLElement): ScrollContainer {
let node: HTMLElement = inputNode;
while (node.parentNode) {
// @ts-expect-error: it's fine
node = node.parentNode!;
if (node === document.body) {
// We've reached all the way to the root node.
return window;
}
const style = window.getComputedStyle(node);
const overflow =
style.getPropertyValue('overflow-y') ||
style.getPropertyValue('overflow');
if (
overflow === 'auto' ||
overflow === 'scroll' ||
overflow === 'overlay'
) {
return node;
}
}
// A scrollable ancestor element was not found, which means that we need to
// do stuff on window.
return window;
}
type Bounds = {
top: number;
bottom: number;
viewportTop: number;
viewportBottom: number;
};
function getBounds({
node,
scrollableAncestor,
topOffset,
bottomOffset,
}: {
node: Element;
scrollableAncestor: ScrollContainer;
topOffset: number;
bottomOffset: number;
}): Bounds {
const {top, bottom} = node.getBoundingClientRect();
let contextHeight;
let contextScrollTop;
if (scrollableAncestor === window) {
contextHeight = window.innerHeight;
contextScrollTop = 0;
} else {
const ancestorElement = scrollableAncestor as HTMLElement;
contextHeight = ancestorElement.offsetHeight;
contextScrollTop = ancestorElement.getBoundingClientRect().top;
}
const contextBottom = contextScrollTop + contextHeight;
return {
top,
bottom,
viewportTop: contextScrollTop + topOffset,
viewportBottom: contextBottom - bottomOffset,
};
}
function getCurrentPosition(bounds: Bounds): Position {
if (bounds.viewportBottom - bounds.viewportTop === 0) {
return 'invisible';
}
// top is within the viewport
if (bounds.viewportTop <= bounds.top && bounds.top <= bounds.viewportBottom) {
return 'inside';
}
// bottom is within the viewport
if (
bounds.viewportTop <= bounds.bottom &&
bounds.bottom <= bounds.viewportBottom
) {
return 'inside';
}
// top is above the viewport and bottom is below the viewport
if (
bounds.top <= bounds.viewportTop &&
bounds.viewportBottom <= bounds.bottom
) {
return 'inside';
}
if (bounds.viewportBottom < bounds.top) {
return 'below';
}
if (bounds.top < bounds.viewportTop) {
return 'above';
}
return 'invisible';
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import IdealImage from '../IdealImage';
import icons from '../icons';
import theme from '../theme';
const IdealImageWithDefaults = ({
icons: iconsProp = icons,
theme: themeProp = theme,
...props
}) => <IdealImage {...props} icons={iconsProp} theme={themeProp} />;
export default IdealImageWithDefaults;

View File

@ -0,0 +1,169 @@
import React, {PureComponent} from 'react';
// import PropTypes from 'prop-types'
import compose from '../composeStyle';
import {icons as defaultIcons} from '../constants';
const {load, loading, loaded, error, noicon, offline} = defaultIcons;
export default class Media extends PureComponent {
/*static propTypes = {
/!** URL of the image *!/
src: PropTypes.string.isRequired,
/!** Width of the image in px *!/
width: PropTypes.number.isRequired,
/!** Height of the image in px *!/
height: PropTypes.number.isRequired,
placeholder: PropTypes.oneOfType([
PropTypes.shape({
/!** Solid color placeholder *!/
color: PropTypes.string.isRequired,
}),
PropTypes.shape({
/!**
* [Low Quality Image Placeholder](https://github.com/zouhir/lqip)
* [SVG-Based Image Placeholder](https://github.com/technopagan/sqip)
* base64 encoded image of low quality
*!/
lqip: PropTypes.string.isRequired,
}),
]).isRequired,
/!** display icon *!/
icon: PropTypes.oneOf([load, loading, loaded, error, noicon, offline])
.isRequired,
/!** Map of icons *!/
icons: PropTypes.object.isRequired,
/!** theme object - CSS Modules or React styles *!/
theme: PropTypes.object.isRequired,
/!** Alternative text *!/
alt: PropTypes.string,
/!** Color of the icon *!/
iconColor: PropTypes.string,
/!** Size of the icon in px *!/
iconSize: PropTypes.number,
/!** React's style attribute for root element of the component *!/
style: PropTypes.object,
/!** React's className attribute for root element of the component *!/
className: PropTypes.string,
/!** On click handler *!/
onClick: PropTypes.func,
/!** callback to get dimensions of the placeholder *!/
onDimensions: PropTypes.func,
/!** message to show below the icon *!/
message: PropTypes.node,
/!** reference for Waypoint *!/
innerRef: PropTypes.func,
/!** noscript image src *!/
nsSrc: PropTypes.string,
/!** noscript image srcSet *!/
nsSrcSet: PropTypes.string,
}*/
static defaultProps = {
iconColor: '#fff',
iconSize: 64,
};
constructor(props) {
super(props);
this.state = {isMounted: false};
}
componentDidMount() {
this.setState({isMounted: true});
if (this.props.onDimensions && this.dimensionElement)
/* Firefox returns 0 for both clientWidth and clientHeight.
To fix this we can check the parentNode's clientWidth and clientHeight as a fallback. */
this.props.onDimensions({
width:
this.dimensionElement.clientWidth ||
this.dimensionElement.parentNode.clientWidth,
height:
this.dimensionElement.clientHeight ||
this.dimensionElement.parentNode.clientHeight,
});
}
renderIcon(props) {
const {icon, icons, iconColor: fill, iconSize: size, theme} = props;
const iconToRender = icons[icon];
if (!iconToRender) return null;
const styleOrClass = compose(
{width: size + 100, height: size, color: fill},
theme.icon,
);
return React.createElement('div', styleOrClass, [
React.createElement(iconToRender, {fill, size, key: 'icon'}),
React.createElement('br', {key: 'br'}),
this.props.message,
]);
}
renderImage(props) {
return props.icon === loaded ? (
<img
{...compose(props.theme.img)}
src={props.src}
alt={props.alt}
width={props.width}
height={props.height}
/>
) : (
<svg
{...compose(props.theme.img)}
width={props.width}
height={props.height}
ref={(ref) => (this.dimensionElement = ref)}
/>
);
}
renderNoscript(props) {
// render noscript in ssr + hydration to avoid hydration mismatch error
return this.state.isMounted ? null : (
<noscript>
<img
{...compose(props.theme.img, props.theme.noscript)}
src={props.nsSrc}
srcSet={props.nsSrcSet}
alt={props.alt}
width={props.width}
height={props.height}
/>
</noscript>
);
}
render() {
const props = this.props;
const {placeholder, theme} = props;
let background;
if (props.icon === loaded) {
background = {};
} else if (placeholder.lqip) {
background = {
backgroundImage: `url("${placeholder.lqip}")`,
};
} else {
background = {
backgroundColor: placeholder.color,
};
}
return (
<div
{...compose(
theme.placeholder,
background,
props.style,
props.className,
)}
onClick={this.props.onClick}
onKeyPress={this.props.onClick}
ref={this.props.innerRef}>
{this.renderImage(props)}
{this.renderNoscript(props)}
{this.renderIcon(props)}
</div>
);
}
}

View File

@ -0,0 +1,89 @@
All possible states of the component
```js
const lqip =
'data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAA4DASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUG/8QAIRAAAQQDAAEFAAAAAAAAAAAAAQIDBREABAYhEjEyQVH/xAAUAQEAAAAAAAAAAAAAAAAAAAAE/8QAGBEBAAMBAAAAAAAAAAAAAAAAAQACIRH/2gAMAwEAAhEDEQA/AMJ2DG+7Dw0nz8gsx+uyhlxnWdLakOlfzpIF3aRf1WT5t96P5+N1ug9Tu7ZWS8q1gG6B8H2FDz+YxhjUrEOdZ//Z';
const sqip =
"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4774 3024'%3e%3cfilter id='b'%3e%3cfeGaussianBlur stdDeviation='12' /%3e%3c/filter%3e%3cpath fill='%23515a57' d='M0 0h4774v3021H0z'/%3e%3cg filter='url(%23b)' transform='translate(9.3 9.3) scale(18.64844)' fill-opacity='.5'%3e%3cellipse fill='whitefef' rx='1' ry='1' transform='matrix(74.55002 60.89891 -21.7939 26.67923 151.8 104.4)'/%3e%3cellipse fill='black80c' cx='216' cy='49' rx='59' ry='59'/%3e%3cellipse fill='black60e' cx='22' cy='60' rx='46' ry='89'/%3e%3cellipse fill='%23ffebd5' cx='110' cy='66' rx='42' ry='28'/%3e%3cellipse fill='whiteff9' rx='1' ry='1' transform='rotate(33.3 -113.2 392.6) scale(42.337 17.49703)'/%3e%3cellipse fill='%23031f1e' rx='1' ry='1' transform='matrix(163.4651 -64.93326 6.77862 17.06471 111 16.4)'/%3e%3cpath fill='whitefea' d='M66 74l9 39 16-44z'/%3e%3cellipse fill='%23a28364' rx='1' ry='1' transform='rotate(-32.4 253.2 -179) scale(30.79511 43.65381)'/%3e%3cpath fill='%231a232c' d='M40 139l61-57 33 95z'/%3e%3cpath fill='%230a222b' d='M249.8 153.3l-48.1-48 32.5-32.6 48.1 48z'/%3e%3c/g%3e%3c/svg%3e";
<table>
<tbody>
<tr>
<th align="left" width="100">
load
</th>
<td>
<MediaWithDefaults
width={3500}
height={2095}
placeholder={{lqip: lqip}}
src="andre-spieker-238-unsplash.jpg"
style={{maxWidth: 200}}
icon={'load'}
/>
</td>
<th align="left" width="100">
noicon
</th>
<td>
<MediaWithDefaults
width={3500}
height={2095}
placeholder={{lqip: lqip}}
src="andre-spieker-238-unsplash.jpg"
style={{maxWidth: 200}}
icon={'noicon'}
/>
</td>
</tr>
<tr>
<th align="left">loading</th>
<td>
<MediaWithDefaults
width={3500}
height={2095}
placeholder={{lqip: lqip}}
src="andre-spieker-238-unsplash.jpg"
style={{maxWidth: 200}}
icon={'loading'}
/>
</td>
<th align="left">offline</th>
<td>
<MediaWithDefaults
width={3500}
height={2095}
placeholder={{lqip: lqip}}
src="andre-spieker-238-unsplash.jpg"
style={{maxWidth: 200}}
icon={'offline'}
/>
</td>
</tr>
<tr>
<th align="left">loaded</th>
<td>
<MediaWithDefaults
width={3500}
height={2095}
placeholder={{lqip: lqip}}
src="andre-spieker-238-unsplash.jpg"
style={{maxWidth: 200}}
icon={'loaded'}
/>
</td>
<th align="left">error</th>
<td>
<MediaWithDefaults
width={3500}
height={2095}
placeholder={{lqip: lqip}}
src="andre-spieker-238-unsplash.jpg"
style={{maxWidth: 200}}
icon={'error'}
/>
</td>
</tr>
</tbody>
</table>;
```

View File

@ -0,0 +1,12 @@
import React from 'react';
import Media from '../Media';
import icons from '../icons';
import theme from '../theme';
const MediaWithDefaults = ({
icons: iconsProp = icons,
theme: themeProp = theme,
...props
}) => <Media {...props} icons={iconsProp} theme={themeProp} />;
export default MediaWithDefaults;

View File

@ -0,0 +1,37 @@
/**
* Composes styles and/or classes
*
* For classes it will concat them in in one string
* and return as `className` property.
* Alternative is https://github.com/JedWatson/classnames
*
* For objects it will merge them in one object
* and return as `style` property.
*
* Usage:
* Asume you have `theme` object, which can be css-module
* or object or other css-in-js compatible with css-module
*
* <a {...compose(theme.link, theme.active, {color: "#000"})}>link</a>
*
* @returns {{className: string, style: object}} - params for React component
*/
export default (...stylesOrClasses) => {
const classes = [];
let style;
for (const obj of stylesOrClasses) {
if (obj instanceof Object) {
Object.assign(style || (style = {}), obj);
} else if (obj === undefined || obj === false) {
// ignore
} else if (typeof obj === 'string') {
classes.push(obj);
} else {
throw new Error(`Unexpected value ${obj}`);
}
}
return {
className: classes.length > 1 ? classes.join(' ') : classes[0],
style,
};
};

View File

@ -0,0 +1,24 @@
const load = 'load';
const loading = 'loading';
const loaded = 'loaded';
const error = 'error';
const noicon = 'noicon';
const offline = 'offline';
export const icons = {
load,
loading,
loaded,
error,
noicon,
offline,
};
const initial = 'initial';
export const loadStates = {
initial,
loading,
loaded,
error,
};

View File

@ -0,0 +1,142 @@
export const ssr =
typeof window === 'undefined' || window.navigator.userAgent === 'ReactSnap';
export const nativeConnection = !ssr && !!window.navigator.connection;
// export const getScreenWidth = () => {
// if (ssr) return 0
// const devicePixelRatio = window.devicePixelRatio || 1
// const {screen} = window
// const {width} = screen
// // const angle = (screen.orientation && screen.orientation.angle) || 0
// // return Math.max(width, height)
// // const rotated = Math.floor(angle / 90) % 2 !== 0
// // return (rotated ? height : width) * devicePixelRatio
// return width * devicePixelRatio
// }
// export const screenWidth = getScreenWidth()
export const guessMaxImageWidth = (dimensions, w) => {
if (ssr) return 0;
// Default to window object but don't use window as a default
// parameter so that this can be used on the server as well
if (!w) {
w = window;
}
const imgWidth = dimensions.width;
const {screen} = w;
const sWidth = screen.width;
const sHeight = screen.height;
const {documentElement} = document;
const windowWidth = w.innerWidth || documentElement.clientWidth;
const windowHeight = w.innerHeight || documentElement.clientHeight;
const devicePixelRatio = w.devicePixelRatio || 1;
const windowResized = sWidth > windowWidth;
let result;
if (windowResized) {
const body = document.getElementsByTagName('body')[0];
const scrollWidth = windowWidth - imgWidth;
const isScroll =
body.clientHeight > windowHeight || body.clientHeight > sHeight;
if (isScroll && scrollWidth <= 15) {
result = sWidth - scrollWidth;
} else {
result = (imgWidth / windowWidth) * sWidth;
}
} else {
result = imgWidth;
}
return result * devicePixelRatio;
};
export const bytesToSize = (bytes) => {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return 'n/a';
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10);
if (i === 0) return `${bytes} ${sizes[i]}`;
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`;
};
// async function supportsWebp() {
// if (typeof createImageBitmap === 'undefined' || typeof fetch === 'undefined')
// return false
// return fetch(
// 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=',
// )
// .then(response => response.blob())
// .then(blob => createImageBitmap(blob).then(() => true, () => false))
// }
// let webp = undefined
// const webpPromise = supportsWebp()
// webpPromise.then(x => (webp = x))
// export default () => {
// if (webp === undefined) return webpPromise
// return {
// then: callback => callback(webp),
// }
// }
const detectWebpSupport = () => {
if (ssr) return false;
const elem = document.createElement('canvas');
if (elem.getContext && elem.getContext('2d')) {
// was able or not to get WebP representation
return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0;
} else {
// very old browser like IE 8, canvas not supported
return false;
}
};
export const supportsWebp = detectWebpSupport();
const isWebp = (x) =>
x.format === 'webp' || (x.src && x.src.match(/\.webp($|\?.*)/i));
// eslint-disable-next-line no-shadow
export const selectSrc = ({srcSet, maxImageWidth, supportsWebp}) => {
if (srcSet.length === 0) throw new Error('Need at least one item in srcSet');
let supportedFormat, width;
if (supportsWebp) {
supportedFormat = srcSet.filter(isWebp);
if (supportedFormat.length === 0) supportedFormat = srcSet;
} else {
supportedFormat = srcSet.filter((x) => !isWebp(x));
if (supportedFormat.length === 0)
throw new Error('Need at least one supported format item in srcSet');
}
let widths = supportedFormat.filter((x) => x.width >= maxImageWidth);
if (widths.length === 0) {
widths = supportedFormat;
width = Math.max.apply(
null,
widths.map((x) => x.width),
);
} else {
width = Math.min.apply(
null,
widths.map((x) => x.width),
);
}
return supportedFormat.filter((x) => x.width === width)[0];
};
export const fallbackParams = ({srcSet, getUrl}) => {
if (!ssr) return {};
const notWebp = srcSet.filter((x) => !isWebp(x));
const first = notWebp[0];
return {
nsSrcSet: notWebp
.map((x) => `${getUrl ? getUrl(x) : x.src} ${x.width}w`)
.join(','),
nsSrc: getUrl ? getUrl(first) : first.src,
ssr,
};
};

View File

@ -0,0 +1,16 @@
import DownloadIcon from './Icon/Download';
import OfflineIcon from './Icon/Offline';
import WarningIcon from './Icon/Warning';
import LoadingIcon from './Icon/Loading';
import {icons} from './constants';
const {load, loading, loaded, error, noicon, offline} = icons;
export default {
[load]: DownloadIcon,
[loading]: LoadingIcon,
[loaded]: null,
[error]: WarningIcon,
[noicon]: null,
[offline]: OfflineIcon,
};

View File

@ -0,0 +1,112 @@
// There is an issue with cancelable interface
// It is not obvious that
// `image(src)` has `cancel` function
// but `image(src).then()` doesn't
import {unfetch, UnfetchAbortController} from './unfetch';
/**
* returns new "promise" with cancel function combined
*
* @param {Promise} p1 - first "promise" with cancel
* @param {Promise} p2 - second "promise" with cancel
* @returns {Promise} - new "promise" with cancel
*/
export const combineCancel = (p1, p2) => {
if (!p2) return p1;
const result = p1.then(
(x) => x,
(x) => x,
);
result.cancel = () => {
p1.cancel();
p2.cancel();
};
return result;
};
export const timeout = (threshold) => {
let timerId;
const result = new Promise((resolve) => {
timerId = setTimeout(resolve, threshold);
});
result.cancel = () => {
// there is a bug with cancel somewhere in the code
// if (!timerId) throw new Error('Already canceled')
clearTimeout(timerId);
timerId = undefined;
};
return result;
};
// Caveat: image loader can not cancel download in some browsers
export const imageLoader = (src) => {
let img = new Image();
const result = new Promise((resolve, reject) => {
img.onload = resolve;
// eslint-disable-next-line no-multi-assign
img.onabort = img.onerror = () => reject({});
img.src = src;
});
result.cancel = () => {
if (!img) throw new Error('Already canceled');
// eslint-disable-next-line no-multi-assign
img.onload = img.onabort = img.onerror = undefined;
img.src = '';
img = undefined;
};
return result;
};
// Caveat: XHR loader can cause errors because of 'Access-Control-Allow-Origin'
// Caveat: we still need imageLoader to do actual decoding,
// but if images are uncachable this will lead to two requests
export const xhrLoader = (url, options) => {
let controller = new UnfetchAbortController();
const signal = controller.signal;
const result = new Promise((resolve, reject) =>
unfetch(url, {...options, signal}).then((response) => {
if (response.ok) {
response
.blob()
.then(() => imageLoader(url))
.then(resolve);
} else {
reject({status: response.status});
}
}, reject),
);
result.cancel = () => {
if (!controller) throw new Error('Already canceled');
controller.abort();
controller = undefined;
};
return result;
};
// Caveat: AbortController only supported in Chrome 66+
// Caveat: we still need imageLoader to do actual decoding,
// but if images are uncachable this will lead to two requests
// export const fetchLoader = (url, options) => {
// let controller = new AbortController()
// const signal = controller.signal
// const result = new Promise((resolve, reject) =>
// fetch(url, {...options, signal}).then(response => {
// if (response.ok) {
// options && options.onMeta && options.onMeta(response.headers)
// response
// .blob()
// .then(() => imageLoader(url))
// .then(resolve)
// } else {
// reject({status: response.status})
// }
// }, reject),
// )
// result.cancel = () => {
// if (!controller) throw new Error('Already canceled')
// controller.abort()
// controller = undefined
// }
// return result
// }

View File

@ -0,0 +1,26 @@
export default {
placeholder: {
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
position: 'relative',
},
img: {
width: '100%',
height: 'auto',
maxWidth: '100%',
/* TODO: fix bug in styles */
marginBottom: '-4px',
},
icon: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
},
noscript: {
position: 'absolute',
top: 0,
left: 0,
},
};

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.placeholder {
background-size: cover;
background-repeat: no-repeat;
position: relative;
}
.img {
width: 100%;
height: auto;
max-width: 100%;
/* TODO: fix bug in styles */
margin-bottom: -4px;
transform: translate3d(0, 0, 0);
}
.icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.noscript {
position: absolute;
top: 0;
left: 0;
}

View File

@ -0,0 +1,74 @@
export class UnfetchAbortController {
constructor() {
this.signal = {onabort: () => {}};
this.abort = () => this.signal.onabort();
}
}
// modified version of https://github.com/developit/unfetch
// - ponyfill intead of polyfill
// - add support for AbortController
export const unfetch = (url, options) => {
options = options || {};
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open(options.method || 'get', url, true);
// eslint-disable-next-line guard-for-in
for (const i in options.headers) {
request.setRequestHeader(i, options.headers[i]);
}
request.withCredentials = options.credentials === 'include';
request.onload = () => {
resolve(response());
};
request.onerror = reject;
if (options.signal)
options.signal.onabort = () => {
// eslint-disable-next-line no-multi-assign
request.onerror = request.onload = undefined;
request.abort();
};
request.send(options.body);
function response() {
const keys = [];
const all = [];
const headers = {};
let header;
request
.getAllResponseHeaders()
.replace(/^(.*?):\s*?([\s\S]*?)$/gm, (m, key, value) => {
keys.push((key = key.toLowerCase()));
all.push([key, value]);
header = headers[key];
headers[key] = header ? `${header},${value}` : value;
});
return {
// eslint-disable-next-line no-bitwise
ok: ((request.status / 100) | 0) === 2, // 200-299
status: request.status,
statusText: request.statusText,
url: request.responseURL,
clone: response,
text: () => Promise.resolve(request.responseText),
json: () => Promise.resolve(request.responseText).then(JSON.parse),
blob: () => Promise.resolve(new Blob([request.response])),
headers: {
keys: () => keys,
entries: () => all,
get: (n) => headers[n.toLowerCase()],
has: (n) => n.toLowerCase() in headers,
},
};
}
});
};

View File

@ -0,0 +1,3 @@
import IdealImageWithDefaults from './components/IdealImageWithDefaults';
export default IdealImageWithDefaults;

View File

@ -159,7 +159,7 @@ export default function getSwizzleConfig(): SwizzleConfig {
'CodeBlock/Content': {
actions: {
eject: 'unsafe',
wrap: 'forbidden',
wrap: 'unsafe',
},
description:
'The folder containing components responsible for rendering different types of CodeBlock content.',

View File

@ -410,7 +410,7 @@ declare module '@theme/CodeBlock' {
readonly children: ReactNode;
readonly className?: string;
readonly metastring?: string;
readonly title?: string;
readonly title?: ReactNode;
readonly language?: string;
readonly showLineNumbers?: boolean | number;
}
@ -426,17 +426,76 @@ declare module '@theme/CodeInline' {
export default function CodeInline(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/CopyButton' {
declare module '@theme/CodeBlock/Provider' {
import type {ReactNode} from 'react';
export interface Props {
readonly code: string;
readonly children: ReactNode;
}
export default function CodeBlockProvider(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Title' {
import type {ReactNode} from 'react';
export interface Props {
readonly children: ReactNode;
}
export default function CodeBlockTitle(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Layout' {
import type {ReactNode} from 'react';
export interface Props {
readonly className?: string;
}
export default function CodeBlockLayout(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Buttons' {
import type {ReactNode} from 'react';
export interface Props {
readonly className?: string;
}
export default function CodeBlockButtons(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Buttons/Button' {
import type {ComponentProps, ReactNode} from 'react';
export interface Props extends ComponentProps<'button'> {
readonly className?: string;
}
export default function CopyButton(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Buttons/CopyButton' {
import type {ReactNode} from 'react';
export interface Props {
readonly className?: string;
}
export default function CodeBlockButtonCopy(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Buttons/WordWrapButton' {
import type {ReactNode} from 'react';
export interface Props {
readonly className?: string;
}
export default function CodeBlockButtonWordWrap(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Container' {
import type {ReactNode} from 'react';
import type {ComponentProps} from 'react';
@ -447,13 +506,23 @@ declare module '@theme/CodeBlock/Container' {
}: {as: T} & ComponentProps<T>): ReactNode;
}
declare module '@theme/CodeBlock/Content' {
import type {ReactNode} from 'react';
export interface Props {
className?: string;
}
export default function CodeBlockContent(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Content/Element' {
import type {ReactNode} from 'react';
import type {Props} from '@theme/CodeBlock';
export type {Props};
export default function CodeBlockElementContent(props: Props): ReactNode;
export default function CodeBlockContentElement(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Content/String' {
@ -464,7 +533,7 @@ declare module '@theme/CodeBlock/Content/String' {
readonly children: string;
}
export default function CodeBlockStringContent(props: Props): ReactNode;
export default function CodeBlockContentString(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/Line' {
@ -488,16 +557,16 @@ declare module '@theme/CodeBlock/Line' {
export default function CodeBlockLine(props: Props): ReactNode;
}
declare module '@theme/CodeBlock/WordWrapButton' {
declare module '@theme/CodeBlock/Line/Token' {
import type {ReactNode} from 'react';
import type {Token, TokenOutputProps} from 'prism-react-renderer';
export interface Props {
readonly className?: string;
readonly onClick: React.MouseEventHandler;
readonly isEnabled: boolean;
export interface Props extends TokenOutputProps {
readonly token: Token;
readonly line: Token[];
}
export default function WordWrapButton(props: Props): ReactNode;
export default function CodeBlockLine(props: Props): ReactNode;
}
declare module '@theme/DocCard' {
@ -1181,19 +1250,40 @@ declare module '@theme/NavbarItem/DefaultNavbarItem' {
import type {ReactNode} from 'react';
import type {Props as NavbarNavLinkProps} from '@theme/NavbarItem/NavbarNavLink';
export type DesktopOrMobileNavBarItemProps = NavbarNavLinkProps & {
export type DefaultNavbarItemProps = NavbarNavLinkProps & {
readonly isDropdownItem?: boolean;
readonly className?: string;
readonly position?: 'left' | 'right';
};
export interface Props extends DesktopOrMobileNavBarItemProps {
// TODO Docusaurus v4, remove old type name
export type DesktopOrMobileNavBarItemProps = DefaultNavbarItemProps;
export interface Props extends DefaultNavbarItemProps {
readonly mobile?: boolean;
}
export default function DefaultNavbarItem(props: Props): ReactNode;
}
declare module '@theme/NavbarItem/DefaultNavbarItem/Mobile' {
import type {ReactNode} from 'react';
import type {DefaultNavbarItemProps} from '@theme/NavbarItem/DefaultNavbarItem';
export interface Props extends DefaultNavbarItemProps {}
export default function DefaultNavbarItemMobile(props: Props): ReactNode;
}
declare module '@theme/NavbarItem/DefaultNavbarItem/Desktop' {
import type {ReactNode} from 'react';
import type {DefaultNavbarItemProps} from '@theme/NavbarItem/DefaultNavbarItem';
export interface Props extends DefaultNavbarItemProps {}
export default function DefaultNavbarItemDesktop(props: Props): ReactNode;
}
declare module '@theme/NavbarItem/NavbarNavLink' {
import type {ReactNode} from 'react';
import type {Props as LinkProps} from '@docusaurus/Link';
@ -1216,19 +1306,40 @@ declare module '@theme/NavbarItem/DropdownNavbarItem' {
import type {Props as NavbarNavLinkProps} from '@theme/NavbarItem/NavbarNavLink';
import type {LinkLikeNavbarItemProps} from '@theme/NavbarItem';
export type DesktopOrMobileNavBarItemProps = NavbarNavLinkProps & {
export type DropdownNavbarItemProps = NavbarNavLinkProps & {
readonly position?: 'left' | 'right';
readonly items: readonly LinkLikeNavbarItemProps[];
readonly className?: string;
};
export interface Props extends DesktopOrMobileNavBarItemProps {
// TODO Docusaurus v4, remove old type name
export type DesktopOrMobileNavBarItemProps = DropdownNavbarItemProps;
export interface Props extends DropdownNavbarItemProps {
readonly mobile?: boolean;
}
export default function DropdownNavbarItem(props: Props): ReactNode;
}
declare module '@theme/NavbarItem/DropdownNavbarItem/Mobile' {
import type {ReactNode} from 'react';
import type {DropdownNavbarItemProps} from '@theme/NavbarItem/DropdownNavbarItem';
export interface Props extends DropdownNavbarItemProps {}
export default function DropdownNavbarItemMobile(props: Props): ReactNode;
}
declare module '@theme/NavbarItem/DropdownNavbarItem/Desktop' {
import type {ReactNode} from 'react';
import type {DropdownNavbarItemProps} from '@theme/NavbarItem/DropdownNavbarItem';
export interface Props extends DropdownNavbarItemProps {}
export default function DropdownNavbarItemDesktop(props: Props): ReactNode;
}
declare module '@theme/NavbarItem/SearchNavbarItem' {
import type {ReactNode} from 'react';

View File

@ -0,0 +1,19 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import type {Props} from '@theme/CodeBlock/Buttons/Button';
export default function CodeBlockButton({
className,
...props
}: Props): ReactNode {
return (
<button type="button" {...props} className={clsx('clean-btn', className)} />
);
}

View File

@ -15,16 +15,44 @@ import React, {
import clsx from 'clsx';
import copy from 'copy-text-to-clipboard';
import {translate} from '@docusaurus/Translate';
import type {Props} from '@theme/CodeBlock/CopyButton';
import {useCodeBlockContext} from '@docusaurus/theme-common/internal';
import Button from '@theme/CodeBlock/Buttons/Button';
import type {Props} from '@theme/CodeBlock/Buttons/CopyButton';
import IconCopy from '@theme/Icon/Copy';
import IconSuccess from '@theme/Icon/Success';
import styles from './styles.module.css';
export default function CopyButton({code, className}: Props): ReactNode {
function title() {
return translate({
id: 'theme.CodeBlock.copy',
message: 'Copy',
description: 'The copy button label on code blocks',
});
}
function ariaLabel(isCopied: boolean) {
return isCopied
? translate({
id: 'theme.CodeBlock.copied',
message: 'Copied',
description: 'The copied button label on code blocks',
})
: translate({
id: 'theme.CodeBlock.copyButtonAriaLabel',
message: 'Copy code to clipboard',
description: 'The ARIA label for copy code blocks button',
});
}
function useCopyButton() {
const {
metadata: {code},
} = useCodeBlockContext();
const [isCopied, setIsCopied] = useState(false);
const copyTimeout = useRef<number | undefined>(undefined);
const handleCopyCode = useCallback(() => {
const copyCode = useCallback(() => {
copy(code);
setIsCopied(true);
copyTimeout.current = window.setTimeout(() => {
@ -34,38 +62,26 @@ export default function CopyButton({code, className}: Props): ReactNode {
useEffect(() => () => window.clearTimeout(copyTimeout.current), []);
return {copyCode, isCopied};
}
export default function CopyButton({className}: Props): ReactNode {
const {copyCode, isCopied} = useCopyButton();
return (
<button
type="button"
aria-label={
isCopied
? translate({
id: 'theme.CodeBlock.copied',
message: 'Copied',
description: 'The copied button label on code blocks',
})
: translate({
id: 'theme.CodeBlock.copyButtonAriaLabel',
message: 'Copy code to clipboard',
description: 'The ARIA label for copy code blocks button',
})
}
title={translate({
id: 'theme.CodeBlock.copy',
message: 'Copy',
description: 'The copy button label on code blocks',
})}
<Button
aria-label={ariaLabel(isCopied)}
title={title()}
className={clsx(
'clean-btn',
className,
styles.copyButton,
isCopied && styles.copyButtonCopied,
)}
onClick={handleCopyCode}>
onClick={copyCode}>
<span className={styles.copyButtonIcons} aria-hidden="true">
<IconCopy className={styles.copyButtonIcon} />
<IconSuccess className={styles.copyButtonSuccessIcon} />
</span>
</button>
</Button>
);
}

View File

@ -8,16 +8,21 @@
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import {translate} from '@docusaurus/Translate';
import type {Props} from '@theme/CodeBlock/WordWrapButton';
import {useCodeBlockContext} from '@docusaurus/theme-common/internal';
import Button from '@theme/CodeBlock/Buttons/Button';
import type {Props} from '@theme/CodeBlock/Buttons/WordWrapButton';
import IconWordWrap from '@theme/Icon/WordWrap';
import styles from './styles.module.css';
export default function WordWrapButton({
className,
onClick,
isEnabled,
}: Props): ReactNode {
export default function WordWrapButton({className}: Props): ReactNode {
const {wordWrap} = useCodeBlockContext();
const canShowButton = wordWrap.isEnabled || wordWrap.isCodeScrollable;
if (!canShowButton) {
return false;
}
const title = translate({
id: 'theme.CodeBlock.wordWrapToggle',
message: 'Toggle word wrap',
@ -26,17 +31,15 @@ export default function WordWrapButton({
});
return (
<button
type="button"
onClick={onClick}
<Button
onClick={() => wordWrap.toggle()}
className={clsx(
'clean-btn',
className,
isEnabled && styles.wordWrapButtonEnabled,
wordWrap.isEnabled && styles.wordWrapButtonEnabled,
)}
aria-label={title}
title={title}>
<IconWordWrap className={styles.wordWrapButtonIcon} aria-hidden="true" />
</button>
</Button>
);
}

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import BrowserOnly from '@docusaurus/BrowserOnly';
import CopyButton from '@theme/CodeBlock/Buttons/CopyButton';
import WordWrapButton from '@theme/CodeBlock/Buttons/WordWrapButton';
import type {Props} from '@theme/CodeBlock/Buttons';
import styles from './styles.module.css';
// Code block buttons are not server-rendered on purpose
// Adding them to the initial HTML is useless and expensive (due to JSX SVG)
// They are hidden by default and require React to become interactive
export default function CodeBlockButtons({className}: Props): ReactNode {
return (
<BrowserOnly>
{() => (
<div className={clsx(className, styles.buttonGroup)}>
<WordWrapButton />
<CopyButton />
</div>
)}
</BrowserOnly>
);
}

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.buttonGroup {
display: flex;
column-gap: 0.2rem;
position: absolute;
/* rtl:ignore */
right: calc(var(--ifm-pre-padding) / 2);
top: calc(var(--ifm-pre-padding) / 2);
}
.buttonGroup button {
display: flex;
align-items: center;
background: var(--prism-background-color);
color: var(--prism-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
padding: 0.4rem;
line-height: 0;
transition: opacity var(--ifm-transition-fast) ease-in-out;
opacity: 0;
}
.buttonGroup button:focus-visible,
.buttonGroup button:hover {
opacity: 1 !important;
}
:global(.theme-code-block:hover) .buttonGroup button {
opacity: 0.4;
}

View File

@ -12,8 +12,10 @@ import type {Props} from '@theme/CodeBlock/Content/Element';
import styles from './styles.module.css';
// <pre> tags in markdown map to CodeBlocks. They may contain JSX children. When
// the children is not a simple string, we just return a styled block without
// TODO Docusaurus v4: move this component at the root?
// This component only handles a rare edge-case: <pre><MyComp/></pre> in MDX
// <pre> tags in markdown map to CodeBlocks. They may contain JSX children.
// When children is not a simple string, we just return a styled block without
// actually highlighting.
export default function CodeBlockJSX({children, className}: Props): ReactNode {
return (

View File

@ -6,126 +6,37 @@
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import {useThemeConfig, usePrismTheme} from '@docusaurus/theme-common';
import {useThemeConfig} from '@docusaurus/theme-common';
import {
parseCodeBlockTitle,
parseLanguage,
parseLines,
getLineNumbersStart,
CodeBlockContextProvider,
type CodeBlockMetadata,
createCodeBlockMetadata,
useCodeWordWrap,
} from '@docusaurus/theme-common/internal';
import useIsBrowser from '@docusaurus/useIsBrowser';
import {Highlight, type Language} from 'prism-react-renderer';
import Line from '@theme/CodeBlock/Line';
import CopyButton from '@theme/CodeBlock/CopyButton';
import WordWrapButton from '@theme/CodeBlock/WordWrapButton';
import Container from '@theme/CodeBlock/Container';
import type {Props} from '@theme/CodeBlock/Content/String';
import CodeBlockLayout from '@theme/CodeBlock/Layout';
import styles from './styles.module.css';
// Prism languages are always lowercase
// We want to fail-safe and allow both "php" and "PHP"
// See https://github.com/facebook/docusaurus/issues/9012
function normalizeLanguage(language: string | undefined): string | undefined {
return language?.toLowerCase();
function useCodeBlockMetadata(props: Props): CodeBlockMetadata {
const {prism} = useThemeConfig();
return createCodeBlockMetadata({
code: props.children,
className: props.className,
metastring: props.metastring,
magicComments: prism.magicComments,
defaultLanguage: prism.defaultLanguage,
language: props.language,
title: props.title,
showLineNumbers: props.showLineNumbers,
});
}
export default function CodeBlockString({
children,
className: blockClassName = '',
metastring,
title: titleProp,
showLineNumbers: showLineNumbersProp,
language: languageProp,
}: Props): ReactNode {
const {
prism: {defaultLanguage, magicComments},
} = useThemeConfig();
const language = normalizeLanguage(
languageProp ?? parseLanguage(blockClassName) ?? defaultLanguage,
);
const prismTheme = usePrismTheme();
// TODO Docusaurus v4: move this component at the root?
export default function CodeBlockString(props: Props): ReactNode {
const metadata = useCodeBlockMetadata(props);
const wordWrap = useCodeWordWrap();
const isBrowser = useIsBrowser();
// We still parse the metastring in case we want to support more syntax in the
// future. Note that MDX doesn't strip quotes when parsing metastring:
// "title=\"xyz\"" => title: "\"xyz\""
const title = parseCodeBlockTitle(metastring) || titleProp;
const {lineClassNames, code} = parseLines(children, {
metastring,
language,
magicComments,
});
const lineNumbersStart = getLineNumbersStart({
showLineNumbers: showLineNumbersProp,
metastring,
});
return (
<Container
as="div"
className={clsx(
blockClassName,
language &&
!blockClassName.includes(`language-${language}`) &&
`language-${language}`,
)}>
{title && <div className={styles.codeBlockTitle}>{title}</div>}
<div className={styles.codeBlockContent}>
<Highlight
theme={prismTheme}
code={code}
language={(language ?? 'text') as Language}>
{({className, style, tokens, getLineProps, getTokenProps}) => (
<pre
/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */
tabIndex={0}
ref={wordWrap.codeBlockRef}
className={clsx(className, styles.codeBlock, 'thin-scrollbar')}
style={style}>
<code
className={clsx(
styles.codeBlockLines,
lineNumbersStart !== undefined &&
styles.codeBlockLinesWithNumbering,
)}
style={
lineNumbersStart === undefined
? undefined
: {counterReset: `line-count ${lineNumbersStart - 1}`}
}>
{tokens.map((line, i) => (
<Line
key={i}
line={line}
getLineProps={getLineProps}
getTokenProps={getTokenProps}
classNames={lineClassNames[i]}
showLineNumbers={lineNumbersStart !== undefined}
/>
))}
</code>
</pre>
)}
</Highlight>
{isBrowser ? (
<div className={styles.buttonGroup}>
{(wordWrap.isEnabled || wordWrap.isCodeScrollable) && (
<WordWrapButton
className={styles.codeButton}
onClick={() => wordWrap.toggle()}
isEnabled={wordWrap.isEnabled}
/>
)}
<CopyButton className={styles.codeButton} code={code} />
</div>
) : null}
</div>
</Container>
<CodeBlockContextProvider metadata={metadata} wordWrap={wordWrap}>
<CodeBlockLayout />
</CodeBlockContextProvider>
);
}

View File

@ -0,0 +1,84 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ComponentProps, type ReactNode} from 'react';
import clsx from 'clsx';
import {useCodeBlockContext} from '@docusaurus/theme-common/internal';
import {usePrismTheme} from '@docusaurus/theme-common';
import {Highlight} from 'prism-react-renderer';
import type {Props} from '@theme/CodeBlock/Content';
import Line from '@theme/CodeBlock/Line';
import styles from './styles.module.css';
// TODO Docusaurus v4: remove useless forwardRef
const Pre = React.forwardRef<HTMLPreElement, ComponentProps<'pre'>>(
(props, ref) => {
return (
<pre
ref={ref}
/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */
tabIndex={0}
{...props}
className={clsx(props.className, styles.codeBlock, 'thin-scrollbar')}
/>
);
},
);
function Code(props: ComponentProps<'code'>) {
const {metadata} = useCodeBlockContext();
return (
<code
{...props}
className={clsx(
props.className,
styles.codeBlockLines,
metadata.lineNumbersStart !== undefined &&
styles.codeBlockLinesWithNumbering,
)}
style={{
...props.style,
counterReset:
metadata.lineNumbersStart === undefined
? undefined
: `line-count ${metadata.lineNumbersStart - 1}`,
}}
/>
);
}
export default function CodeBlockContent({
className: classNameProp,
}: Props): ReactNode {
const {metadata, wordWrap} = useCodeBlockContext();
const prismTheme = usePrismTheme();
const {code, language, lineNumbersStart, lineClassNames} = metadata;
return (
<Highlight theme={prismTheme} code={code} language={language}>
{({className, style, tokens: lines, getLineProps, getTokenProps}) => (
<Pre
ref={wordWrap.codeBlockRef}
className={clsx(classNameProp, className)}
style={style}>
<Code>
{lines.map((line, i) => (
<Line
key={i}
line={line}
getLineProps={getLineProps}
getTokenProps={getTokenProps}
classNames={lineClassNames[i]}
showLineNumbers={lineNumbersStart !== undefined}
/>
))}
</Code>
</Pre>
)}
</Highlight>
);
}

View File

@ -5,33 +5,12 @@
* LICENSE file in the root directory of this source tree.
*/
.codeBlockContent {
position: relative;
/* rtl:ignore */
direction: ltr;
border-radius: inherit;
}
.codeBlockTitle {
border-bottom: 1px solid var(--ifm-color-emphasis-300);
font-size: var(--ifm-code-font-size);
font-weight: 500;
padding: 0.75rem var(--ifm-pre-padding);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.codeBlock {
--ifm-pre-background: var(--prism-background-color);
margin: 0;
padding: 0;
}
.codeBlockTitle + .codeBlockContent .codeBlock {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.codeBlockStandalone {
padding: 0;
}
@ -54,34 +33,3 @@
white-space: pre-wrap;
}
}
.buttonGroup {
display: flex;
column-gap: 0.2rem;
position: absolute;
/* rtl:ignore */
right: calc(var(--ifm-pre-padding) / 2);
top: calc(var(--ifm-pre-padding) / 2);
}
.buttonGroup button {
display: flex;
align-items: center;
background: var(--prism-background-color);
color: var(--prism-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
padding: 0.4rem;
line-height: 0;
transition: opacity var(--ifm-transition-fast) ease-in-out;
opacity: 0;
}
.buttonGroup button:focus-visible,
.buttonGroup button:hover {
opacity: 1 !important;
}
:global(.theme-code-block:hover) .buttonGroup button {
opacity: 0.4;
}

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import {useCodeBlockContext} from '@docusaurus/theme-common/internal';
import Container from '@theme/CodeBlock/Container';
import Title from '@theme/CodeBlock/Title';
import Content from '@theme/CodeBlock/Content';
import type {Props} from '@theme/CodeBlock/Layout';
import Buttons from '@theme/CodeBlock/Buttons';
import styles from './styles.module.css';
export default function CodeBlockLayout({className}: Props): ReactNode {
const {metadata} = useCodeBlockContext();
return (
<Container as="div" className={clsx(className, metadata.className)}>
{metadata.title && (
<div className={styles.codeBlockTitle}>
<Title>{metadata.title}</Title>
</div>
)}
<div className={styles.codeBlockContent}>
<Content />
<Buttons />
</div>
</Container>
);
}

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.codeBlockContent {
position: relative;
/* rtl:ignore */
direction: ltr;
border-radius: inherit;
}
.codeBlockTitle {
border-bottom: 1px solid var(--ifm-color-emphasis-300);
font-size: var(--ifm-code-font-size);
font-weight: 500;
padding: 0.75rem var(--ifm-pre-padding);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.codeBlockTitle + .codeBlockContent .codeBlock {
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View File

@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import type {Props} from '@theme/CodeBlock/Line/Token';
// Pass-through components that users can swizzle and customize
export default function CodeBlockLineToken({
line,
token,
...props
}: Props): ReactNode {
return <span {...props} />;
}

View File

@ -7,6 +7,7 @@
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import LineToken from '@theme/CodeBlock/Line/Token';
import type {Props} from '@theme/CodeBlock/Line';
import styles from './styles.module.css';
@ -40,9 +41,14 @@ export default function CodeBlockLine({
className: clsx(classNames, showLineNumbers && styles.codeLine),
});
const lineTokens = line.map((token, key) => (
<span key={key} {...getTokenProps({token})} />
));
const lineTokens = line.map((token, key) => {
const tokenProps = getTokenProps({token});
return (
<LineToken key={key} {...tokenProps} line={line} token={token}>
{tokenProps.children}
</LineToken>
);
});
return (
<span {...lineProps}>

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {ReactNode} from 'react';
import type {Props} from '@theme/CodeBlock/Title';
// Just a pass-through component that users can swizzle and customize
export default function CodeBlockTitle({children}: Props): ReactNode {
return children;
}

View File

@ -1,66 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink';
import type {
DesktopOrMobileNavBarItemProps,
Props,
} from '@theme/NavbarItem/DefaultNavbarItem';
function DefaultNavbarItemDesktop({
className,
isDropdownItem = false,
...props
}: DesktopOrMobileNavBarItemProps) {
const element = (
<NavbarNavLink
className={clsx(
isDropdownItem ? 'dropdown__link' : 'navbar__item navbar__link',
className,
)}
isDropdownLink={isDropdownItem}
{...props}
/>
);
if (isDropdownItem) {
return <li>{element}</li>;
}
return element;
}
function DefaultNavbarItemMobile({
className,
isDropdownItem,
...props
}: DesktopOrMobileNavBarItemProps) {
return (
<li className="menu__list-item">
<NavbarNavLink className={clsx('menu__link', className)} {...props} />
</li>
);
}
export default function DefaultNavbarItem({
mobile = false,
position, // Need to destructure position from props so that it doesn't get passed on.
...props
}: Props): ReactNode {
const Comp = mobile ? DefaultNavbarItemMobile : DefaultNavbarItemDesktop;
return (
<Comp
{...props}
activeClassName={
props.activeClassName ??
(mobile ? 'menu__link--active' : 'navbar__link--active')
}
/>
);
}

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink';
import type {Props} from '@theme/NavbarItem/DefaultNavbarItem/Desktop';
export default function DefaultNavbarItemDesktop({
className,
isDropdownItem = false,
...props
}: Props): ReactNode {
const element = (
<NavbarNavLink
className={clsx(
isDropdownItem ? 'dropdown__link' : 'navbar__item navbar__link',
className,
)}
isDropdownLink={isDropdownItem}
{...props}
/>
);
if (isDropdownItem) {
return <li>{element}</li>;
}
return element;
}

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink';
import type {Props} from '@theme/NavbarItem/DefaultNavbarItem/Mobile';
export default function DefaultNavbarItemMobile({
className,
isDropdownItem,
...props
}: Props): ReactNode {
return (
<li className="menu__list-item">
<NavbarNavLink className={clsx('menu__link', className)} {...props} />
</li>
);
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import DefaultNavbarItemMobile from '@theme/NavbarItem/DefaultNavbarItem/Mobile';
import DefaultNavbarItemDesktop from '@theme/NavbarItem/DefaultNavbarItem/Desktop';
import type {Props} from '@theme/NavbarItem/DefaultNavbarItem';
export default function DefaultNavbarItem({
mobile = false,
position, // Need to destructure position from props so that it doesn't get passed on.
...props
}: Props): ReactNode {
const Comp = mobile ? DefaultNavbarItemMobile : DefaultNavbarItemDesktop;
return (
<Comp
{...props}
activeClassName={
props.activeClassName ??
(mobile ? 'menu__link--active' : 'navbar__link--active')
}
/>
);
}

View File

@ -0,0 +1,86 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useState, useRef, useEffect, type ReactNode} from 'react';
import clsx from 'clsx';
import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink';
import NavbarItem from '@theme/NavbarItem';
import type {Props} from '@theme/NavbarItem/DropdownNavbarItem/Desktop';
export default function DropdownNavbarItemDesktop({
items,
position,
className,
onClick,
...props
}: Props): ReactNode {
const dropdownRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
useEffect(() => {
const handleClickOutside = (
event: MouseEvent | TouchEvent | FocusEvent,
) => {
if (
!dropdownRef.current ||
dropdownRef.current.contains(event.target as Node)
) {
return;
}
setShowDropdown(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
document.addEventListener('focusin', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
document.removeEventListener('focusin', handleClickOutside);
};
}, [dropdownRef]);
return (
<div
ref={dropdownRef}
className={clsx('navbar__item', 'dropdown', 'dropdown--hoverable', {
'dropdown--right': position === 'right',
'dropdown--show': showDropdown,
})}>
<NavbarNavLink
aria-haspopup="true"
aria-expanded={showDropdown}
role="button"
// # hash permits to make the <a> tag focusable in case no link target
// See https://github.com/facebook/docusaurus/pull/6003
// There's probably a better solution though...
href={props.to ? undefined : '#'}
className={clsx('navbar__link', className)}
{...props}
onClick={props.to ? undefined : (e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setShowDropdown(!showDropdown);
}
}}>
{props.children ?? props.label}
</NavbarNavLink>
<ul className="dropdown__menu">
{items.map((childItemProps, i) => (
<NavbarItem
isDropdownItem
activeClassName="dropdown__link--active"
{...childItemProps}
key={i}
/>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,166 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useEffect, type ReactNode, type ComponentProps} from 'react';
import clsx from 'clsx';
import {
isRegexpStringMatch,
useCollapsible,
Collapsible,
} from '@docusaurus/theme-common';
import {isSamePath, useLocalPathname} from '@docusaurus/theme-common/internal';
import {translate} from '@docusaurus/Translate';
import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink';
import NavbarItem, {type LinkLikeNavbarItemProps} from '@theme/NavbarItem';
import type {Props} from '@theme/NavbarItem/DropdownNavbarItem/Mobile';
import styles from './styles.module.css';
function isItemActive(
item: LinkLikeNavbarItemProps,
localPathname: string,
): boolean {
if (isSamePath(item.to, localPathname)) {
return true;
}
if (isRegexpStringMatch(item.activeBaseRegex, localPathname)) {
return true;
}
if (item.activeBasePath && localPathname.startsWith(item.activeBasePath)) {
return true;
}
return false;
}
function containsActiveItems(
items: readonly LinkLikeNavbarItemProps[],
localPathname: string,
): boolean {
return items.some((item) => isItemActive(item, localPathname));
}
function CollapseButton({
collapsed,
onClick,
}: {
collapsed: boolean;
onClick: ComponentProps<'button'>['onClick'];
}) {
return (
<button
aria-label={
collapsed
? translate({
id: 'theme.navbar.mobileDropdown.collapseButton.expandAriaLabel',
message: 'Expand the dropdown',
description:
'The ARIA label of the button to expand the mobile dropdown navbar item',
})
: translate({
id: 'theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel',
message: 'Collapse the dropdown',
description:
'The ARIA label of the button to collapse the mobile dropdown navbar item',
})
}
aria-expanded={!collapsed}
type="button"
className="clean-btn menu__caret"
onClick={onClick}
/>
);
}
function useItemCollapsible({active}: {active: boolean}) {
const {collapsed, toggleCollapsed, setCollapsed} = useCollapsible({
initialState: () => !active,
});
// Expand if any item active after a navigation
useEffect(() => {
if (active) {
setCollapsed(false);
}
}, [active, setCollapsed]);
return {
collapsed,
toggleCollapsed,
};
}
export default function DropdownNavbarItemMobile({
items,
className,
position, // Need to destructure position from props so that it doesn't get passed on.
onClick,
...props
}: Props): ReactNode {
const localPathname = useLocalPathname();
const isActive = isSamePath(props.to, localPathname);
const containsActive = containsActiveItems(items, localPathname);
const {collapsed, toggleCollapsed} = useItemCollapsible({
active: isActive || containsActive,
});
// # hash permits to make the <a> tag focusable in case no link target
// See https://github.com/facebook/docusaurus/pull/6003
// There's probably a better solution though...
const href = props.to ? undefined : '#';
return (
<li
className={clsx('menu__list-item', {
'menu__list-item--collapsed': collapsed,
})}>
<div
className={clsx('menu__list-item-collapsible', {
'menu__list-item-collapsible--active': isActive,
})}>
<NavbarNavLink
role="button"
className={clsx(
styles.dropdownNavbarItemMobile,
'menu__link menu__link--sublist',
className,
)}
href={href}
{...props}
onClick={(e) => {
// Prevent navigation when link is "#"
if (href === '#') {
e.preventDefault();
}
// Otherwise we let navigation eventually happen, and/or collapse
toggleCollapsed();
}}>
{props.children ?? props.label}
</NavbarNavLink>
<CollapseButton
collapsed={collapsed}
onClick={(e) => {
e.preventDefault();
toggleCollapsed();
}}
/>
</div>
<Collapsible lazy as="ul" className="menu__list" collapsed={collapsed}>
{items.map((childItemProps, i) => (
<NavbarItem
mobile
isDropdownItem
onClick={onClick}
activeClassName="menu__link--active"
{...childItemProps}
key={i}
/>
))}
</Collapsible>
</li>
);
}

View File

@ -5,178 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {useState, useRef, useEffect, type ReactNode} from 'react';
import clsx from 'clsx';
import {
isRegexpStringMatch,
useCollapsible,
Collapsible,
} from '@docusaurus/theme-common';
import {isSamePath, useLocalPathname} from '@docusaurus/theme-common/internal';
import NavbarNavLink from '@theme/NavbarItem/NavbarNavLink';
import NavbarItem, {type LinkLikeNavbarItemProps} from '@theme/NavbarItem';
import type {
DesktopOrMobileNavBarItemProps,
Props,
} from '@theme/NavbarItem/DropdownNavbarItem';
import styles from './styles.module.css';
function isItemActive(
item: LinkLikeNavbarItemProps,
localPathname: string,
): boolean {
if (isSamePath(item.to, localPathname)) {
return true;
}
if (isRegexpStringMatch(item.activeBaseRegex, localPathname)) {
return true;
}
if (item.activeBasePath && localPathname.startsWith(item.activeBasePath)) {
return true;
}
return false;
}
function containsActiveItems(
items: readonly LinkLikeNavbarItemProps[],
localPathname: string,
): boolean {
return items.some((item) => isItemActive(item, localPathname));
}
function DropdownNavbarItemDesktop({
items,
position,
className,
onClick,
...props
}: DesktopOrMobileNavBarItemProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
useEffect(() => {
const handleClickOutside = (
event: MouseEvent | TouchEvent | FocusEvent,
) => {
if (
!dropdownRef.current ||
dropdownRef.current.contains(event.target as Node)
) {
return;
}
setShowDropdown(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
document.addEventListener('focusin', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
document.removeEventListener('focusin', handleClickOutside);
};
}, [dropdownRef]);
return (
<div
ref={dropdownRef}
className={clsx('navbar__item', 'dropdown', 'dropdown--hoverable', {
'dropdown--right': position === 'right',
'dropdown--show': showDropdown,
})}>
<NavbarNavLink
aria-haspopup="true"
aria-expanded={showDropdown}
role="button"
// # hash permits to make the <a> tag focusable in case no link target
// See https://github.com/facebook/docusaurus/pull/6003
// There's probably a better solution though...
href={props.to ? undefined : '#'}
className={clsx('navbar__link', className)}
{...props}
onClick={props.to ? undefined : (e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
setShowDropdown(!showDropdown);
}
}}>
{props.children ?? props.label}
</NavbarNavLink>
<ul className="dropdown__menu">
{items.map((childItemProps, i) => (
<NavbarItem
isDropdownItem
activeClassName="dropdown__link--active"
{...childItemProps}
key={i}
/>
))}
</ul>
</div>
);
}
function DropdownNavbarItemMobile({
items,
className,
position, // Need to destructure position from props so that it doesn't get passed on.
onClick,
...props
}: DesktopOrMobileNavBarItemProps) {
const localPathname = useLocalPathname();
const containsActive = containsActiveItems(items, localPathname);
const {collapsed, toggleCollapsed, setCollapsed} = useCollapsible({
initialState: () => !containsActive,
});
// Expand/collapse if any item active after a navigation
useEffect(() => {
if (containsActive) {
setCollapsed(!containsActive);
}
}, [localPathname, containsActive, setCollapsed]);
return (
<li
className={clsx('menu__list-item', {
'menu__list-item--collapsed': collapsed,
})}>
<NavbarNavLink
role="button"
className={clsx(
styles.dropdownNavbarItemMobile,
'menu__link menu__link--sublist menu__link--sublist-caret',
className,
)}
// # hash permits to make the <a> tag focusable in case no link target
// See https://github.com/facebook/docusaurus/pull/6003
// There's probably a better solution though...
href={props.to ? undefined : '#'}
{...props}
onClick={(e) => {
e.preventDefault();
toggleCollapsed();
}}>
{props.children ?? props.label}
</NavbarNavLink>
<Collapsible lazy as="ul" className="menu__list" collapsed={collapsed}>
{items.map((childItemProps, i) => (
<NavbarItem
mobile
isDropdownItem
onClick={onClick}
activeClassName="menu__link--active"
{...childItemProps}
key={i}
/>
))}
</Collapsible>
</li>
);
}
import React, {type ReactNode} from 'react';
import DropdownNavbarItemMobile from '@theme/NavbarItem/DropdownNavbarItem/Mobile';
import DropdownNavbarItemDesktop from '@theme/NavbarItem/DropdownNavbarItem/Desktop';
import type {Props} from '@theme/NavbarItem/DropdownNavbarItem';
export default function DropdownNavbarItem({
mobile = false,

View File

@ -52,12 +52,14 @@ function useTabBecameVisibleCallback(
);
}
export function useCodeWordWrap(): {
export type WordWrap = {
readonly codeBlockRef: RefObject<HTMLPreElement>;
readonly isEnabled: boolean;
readonly isCodeScrollable: boolean;
readonly toggle: () => void;
} {
};
export function useCodeWordWrap(): WordWrap {
const [isEnabled, setIsEnabled] = useState(false);
const [isCodeScrollable, setIsCodeScrollable] = useState<boolean>(false);
const codeBlockRef = useRef<HTMLPreElement>(null);

View File

@ -34,10 +34,11 @@ export {ColorModeProvider} from './contexts/colorMode';
export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
export {
parseCodeBlockTitle,
parseLanguage,
parseLines,
getLineNumbersStart,
type CodeBlockMetadata,
createCodeBlockMetadata,
getPrismCssVariables,
CodeBlockContextProvider,
useCodeBlockContext,
} from './utils/codeBlockUtils';
export {DEFAULT_SEARCH_TAG} from './utils/searchUtils';
@ -88,7 +89,6 @@ export {
} from './hooks/useKeyboardNavigation';
export {useLockBodyScroll} from './hooks/useLockBodyScroll';
export {useCodeWordWrap} from './hooks/useCodeWordWrap';
export {getPrismCssVariables} from './utils/codeBlockUtils';
export {useBackToTopButton} from './hooks/useBackToTopButton';
export {

View File

@ -1,309 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`parseLines does not parse content with metastring 1`] = `
{
"code": "aaaaa
nnnnn",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines does not parse content with metastring 2`] = `
{
"code": "// highlight-next-line
aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines does not parse content with metastring 3`] = `
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines does not parse content with no language 1`] = `
{
"code": "// highlight-next-line
aaaaa
bbbbb",
"lineClassNames": {},
}
`;
exports[`parseLines handles one line with multiple class names 1`] = `
{
"code": "
highlighted and collapsed
highlighted and collapsed
highlighted and collapsed
Only highlighted
Only collapsed
highlighted and collapsed
highlighted and collapsed
Only collapsed
highlighted and collapsed",
"lineClassNames": {
"1": [
"highlight",
"collapse",
],
"2": [
"highlight",
"collapse",
],
"3": [
"highlight",
"collapse",
],
"4": [
"highlight",
],
"5": [
"collapse",
],
"6": [
"highlight",
"collapse",
],
"7": [
"highlight",
"collapse",
],
"8": [
"collapse",
],
"9": [
"highlight",
"collapse",
],
},
}
`;
exports[`parseLines handles one line with multiple class names 2`] = `
{
"code": "line
line",
"lineClassNames": {
"0": [
"a",
"b",
"c",
"d",
],
"1": [
"b",
"d",
],
},
}
`;
exports[`parseLines parses multiple types of magic comments 1`] = `
{
"code": "
highlighted
collapsed
collapsed
collapsed",
"lineClassNames": {
"1": [
"highlight",
],
"2": [
"collapse",
],
"3": [
"collapse",
],
"4": [
"collapse",
],
},
}
`;
exports[`parseLines removes lines correctly 1`] = `
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines removes lines correctly 2`] = `
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines removes lines correctly 3`] = `
{
"code": "aaaaa
bbbbbbb
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
"theme-code-block-highlighted-line",
],
"1": [
"theme-code-block-highlighted-line",
],
"2": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines respects language: html 1`] = `
{
"code": "aaaa
{/* highlight-next-line */}
bbbbb
dddd",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
"3": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines respects language: js 1`] = `
{
"code": "# highlight-next-line
aaaaa
bbbbb",
"lineClassNames": {},
}
`;
exports[`parseLines respects language: jsx 1`] = `
{
"code": "aaaa
bbbbb
<!-- highlight-next-line -->
dddd",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
"1": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines respects language: md 1`] = `
{
"code": "---
aaa: boo
---
aaaa
<div>
foo
</div>
bbbbb
dddd
\`\`\`js
// highlight-next-line
console.log("preserved");
\`\`\`",
"lineClassNames": {
"1": [
"theme-code-block-highlighted-line",
],
"11": [
"theme-code-block-highlighted-line",
],
"7": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines respects language: none 1`] = `
{
"code": "aaaa
bbbbb
ccccc
dddd",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
"1": [
"theme-code-block-highlighted-line",
],
"2": [
"theme-code-block-highlighted-line",
],
"3": [
"theme-code-block-highlighted-line",
],
},
}
`;
exports[`parseLines respects language: py 1`] = `
{
"code": "/* highlight-next-line */
aaaaa
bbbbb",
"lineClassNames": {},
}
`;
exports[`parseLines respects language: py 2`] = `
{
"code": "// highlight-next-line
aaaa
/* highlight-next-line */
bbbbb
ccccc
<!-- highlight-next-line -->
dddd",
"lineClassNames": {
"4": [
"theme-code-block-highlighted-line",
],
},
}
`;

View File

@ -6,12 +6,22 @@
*/
import {
getLineNumbersStart,
type MagicCommentConfig,
parseCodeBlockTitle,
parseLanguage,
parseClassNameLanguage,
parseLines,
createCodeBlockMetadata,
} from '../codeBlockUtils';
const defaultMagicComments: MagicCommentConfig[] = [
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
},
];
describe('parseCodeBlockTitle', () => {
it('parses double quote delimited title', () => {
expect(parseCodeBlockTitle(`title="index.js"`)).toBe(`index.js`);
@ -58,24 +68,16 @@ describe('parseCodeBlockTitle', () => {
});
});
describe('parseLanguage', () => {
describe('parseClassNameLanguage', () => {
it('works', () => {
expect(parseLanguage('language-foo xxx yyy')).toBe('foo');
expect(parseLanguage('xxxxx language-foo yyy')).toBe('foo');
expect(parseLanguage('xx-language-foo yyyy')).toBeUndefined();
expect(parseLanguage('xxx yyy zzz')).toBeUndefined();
expect(parseClassNameLanguage('language-foo xxx yyy')).toBe('foo');
expect(parseClassNameLanguage('xxxxx language-foo yyy')).toBe('foo');
expect(parseClassNameLanguage('xx-language-foo yyyy')).toBeUndefined();
expect(parseClassNameLanguage('xxx yyy zzz')).toBeUndefined();
});
});
describe('parseLines', () => {
const defaultMagicComments: MagicCommentConfig[] = [
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
},
];
it('does not parse content with metastring', () => {
expect(
parseLines('aaaaa\nnnnnn', {
@ -83,7 +85,18 @@ describe('parseLines', () => {
language: 'js',
magicComments: defaultMagicComments,
}),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "aaaaa
nnnnn",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`// highlight-next-line
@ -95,7 +108,19 @@ bbbbb`,
magicComments: defaultMagicComments,
},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "// highlight-next-line
aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`aaaaa
@ -106,7 +131,18 @@ bbbbb`,
magicComments: defaultMagicComments,
},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(() =>
parseLines(
`aaaaa
@ -121,6 +157,7 @@ bbbbb`,
`"A highlight range has been given in code block's metastring (\`\`\` {1}), but no magic comment config is available. Docusaurus applies the first magic comment entry's className for metastring ranges."`,
);
});
it('does not parse content with no language', () => {
expect(
parseLines(
@ -133,8 +170,16 @@ bbbbb`,
magicComments: defaultMagicComments,
},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "// highlight-next-line
aaaaa
bbbbb",
"lineClassNames": {},
}
`);
});
it('removes lines correctly', () => {
expect(
parseLines(
@ -143,7 +188,18 @@ aaaaa
bbbbb`,
{metastring: '', language: 'js', magicComments: defaultMagicComments},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`// highlight-start
@ -152,7 +208,18 @@ aaaaa
bbbbb`,
{metastring: '', language: 'js', magicComments: defaultMagicComments},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`// highlight-start
@ -164,8 +231,27 @@ bbbbbbb
bbbbb`,
{metastring: '', language: 'js', magicComments: defaultMagicComments},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "aaaaa
bbbbbbb
bbbbb",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
"theme-code-block-highlighted-line",
],
"1": [
"theme-code-block-highlighted-line",
],
"2": [
"theme-code-block-highlighted-line",
],
},
}
`);
});
it('respects language', () => {
expect(
parseLines(
@ -174,7 +260,15 @@ aaaaa
bbbbb`,
{metastring: '', language: 'js', magicComments: defaultMagicComments},
),
).toMatchSnapshot('js');
).toMatchInlineSnapshot(`
{
"code": "# highlight-next-line
aaaaa
bbbbb",
"lineClassNames": {},
}
`);
expect(
parseLines(
`/* highlight-next-line */
@ -182,7 +276,15 @@ aaaaa
bbbbb`,
{metastring: '', language: 'py', magicComments: defaultMagicComments},
),
).toMatchSnapshot('py');
).toMatchInlineSnapshot(`
{
"code": "/* highlight-next-line */
aaaaa
bbbbb",
"lineClassNames": {},
}
`);
expect(
parseLines(
`// highlight-next-line
@ -195,7 +297,23 @@ ccccc
dddd`,
{metastring: '', language: 'py', magicComments: defaultMagicComments},
),
).toMatchSnapshot('py');
).toMatchInlineSnapshot(`
{
"code": "// highlight-next-line
aaaa
/* highlight-next-line */
bbbbb
ccccc
<!-- highlight-next-line -->
dddd",
"lineClassNames": {
"4": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`// highlight-next-line
@ -208,7 +326,29 @@ ccccc
dddd`,
{metastring: '', language: '', magicComments: defaultMagicComments},
),
).toMatchSnapshot('none');
).toMatchInlineSnapshot(`
{
"code": "aaaa
bbbbb
ccccc
dddd",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
"1": [
"theme-code-block-highlighted-line",
],
"2": [
"theme-code-block-highlighted-line",
],
"3": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`// highlight-next-line
@ -219,7 +359,23 @@ bbbbb
dddd`,
{metastring: '', language: 'jsx', magicComments: defaultMagicComments},
),
).toMatchSnapshot('jsx');
).toMatchInlineSnapshot(`
{
"code": "aaaa
bbbbb
<!-- highlight-next-line -->
dddd",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
"1": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`// highlight-next-line
@ -230,7 +386,23 @@ bbbbb
dddd`,
{metastring: '', language: 'html', magicComments: defaultMagicComments},
),
).toMatchSnapshot('html');
).toMatchInlineSnapshot(`
{
"code": "aaaa
{/* highlight-next-line */}
bbbbb
dddd",
"lineClassNames": {
"0": [
"theme-code-block-highlighted-line",
],
"3": [
"theme-code-block-highlighted-line",
],
},
}
`);
expect(
parseLines(
`---
@ -256,7 +428,38 @@ console.log("preserved");
`,
{metastring: '', language: 'md', magicComments: defaultMagicComments},
),
).toMatchSnapshot('md');
).toMatchInlineSnapshot(`
{
"code": "---
aaa: boo
---
aaaa
<div>
foo
</div>
bbbbb
dddd
\`\`\`js
// highlight-next-line
console.log("preserved");
\`\`\`",
"lineClassNames": {
"1": [
"theme-code-block-highlighted-line",
],
"11": [
"theme-code-block-highlighted-line",
],
"7": [
"theme-code-block-highlighted-line",
],
},
}
`);
});
it('parses multiple types of magic comments', () => {
@ -289,7 +492,29 @@ collapsed
],
},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "
highlighted
collapsed
collapsed
collapsed",
"lineClassNames": {
"1": [
"highlight",
],
"2": [
"collapse",
],
"3": [
"collapse",
],
"4": [
"collapse",
],
},
}
`);
});
it('handles one line with multiple class names', () => {
@ -334,7 +559,56 @@ highlighted and collapsed
],
},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "
highlighted and collapsed
highlighted and collapsed
highlighted and collapsed
Only highlighted
Only collapsed
highlighted and collapsed
highlighted and collapsed
Only collapsed
highlighted and collapsed",
"lineClassNames": {
"1": [
"highlight",
"collapse",
],
"2": [
"highlight",
"collapse",
],
"3": [
"highlight",
"collapse",
],
"4": [
"highlight",
],
"5": [
"collapse",
],
"6": [
"highlight",
"collapse",
],
"7": [
"highlight",
"collapse",
],
"8": [
"collapse",
],
"9": [
"highlight",
"collapse",
],
},
}
`);
expect(
parseLines(
`// a
@ -357,6 +631,394 @@ line
],
},
),
).toMatchSnapshot();
).toMatchInlineSnapshot(`
{
"code": "line
line",
"lineClassNames": {
"0": [
"a",
"b",
"c",
"d",
],
"1": [
"b",
"d",
],
},
}
`);
});
it('handles CRLF line breaks with highlight comments correctly', () => {
expect(
parseLines(
`aaaaa\r\n// highlight-start\r\nbbbbb\r\n// highlight-end\r\n`,
{
metastring: '',
language: 'js',
magicComments: defaultMagicComments,
},
),
).toMatchInlineSnapshot(`
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"1": [
"theme-code-block-highlighted-line",
],
},
}
`);
});
it('handles CRLF line breaks with highlight metastring', () => {
expect(
parseLines(`aaaaa\r\nbbbbb\r\n`, {
metastring: '{2}',
language: 'js',
magicComments: defaultMagicComments,
}),
).toMatchInlineSnapshot(`
{
"code": "aaaaa
bbbbb",
"lineClassNames": {
"1": [
"theme-code-block-highlighted-line",
],
},
}
`);
});
});
describe('getLineNumbersStart', () => {
it('with nothing set', () => {
expect(
getLineNumbersStart({
showLineNumbers: undefined,
metastring: undefined,
}),
).toMatchInlineSnapshot(`undefined`);
expect(
getLineNumbersStart({
showLineNumbers: undefined,
metastring: '',
}),
).toMatchInlineSnapshot(`undefined`);
});
describe('handles prop', () => {
describe('combined with metastring', () => {
it('set to true', () => {
expect(
getLineNumbersStart({
showLineNumbers: true,
metastring: 'showLineNumbers=2',
}),
).toMatchInlineSnapshot(`1`);
});
it('set to false', () => {
expect(
getLineNumbersStart({
showLineNumbers: false,
metastring: 'showLineNumbers=2',
}),
).toMatchInlineSnapshot(`undefined`);
});
it('set to number', () => {
expect(
getLineNumbersStart({
showLineNumbers: 10,
metastring: 'showLineNumbers=2',
}),
).toMatchInlineSnapshot(`10`);
});
});
describe('standalone', () => {
it('set to true', () => {
expect(
getLineNumbersStart({
showLineNumbers: true,
metastring: undefined,
}),
).toMatchInlineSnapshot(`1`);
});
it('set to false', () => {
expect(
getLineNumbersStart({
showLineNumbers: false,
metastring: undefined,
}),
).toMatchInlineSnapshot(`undefined`);
});
it('set to number', () => {
expect(
getLineNumbersStart({
showLineNumbers: 10,
metastring: undefined,
}),
).toMatchInlineSnapshot(`10`);
});
});
});
describe('handles metadata', () => {
describe('standalone', () => {
it('set as flag', () => {
expect(
getLineNumbersStart({
showLineNumbers: undefined,
metastring: 'showLineNumbers',
}),
).toMatchInlineSnapshot(`1`);
});
it('set with number', () => {
expect(
getLineNumbersStart({
showLineNumbers: undefined,
metastring: 'showLineNumbers=10',
}),
).toMatchInlineSnapshot(`10`);
});
});
describe('combined with other options', () => {
it('set as flag', () => {
expect(
getLineNumbersStart({
showLineNumbers: undefined,
metastring: '{1,2-3} title="file.txt" showLineNumbers noInline',
}),
).toMatchInlineSnapshot(`1`);
});
it('set with number', () => {
expect(
getLineNumbersStart({
showLineNumbers: undefined,
metastring: '{1,2-3} title="file.txt" showLineNumbers=10 noInline',
}),
).toMatchInlineSnapshot(`10`);
});
});
});
});
describe('createCodeBlockMetadata', () => {
type Params = Parameters<typeof createCodeBlockMetadata>[0];
const defaultParams: Params = {
code: '',
className: undefined,
metastring: '',
language: undefined,
defaultLanguage: undefined,
magicComments: defaultMagicComments,
title: undefined,
showLineNumbers: undefined,
};
function create(params?: Partial<Params>) {
return createCodeBlockMetadata({...defaultParams, ...params});
}
it('creates basic metadata', () => {
const meta = create();
expect(meta).toMatchInlineSnapshot(`
{
"className": "language-text",
"code": "",
"codeInput": "",
"language": "text",
"lineClassNames": {},
"lineNumbersStart": undefined,
"title": undefined,
}
`);
});
describe('language', () => {
it('returns input language', () => {
const meta = create({language: 'js'});
expect(meta.language).toBe('js');
});
it('returns className language', () => {
const meta = create({className: 'x language-ts y z'});
expect(meta.language).toBe('ts');
});
it('returns default language', () => {
const meta = create({defaultLanguage: 'jsx'});
expect(meta.language).toBe('jsx');
});
it('returns fallback language', () => {
const meta = create();
expect(meta.language).toBe('text');
});
it('returns language with expected precedence', () => {
expect(
create({
language: 'js',
className: 'x language-ts y z',
defaultLanguage: 'jsx',
}).language,
).toBe('js');
expect(
create({
language: undefined,
className: 'x language-ts y z',
defaultLanguage: 'jsx',
}).language,
).toBe('ts');
expect(
create({
language: undefined,
className: 'x y z',
defaultLanguage: 'jsx',
}).language,
).toBe('jsx');
expect(
create({
language: undefined,
className: 'x y z',
defaultLanguage: undefined,
}).language,
).toBe('text');
});
});
describe('code highlighting', () => {
it('returns code with no highlighting', () => {
const code = 'const x = 42;';
const meta = create({code});
expect(meta.codeInput).toBe(code);
expect(meta.code).toBe(code);
expect(meta.lineClassNames).toMatchInlineSnapshot(`{}`);
});
it('returns code with metastring highlighting', () => {
const code = 'const x = 42;';
const meta = create({code, metastring: '{1}'});
expect(meta.codeInput).toBe(code);
expect(meta.code).toBe(code);
expect(meta.lineClassNames).toMatchInlineSnapshot(
`
{
"0": [
"theme-code-block-highlighted-line",
],
}
`,
);
});
it('returns code with magic comment highlighting', () => {
const code = 'const x = 42;';
const inputCode = `// highlight-next-line\n${code}`;
const meta = create({code: inputCode});
expect(meta.codeInput).toBe(inputCode);
expect(meta.code).toBe(code);
expect(meta.lineClassNames).toMatchInlineSnapshot(
`
{
"0": [
"theme-code-block-highlighted-line",
],
}
`,
);
});
});
describe('className', () => {
it('returns provided className with current language', () => {
const meta = create({language: 'js', className: 'some-class'});
expect(meta.className).toBe('some-class language-js');
});
it('returns provided className with fallback language', () => {
const meta = create({className: 'some-class'});
expect(meta.className).toBe('some-class language-text');
});
it('returns provided className without duplicating className language', () => {
const meta = create({
language: 'js',
className: 'some-class language-js',
});
expect(meta.className).toBe('some-class language-js');
});
});
describe('title', () => {
it('returns no title', () => {
const meta = create();
expect(meta.title).toBeUndefined();
});
it('returns title from metastring', () => {
const meta = create({metastring: "title='my title meta'"});
expect(meta.title).toBe('my title meta');
});
it('returns title from param', () => {
const meta = create({title: 'my title param'});
expect(meta.title).toBe('my title param');
});
it('returns title from meta over params', () => {
const meta = create({
metastring: "title='my title meta'",
title: 'my title param',
});
expect(meta.title).toBe('my title meta');
});
});
describe('showLineNumbers', () => {
it('returns no lineNumbersStart', () => {
const meta = create();
expect(meta.lineNumbersStart).toBeUndefined();
});
it('returns lineNumbersStart - params.showLineNumbers=true', () => {
const meta = create({showLineNumbers: true});
expect(meta.lineNumbersStart).toBe(1);
});
it('returns lineNumbersStart - params.showLineNumbers=3', () => {
const meta = create({showLineNumbers: 3});
expect(meta.lineNumbersStart).toBe(3);
});
it('returns lineNumbersStart - meta showLineNumbers', () => {
const meta = create({metastring: 'showLineNumbers'});
expect(meta.lineNumbersStart).toBe(1);
});
it('returns lineNumbersStart - meta showLineNumbers=2', () => {
const meta = create({metastring: 'showLineNumbers=2'});
expect(meta.lineNumbersStart).toBe(2);
});
it('returns lineNumbersStart - params.showLineNumbers=3 + meta showLineNumbers=2', () => {
const meta = create({
showLineNumbers: 3,
metastring: 'showLineNumbers=2',
});
expect(meta.lineNumbersStart).toBe(3);
});
});
});

View File

@ -5,9 +5,13 @@
* LICENSE file in the root directory of this source tree.
*/
import type {CSSProperties} from 'react';
import type {CSSProperties, ReactNode} from 'react';
import {createContext, useContext, useMemo} from 'react';
import clsx from 'clsx';
import rangeParser from 'parse-numeric-range';
import {ReactContextError} from './reactUtils';
import type {PrismTheme, PrismThemeEntry} from 'prism-react-renderer';
import type {WordWrap} from '../hooks/useCodeWordWrap';
const codeBlockTitleRegex = /title=(?<quote>["'])(?<title>.*?)\1/;
const metastringLinesRangeRegex = /\{(?<range>[\d,-]+)\}/;
@ -184,65 +188,42 @@ export function getLineNumbersStart({
return getMetaLineNumbersStart(metastring);
}
/**
* Gets the language name from the class name (set by MDX).
* e.g. `"language-javascript"` => `"javascript"`.
* Returns undefined if there is no language class name.
*/
export function parseLanguage(className: string): string | undefined {
const languageClassName = className
.split(' ')
.find((str) => str.startsWith('language-'));
return languageClassName?.replace(/language-/, '');
}
type ParseCodeLinesParam = {
/**
* The full metastring, as received from MDX. Line ranges declared here
* start at 1.
*/
metastring: string | undefined;
/**
* Language of the code block, used to determine which kinds of magic
* comment styles to enable.
*/
language: string | undefined;
/**
* Magic comment types that we should try to parse. Each entry would
* correspond to one class name to apply to each line.
*/
magicComments: MagicCommentConfig[];
};
/**
* Parses the code content, strips away any magic comments, and returns the
* clean content and the highlighted lines marked by the comments or metastring.
*
* If the metastring contains a range, the `content` will be returned as-is
* without any parsing. The returned `lineClassNames` will be a map from that
* number range to the first magic comment config entry (which _should_ be for
* line highlight directives.)
*
* @param content The raw code with magic comments. Trailing newline will be
* trimmed upfront.
* @param options Options for parsing behavior.
* The highlighted lines, 0-indexed. e.g. `{ 0: ["highlight", "sample"] }`
* means the 1st line should have `highlight` and `sample` as class names.
*/
export function parseLines(
content: string,
options: {
/**
* The full metastring, as received from MDX. Line ranges declared here
* start at 1.
*/
metastring: string | undefined;
/**
* Language of the code block, used to determine which kinds of magic
* comment styles to enable.
*/
language: string | undefined;
/**
* Magic comment types that we should try to parse. Each entry would
* correspond to one class name to apply to each line.
*/
magicComments: MagicCommentConfig[];
},
): {
/**
* The highlighted lines, 0-indexed. e.g. `{ 0: ["highlight", "sample"] }`
* means the 1st line should have `highlight` and `sample` as class names.
*/
lineClassNames: {[lineIndex: number]: string[]};
/**
* If there's number range declared in the metastring, the code block is
* returned as-is (no parsing); otherwise, this is the clean code with all
* magic comments stripped away.
*/
type CodeLineClassNames = {[lineIndex: number]: string[]};
/**
* Code lines after applying magic comments or metastring highlight ranges
*/
type ParsedCodeLines = {
code: string;
} {
let code = content.replace(/\n$/, '');
const {language, magicComments, metastring} = options;
lineClassNames: CodeLineClassNames;
};
function parseCodeLinesFromMetastring(
code: string,
{metastring, magicComments}: ParseCodeLinesParam,
): ParsedCodeLines | null {
// Highlighted lines specified in props: don't parse the content
if (metastring && metastringLinesRangeRegex.test(metastring)) {
const linesRange = metastring.match(metastringLinesRangeRegex)!.groups!
@ -258,6 +239,14 @@ export function parseLines(
.map((n) => [n - 1, [metastringRangeClassName]] as [number, string[]]);
return {lineClassNames: Object.fromEntries(lines), code};
}
return null;
}
function parseCodeLinesFromContent(
code: string,
params: ParseCodeLinesParam,
): ParsedCodeLines {
const {language, magicComments} = params;
if (language === undefined) {
return {lineClassNames: {}, code};
}
@ -266,7 +255,7 @@ export function parseLines(
magicComments,
);
// Go through line by line
const lines = code.split('\n');
const lines = code.split(/\r?\n/);
const blocks = Object.fromEntries(
magicComments.map((d) => [d.className, {start: 0, range: ''}]),
);
@ -307,7 +296,7 @@ export function parseLines(
}
lines.splice(lineNumber, 1);
}
code = lines.join('\n');
const lineClassNames: {[lineIndex: number]: string[]} = {};
Object.entries(blocks).forEach(([className, {range}]) => {
rangeParser(range).forEach((l) => {
@ -315,7 +304,145 @@ export function parseLines(
lineClassNames[l]!.push(className);
});
});
return {lineClassNames, code};
return {code: lines.join('\n'), lineClassNames};
}
/**
* Parses the code content, strips away any magic comments, and returns the
* clean content and the highlighted lines marked by the comments or metastring.
*
* If the metastring contains a range, the `content` will be returned as-is
* without any parsing. The returned `lineClassNames` will be a map from that
* number range to the first magic comment config entry (which _should_ be for
* line highlight directives.)
*/
export function parseLines(
code: string,
params: ParseCodeLinesParam,
): ParsedCodeLines {
// Historical behavior: we remove last line break
const newCode = code.replace(/\r?\n$/, '');
// Historical behavior: we try one strategy after the other
// we don't support mixing metastring ranges + magic comments
return (
parseCodeLinesFromMetastring(newCode, {...params}) ??
parseCodeLinesFromContent(newCode, {...params})
);
}
/**
* Gets the language name from the class name (set by MDX).
* e.g. `"language-javascript"` => `"javascript"`.
* Returns undefined if there is no language class name.
*/
export function parseClassNameLanguage(
className: string | undefined,
): string | undefined {
if (!className) {
return undefined;
}
const languageClassName = className
.split(' ')
.find((str) => str.startsWith('language-'));
return languageClassName?.replace(/language-/, '');
}
// Prism languages are always lowercase
// We want to fail-safe and allow both "php" and "PHP"
// See https://github.com/facebook/docusaurus/issues/9012
function normalizeLanguage(language: string | undefined): string | undefined {
return language?.toLowerCase();
}
function getLanguage(params: {
language: string | undefined;
className: string | undefined;
defaultLanguage: string | undefined;
}): string {
return (
normalizeLanguage(
params.language ??
parseClassNameLanguage(params.className) ??
params.defaultLanguage,
) ?? 'text'
); // There's always a language, required by Prism;
}
/**
* This ensures that we always have the code block language as className
* For MDX code blocks this is provided automatically by MDX
* For JSX code blocks, the language gets added by this function
* This ensures both cases lead to a consistent HTML output
*/
function ensureLanguageClassName({
className,
language,
}: {
className: string | undefined;
language: string;
}): string {
return clsx(
className,
language &&
!className?.includes(`language-${language}`) &&
`language-${language}`,
);
}
export interface CodeBlockMetadata {
codeInput: string; // Including magic comments
code: string; // Rendered code, excluding magic comments
className: string; // There's always a "language-<lang>" className
language: string;
title: ReactNode;
lineNumbersStart: number | undefined;
lineClassNames: CodeLineClassNames;
}
export function createCodeBlockMetadata(params: {
code: string;
className: string | undefined;
language: string | undefined;
defaultLanguage: string | undefined;
metastring: string | undefined;
magicComments: MagicCommentConfig[];
title: ReactNode;
showLineNumbers: boolean | number | undefined;
}): CodeBlockMetadata {
const language = getLanguage({
language: params.language,
defaultLanguage: params.defaultLanguage,
className: params.className,
});
const {lineClassNames, code} = parseLines(params.code, {
metastring: params.metastring,
magicComments: params.magicComments,
language,
});
const className = ensureLanguageClassName({
className: params.className,
language,
});
const title = parseCodeBlockTitle(params.metastring) || params.title;
const lineNumbersStart = getLineNumbersStart({
showLineNumbers: params.showLineNumbers,
metastring: params.metastring,
});
return {
codeInput: params.code,
code,
className,
language,
title,
lineNumbersStart,
lineClassNames,
};
}
export function getPrismCssVariables(prismTheme: PrismTheme): CSSProperties {
@ -333,3 +460,39 @@ export function getPrismCssVariables(prismTheme: PrismTheme): CSSProperties {
});
return properties;
}
type CodeBlockContextValue = {
metadata: CodeBlockMetadata;
wordWrap: WordWrap;
};
const CodeBlockContext = createContext<CodeBlockContextValue | null>(null);
export function CodeBlockContextProvider({
metadata,
wordWrap,
children,
}: {
metadata: CodeBlockMetadata;
wordWrap: WordWrap;
children: ReactNode;
}): ReactNode {
// Should we optimize this in 2 contexts?
// Unlike metadata, wordWrap is stateful and likely to trigger re-renders
const value: CodeBlockContextValue = useMemo(() => {
return {metadata, wordWrap};
}, [metadata, wordWrap]);
return (
<CodeBlockContext.Provider value={value}>
{children}
</CodeBlockContext.Provider>
);
}
export function useCodeBlockContext(): CodeBlockContextValue {
const value = useContext(CodeBlockContext);
if (value === null) {
throw new ReactContextError('CodeBlockContextProvider');
}
return value;
}

View File

@ -38,7 +38,7 @@
"@docusaurus/theme-common": "3.7.0",
"@docusaurus/types": "3.7.0",
"@docusaurus/utils-validation": "3.7.0",
"mermaid": ">=10.4",
"mermaid": ">=11.6.0",
"tslib": "^2.6.0"
},
"devDependencies": {

View File

@ -72,6 +72,8 @@
"theme.lastUpdated.atDate": " في {date}",
"theme.lastUpdated.byUser": " بواسطة {user}",
"theme.lastUpdated.lastUpdatedAtBy": "آخر تحديث{atDate}{byUser}",
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": "Collapse the dropdown",
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": "Expand the dropdown",
"theme.navbar.mobileLanguageDropdown.label": "اللغات",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "→ العودة إلى القائمة الرئيسية",
"theme.navbar.mobileVersionsDropdown.label": "إصدارات",

View File

@ -144,6 +144,10 @@
"theme.lastUpdated.byUser___DESCRIPTION": "The words used to describe by who the page has been last updated",
"theme.lastUpdated.lastUpdatedAtBy": "Last updated{atDate}{byUser}",
"theme.lastUpdated.lastUpdatedAtBy___DESCRIPTION": "The sentence used to display when a page has been last updated, and by who",
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": "Collapse the dropdown",
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel___DESCRIPTION": "The ARIA label of the button to collapse the mobile dropdown navbar item",
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": "Expand the dropdown",
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel___DESCRIPTION": "The ARIA label of the button to expand the mobile dropdown navbar item",
"theme.navbar.mobileLanguageDropdown.label": "Languages",
"theme.navbar.mobileLanguageDropdown.label___DESCRIPTION": "The label for the mobile language switcher dropdown",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← Back to main menu",

View File

@ -72,6 +72,8 @@
"theme.lastUpdated.atDate": " на {date}",
"theme.lastUpdated.byUser": " от {user}",
"theme.lastUpdated.lastUpdatedAtBy": "Последно обновено{atDate}{byUser}",
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": "Collapse the dropdown",
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": "Expand the dropdown",
"theme.navbar.mobileLanguageDropdown.label": "Езици",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← Назад към главното меню",
"theme.navbar.mobileVersionsDropdown.label": "Версии",

View File

@ -72,6 +72,8 @@
"theme.lastUpdated.atDate": " {date} তারিখে",
"theme.lastUpdated.byUser": "{user} দ্বারা",
"theme.lastUpdated.lastUpdatedAtBy": "সর্বশেষ সংষ্করণ{atDate}{byUser}",
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": "Collapse the dropdown",
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": "Expand the dropdown",
"theme.navbar.mobileLanguageDropdown.label": "Languages",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← মেন মেনুতে যান",
"theme.navbar.mobileVersionsDropdown.label": "Versions",

View File

@ -72,6 +72,8 @@
"theme.lastUpdated.atDate": " {date}",
"theme.lastUpdated.byUser": " od {user}",
"theme.lastUpdated.lastUpdatedAtBy": "Naposledy aktualizováno{atDate}{byUser}",
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": "Collapse the dropdown",
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": "Expand the dropdown",
"theme.navbar.mobileLanguageDropdown.label": "Languages",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← Zpět na hlavní menu",
"theme.navbar.mobileVersionsDropdown.label": "Versions",

View File

@ -72,6 +72,8 @@
"theme.lastUpdated.atDate": " den {date}",
"theme.lastUpdated.byUser": " af {user}",
"theme.lastUpdated.lastUpdatedAtBy": "Senest opdateret{atDate}{byUser}",
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": "Collapse the dropdown",
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": "Expand the dropdown",
"theme.navbar.mobileLanguageDropdown.label": "Languages",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← Back to main menu",
"theme.navbar.mobileVersionsDropdown.label": "Versions",

Some files were not shown because too many files have changed in this diff Show More