mirror of
https://github.com/facebook/docusaurus.git
synced 2025-12-29 21:42:50 +00:00
Merge branch 'main' into tinyglobby
This commit is contained in:
commit
bbce79b5eb
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/*
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/*
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,3 +45,6 @@ website/i18n/**/*
|
|||
#!website/i18n/fr/**/*
|
||||
|
||||
.netlify
|
||||
|
||||
website/rspack-tracing.json
|
||||
website/bundler-cpu-profile.json
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
```
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
// }
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import IdealImageWithDefaults from './components/IdealImageWithDefaults';
|
||||
|
||||
export default IdealImageWithDefaults;
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": "إصدارات",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Версии",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue