├── .yarnrc.yml ├── src ├── lib │ ├── types.ts │ └── index.ts ├── plugin │ ├── theme.ts │ ├── context │ │ ├── index.ts │ │ ├── mixins │ │ │ ├── project.tsx │ │ │ ├── comment.tsx │ │ │ ├── container.tsx │ │ │ ├── navigation.tsx │ │ │ ├── layout.tsx │ │ │ └── members.tsx │ │ ├── base.tsx │ │ └── utils.tsx │ ├── utils.ts │ └── index.ts └── assets │ ├── data.ts │ ├── style.css │ └── index.ts ├── README.md ├── example ├── tsconfig.json ├── typedoc.json └── README.md ├── tsconfig.json ├── .gitignore ├── dprint.json ├── LICENSE ├── package.json ├── rollup.config.js └── scripts └── download-assets.js /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type SearchItem = { 2 | id: number; 3 | name: string; 4 | comment: string; 5 | kind: number; 6 | parent: string; 7 | url: string; 8 | }; 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typedoc-theme-oxide 2 | 3 | A TypeDoc theme looks just like [rustdoc](https://doc.rust-lang.org/stable/std/). 4 | 5 | ## Example 6 | 7 | https://balthild.github.io/typedoc-theme-oxide 8 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "skipLibCheck": true, 5 | }, 6 | "include": ["../node_modules/monaco-editor/esm/vs/editor/editor.api.d.ts"], 7 | "exclude": ["theme"], 8 | } 9 | -------------------------------------------------------------------------------- /example/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["../node_modules/monaco-editor/esm/vs/editor/editor.api.d.ts"], 3 | "out": "./docs", 4 | "plugin": ["../dist/plugin/index.js"], 5 | "theme": "oxide", 6 | "name": "Monaco Editor", 7 | "readme": "./README.md", 8 | "includeVersion": true, 9 | "typePrintWidth": 60 10 | } 11 | -------------------------------------------------------------------------------- /src/plugin/theme.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme, PageEvent, Reflection } from 'typedoc'; 2 | 3 | import { OxideContext } from './context'; 4 | 5 | export class OxideTheme extends DefaultTheme { 6 | getRenderContext(page: PageEvent) { 7 | return new OxideContext(this.router, this, page, this.application.options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # TypeDoc Example 2 | 3 | This is an example of [typedoc-theme-oxide](https://github.com/balthild/typedoc-theme-oxide). 4 | 5 | The content of the example documentation is generated from the `d.ts` file of [monaco-editor](https://github.com/microsoft/monaco-editor). However, it is not intended to serve as a documentation. Please refer to https://microsoft.github.io/monaco-editor/ if you want to read the API documentation of monaco-editor. 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "es2022", 5 | "module": "preserve", 6 | "moduleResolution": "bundler", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "sourceMap": true, 12 | "preserveWatchOutput": true, 13 | 14 | "jsx": "react", 15 | "jsxFactory": "JSX.createElement", 16 | "jsxFragmentFactory": "JSX.Fragment", 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Compiled output 13 | dist 14 | docs 15 | assets/dist 16 | assets/rustdoc 17 | 18 | # Dependency directories 19 | node_modules/ 20 | .pnp.* 21 | .yarn/* 22 | !.yarn/patches 23 | !.yarn/plugins 24 | !.yarn/releases 25 | !.yarn/sdks 26 | !.yarn/versions 27 | 28 | # Output of 'npm pack' 29 | *.tgz 30 | -------------------------------------------------------------------------------- /src/plugin/context/index.ts: -------------------------------------------------------------------------------- 1 | import { OxideContextBase } from './base'; 2 | import { CommentMixin } from './mixins/comment'; 3 | import { ContainerMixin } from './mixins/container'; 4 | import { LayoutMixin } from './mixins/layout'; 5 | import { MembersMixin } from './mixins/members'; 6 | import { NavigationMixin } from './mixins/navigation'; 7 | import { ProjectMixin } from './mixins/project'; 8 | 9 | const mixins = [ 10 | LayoutMixin, 11 | 12 | // Pages 13 | ProjectMixin, 14 | ContainerMixin, 15 | 16 | // Partials 17 | NavigationMixin, 18 | CommentMixin, 19 | MembersMixin, 20 | ]; 21 | 22 | let OxideContext = OxideContextBase; 23 | for (const mixin of mixins) { 24 | OxideContext = mixin(OxideContext) as any; 25 | } 26 | 27 | export { OxideContext }; 28 | -------------------------------------------------------------------------------- /src/assets/data.ts: -------------------------------------------------------------------------------- 1 | import { ReflectionKind } from 'typedoc/models'; 2 | import { createSearchDocument } from '../lib'; 3 | 4 | export async function loadDeflateData(url: string) { 5 | const response = await fetch(url); 6 | const blob = await response.blob(); 7 | const stream = blob.stream().pipeThrough(new DecompressionStream('deflate')); 8 | return await new Response(stream).json() as T; 9 | } 10 | 11 | export async function loadSearchIndex() { 12 | const index = createSearchDocument(); 13 | 14 | const base = `${document.documentElement.dataset.base}assets/oxide`; 15 | const parts = await loadDeflateData(`${base}/search-index.deflate`); 16 | for (const [key, data] of Object.entries(parts)) { 17 | index.import(key, data as any); 18 | } 19 | 20 | return index; 21 | } 22 | 23 | export function getReflectionKindName(kind: ReflectionKind) { 24 | return ReflectionKind.classString(kind) 25 | .replace('tsd-kind-', '') 26 | .replace('-', ' '); 27 | } 28 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "includes": [ 3 | "scripts/*.js", 4 | "src/**/*.{ts,tsx,css}", 5 | "rollup.config.js", 6 | ], 7 | "excludes": [ 8 | "**/node_modules", 9 | "**/*-lock.json", 10 | "./example" 11 | ], 12 | "imporg": {}, 13 | "typescript": { 14 | "indentWidth": 2, 15 | "lineWidth": 120, 16 | "semiColons": "always", 17 | "quoteStyle": "preferSingle", 18 | "jsx.quoteStyle": "preferDouble", 19 | "quoteProps": "consistent", 20 | "arrowFunction.useParentheses": "force", 21 | "functionExpression.spaceBeforeParentheses": true, 22 | "jsxOpeningElement.bracketPosition": "sameLine", 23 | "jsxSelfClosingElement.bracketPosition": "nextLine" 24 | }, 25 | "malva": { 26 | "indentWidth": 2, 27 | "printWidth": 120, 28 | "quotes": "preferSingle", 29 | }, 30 | "plugins": [ 31 | "https://plugins.dprint.dev/balthild/imporg-0.1.8.wasm", 32 | "https://plugins.dprint.dev/typescript-0.93.0.wasm", 33 | "https://plugins.dprint.dev/g-plane/malva-v0.10.1.wasm", 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /src/plugin/utils.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from 'typedoc'; 2 | import { ReflectionWithLink } from './context/utils'; 3 | 4 | export function renderTextComment(item: ReflectionWithLink) { 5 | const comments: Comment[] = []; 6 | 7 | if (item.comment) { 8 | comments.push(item.comment); 9 | } 10 | 11 | if (item.isDocument()) { 12 | comments.push(new Comment(item.content)); 13 | } 14 | 15 | if (item.isDeclaration()) { 16 | const signatures = [ 17 | ...item.signatures ?? [], 18 | item.getSignature, 19 | item.setSignature, 20 | ]; 21 | 22 | for (const signature of signatures) { 23 | if (signature?.comment) { 24 | comments.push(signature.comment); 25 | } 26 | } 27 | } 28 | 29 | if (!comments.length) { 30 | return ''; 31 | } 32 | 33 | return comments 34 | .flatMap((comment) => { 35 | return [ 36 | ...comment.summary, 37 | ...comment.blockTags.flatMap((tag) => tag.content), 38 | ]; 39 | }) 40 | .map((part) => part.text) 41 | .join('\n'); 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { Charset, Document, Encoder } from 'flexsearch'; 2 | 3 | import { SearchItem } from './types'; 4 | 5 | export function createSearchDocument() { 6 | return new Document({ 7 | document: { 8 | id: 'id', 9 | store: true, 10 | index: [ 11 | { 12 | field: 'name', 13 | tokenize: 'bidirectional', 14 | priority: 9, 15 | encoder: new Encoder(Charset.LatinBalance).assign({ 16 | normalize(text) { 17 | // split the names in camel case 18 | let words = text.replace(/\d+/g, ' ').split(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/); 19 | if (words.length > 1) { 20 | words.push(text); 21 | } 22 | return words.join(' ').toLowerCase(); 23 | }, 24 | }), 25 | }, 26 | { 27 | field: 'comment', 28 | tokenize: 'default', 29 | encoder: Charset.LatinSoundex, 30 | }, 31 | ], 32 | tag: [], 33 | }, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Balthild 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/plugin/context/mixins/project.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, PageEvent, ProjectReflection, ReflectionKind } from 'typedoc'; 2 | 3 | import { OxideContextBase } from '../base'; 4 | 5 | export const ProjectMixin = (base: typeof OxideContextBase) => 6 | class extends base { 7 | indexTemplate = (page: PageEvent) => { 8 | const { model, project } = page; 9 | 10 | return ( 11 | <> 12 |
13 |

14 | {ReflectionKind.singularString(model.kind)} {project.name} 15 | 16 |

17 | 18 |
19 | 20 |
21 | 22 | Expand description 23 | 24 |
25 | 26 |
27 |
28 | 29 | {this.members(model)} 30 | 31 | ); 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/plugin/context/base.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultTheme, DefaultThemeRenderContext, Options, PageEvent, Reflection, Router } from 'typedoc'; 2 | 3 | import { itemLink, itemSlug, ReflectionSection, ReflectionWithLink, sectionSlug, transformTypography } from './utils'; 4 | 5 | export class OxideContextBase extends DefaultThemeRenderContext { 6 | constructor(router: Router, theme: DefaultTheme, page: PageEvent, options: Options) { 7 | super(router, theme, page, options); 8 | 9 | const markdown = this.markdown; 10 | this.markdown = (text) => transformTypography(markdown(text) ?? ''); 11 | } 12 | 13 | protected sectionSlug(section: ReflectionSection) { 14 | return sectionSlug(section); 15 | } 16 | 17 | protected itemSlug(item: ReflectionWithLink) { 18 | return itemSlug(item); 19 | } 20 | 21 | protected itemLink(item: ReflectionWithLink, forceNested: boolean) { 22 | return itemLink(this, item, forceNested); 23 | } 24 | 25 | protected itemSourceLink(item: Reflection) { 26 | if (!item.isDeclaration() && !item.isSignature()) { 27 | return; 28 | } 29 | 30 | return item.sources?.map((src) => src.url).find((url) => url); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/plugin/context/mixins/comment.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, Reflection, ReflectionKind } from 'typedoc'; 2 | 3 | import { OxideContextBase } from '../base'; 4 | 5 | export const CommentMixin = (base: typeof OxideContextBase) => 6 | class extends base { 7 | commentTags = (model: Reflection) => { 8 | if (!model.comment) { 9 | return; 10 | } 11 | 12 | const skipped = this.options.getValue('notRenderedTags'); 13 | if (model.kindOf(ReflectionKind.SomeSignature)) { 14 | skipped.push('@returns'); 15 | } 16 | 17 | const tags = model.comment.blockTags 18 | .filter((tag) => !tag.skipRendering) 19 | .filter((tag) => !skipped.includes(tag.tag)); 20 | 21 | return ( 22 | <> 23 | {this.hook('comment.beforeTags', this, model.comment, model)} 24 | 25 |
26 | {tags.map((tag) => ( 27 | <> 28 |
29 | {tag.name ?? tag.tag.replace('@', '')} 30 |
31 |
32 | 33 |
34 | 35 | ))} 36 |
37 | 38 | {this.hook('comment.afterTags', this, model.comment, model)} 39 | 40 | ); 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/assets/style.css: -------------------------------------------------------------------------------- 1 | body.rustdoc { 2 | --code-background: var(--code-block-background-color); 3 | } 4 | 5 | .sidebar-elems .block li.parent-module a { 6 | letter-spacing: 0.1em; 7 | } 8 | 9 | :target { 10 | display: block; 11 | } 12 | 13 | a.token, span.token { 14 | color: var(--main-color); 15 | } 16 | 17 | .tsd-anchor-icon { 18 | display: none; 19 | } 20 | 21 | .impl-items > .toggle > summary + .docblock:empty { 22 | margin-bottom: -0.75em; 23 | } 24 | 25 | .structfield { 26 | --code-background: none; 27 | } 28 | 29 | .docblock { 30 | :last-child { 31 | margin-bottom: 0; 32 | } 33 | 34 | pre > button { 35 | display: none; 36 | } 37 | } 38 | 39 | .item-table.comment-tags { 40 | grid-template-columns: auto 1fr; 41 | 42 | .stab { 43 | padding: 1px 5px; 44 | } 45 | } 46 | 47 | rustdoc-toolbar #help-button { 48 | .shortcuts { 49 | width: 100%; 50 | } 51 | 52 | .infos, .top, .bottom { 53 | display: none; 54 | } 55 | } 56 | 57 | oxide-search-results .search-results.active { 58 | display: grid; 59 | grid-template-columns: min-content 1fr; 60 | 61 | > .result-item { 62 | display: grid; 63 | grid-template-areas: none; 64 | grid-template-columns: subgrid; 65 | grid-column: 1 / 3; 66 | 67 | .result-name { 68 | display: grid; 69 | grid-template-columns: subgrid; 70 | grid-column: 1 / 3; 71 | /* align-items: initial; */ 72 | 73 | .typename { 74 | grid-column: 1 / 2; 75 | width: auto; 76 | white-space: nowrap; 77 | /* line-height: 1.5rem; */ 78 | } 79 | 80 | .path { 81 | grid-column: 2 / 3; 82 | 83 | span { 84 | display: inline-block; 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typedoc-theme-oxide", 3 | "version": "0.2.0", 4 | "description": "A TypeDoc theme looks just like rustdoc.", 5 | "repository": "github:balthild/typedoc-theme-oxide", 6 | "author": "Balthild ", 7 | "license": "MIT", 8 | "keywords": [ 9 | "rustdoc", 10 | "typedoc", 11 | "typedoc-theme", 12 | "typescript" 13 | ], 14 | "scripts": { 15 | "dev": "rollup -c -w", 16 | "build": "rollup -c", 17 | "clean": "rm -rf dist example/docs", 18 | "download": "node scripts/download-assets.js", 19 | "example:build": "cd example && typedoc --options ./typedoc.json", 20 | "example:deploy": "yarn build && yarn example:build && gh-pages -d ./example/docs", 21 | "release": "release-it" 22 | }, 23 | "packageManager": "yarn@4.9.1", 24 | "type": "module", 25 | "main": "dist/plugin/index.js", 26 | "files": [ 27 | "dist" 28 | ], 29 | "dependencies": { 30 | "cheerio": "^1.0.0", 31 | "flexsearch": "^0.8.164", 32 | "slug": "^10.0.0" 33 | }, 34 | "devDependencies": { 35 | "@rollup/plugin-node-resolve": "^16.0.1", 36 | "@rollup/plugin-terser": "^0.4.4", 37 | "@rollup/plugin-typescript": "^12.1.2", 38 | "@types/lodash-es": "^4.17.12", 39 | "@types/node": "^22.15.17", 40 | "@types/slug": "^5.0.9", 41 | "clean-css": "^5.3.3", 42 | "gh-pages": "^6.3.0", 43 | "http-server": "^14.1.1", 44 | "lit": "^3.3.0", 45 | "lodash-es": "^4.17.21", 46 | "monaco-editor": "^0.52.2", 47 | "postcss": "^8.5.3", 48 | "postcss-nested": "^7.0.2", 49 | "release-it": "^19.0.2", 50 | "rollup": "^4.40.2", 51 | "rollup-plugin-postcss": "^4.0.2", 52 | "terser": "^5.39.0", 53 | "tslib": "^2.8.1", 54 | "typedoc": "^0.28.4", 55 | "typescript": "^5.8.3", 56 | "undici": "^7.9.0" 57 | }, 58 | "peerDependencies": { 59 | "typedoc": "^0.28.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/plugin/context/mixins/container.tsx: -------------------------------------------------------------------------------- 1 | import { ContainerReflection, JSX, PageEvent, Reflection, ReflectionKind } from 'typedoc'; 2 | 3 | import { OxideContextBase } from '../base'; 4 | import { join } from '../utils'; 5 | 6 | export const ContainerMixin = (base: typeof OxideContextBase) => 7 | class extends base { 8 | reflectionTemplate = (page: PageEvent) => { 9 | const { model } = page; 10 | 11 | return ( 12 | <> 13 |
14 |
15 | {this.#breadcrumbs(page.model.parent)} 16 |
17 | 18 |

19 | {ReflectionKind.singularString(model.kind) + ' '} 20 | {model.name} 21 | {this.#generics(model)} 22 | 23 | 24 |

25 | 26 | 27 | 28 | {this.#source(model)} 29 |
30 | 31 | {model.hasComment() && ( 32 |
33 | 34 | Expand description 35 | 36 |
37 | {this.commentSummary(model)} 38 | {this.commentTags(model)} 39 |
40 |
41 | )} 42 | 43 | {this.members(model)} 44 | 45 | ); 46 | }; 47 | 48 | #breadcrumbs(model?: Reflection): JSX.Children[] { 49 | if (!model || model.isProject()) { 50 | return []; 51 | } 52 | 53 | if (model.kindOf(ReflectionKind.SomeSignature)) { 54 | return this.#breadcrumbs(model.parent); 55 | } 56 | 57 | const trail = [ 58 | ...this.#breadcrumbs(model.parent), 59 | {model.name}, 60 | ]; 61 | 62 | return join(['.', ], trail); 63 | } 64 | 65 | #generics(model: Reflection) { 66 | if (!model.isDeclaration() && !model.isSignature()) { 67 | return; 68 | } 69 | 70 | if (!model.typeParameters) { 71 | return; 72 | } 73 | 74 | return ['<', join(', ', model.typeParameters.map((x) => x.name)), '>']; 75 | } 76 | 77 | #source(model: Reflection) { 78 | const url = this.itemSourceLink(model); 79 | if (!url) { 80 | return; 81 | } 82 | 83 | return ( 84 | 85 | Source 86 | 87 | ); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /src/plugin/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import { promisify } from 'util'; 4 | import { deflate } from 'zlib'; 5 | 6 | import { Application, Reflection, RendererEvent } from 'typedoc'; 7 | 8 | import { createSearchDocument } from '../lib'; 9 | import { itemLink } from './context/utils'; 10 | import { OxideTheme } from './theme'; 11 | import { renderTextComment } from './utils'; 12 | 13 | declare module 'typedoc' { 14 | namespace JSX.JSX { 15 | interface IntrinsicElements { 16 | 'rustdoc-search': IntrinsicAttributes; 17 | 'rustdoc-toolbar': IntrinsicAttributes; 18 | 'oxide-search-results': IntrinsicAttributes; 19 | } 20 | 21 | interface IntrinsicAttributes { 22 | onclick?: string; 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * Called by TypeDoc when loading this theme as a plugin. Should be used to define themes which 29 | * can be selected by the user. 30 | */ 31 | export function load(app: Application) { 32 | app.renderer.defineTheme('oxide', OxideTheme); 33 | 34 | app.renderer.preRenderAsyncJobs.push(async (event: RendererEvent) => { 35 | const document = createSearchDocument(); 36 | 37 | app.renderer.router!.getLinkTargets() 38 | .filter((x) => x instanceof Reflection) 39 | .filter((x) => x.isDeclaration() || x.isDocument()) 40 | .filter((x) => x.name && !x.flags.isExternal) 41 | .forEach((item) => { 42 | try { 43 | document.add({ 44 | id: item.id, 45 | name: item.name, 46 | comment: renderTextComment(item), 47 | kind: item.kind, 48 | parent: item.parent?.isProject() ? '' : item.parent?.getFriendlyFullName() ?? '', 49 | url: itemLink(app.renderer.router!, item, false), 50 | }); 51 | } catch {} 52 | }); 53 | 54 | const parts: Record = {}; 55 | await document.export(async (key, data) => { 56 | app.logger.info(`Search index part: ${key}`); 57 | parts[key] = JSON.parse(data); 58 | }); 59 | 60 | const buffer = await promisify(deflate)(Buffer.from(JSON.stringify(parts))); 61 | 62 | const dest = path.join(event.outputDirectory, 'assets', 'oxide'); 63 | await fs.mkdir(dest, { recursive: true }); 64 | await fs.writeFile(path.join(dest, 'search-index.deflate'), buffer); 65 | }); 66 | 67 | app.renderer.postRenderAsyncJobs.push(async (event: RendererEvent) => { 68 | const src = path.join(import.meta.dirname, '..', 'assets'); 69 | const dest = path.join(event.outputDirectory, 'assets', 'oxide'); 70 | 71 | try { 72 | await fs.access(src, fs.constants.F_OK); 73 | await fs.access(path.join(src, 'rustdoc'), fs.constants.F_OK); 74 | await fs.mkdir(dest, { recursive: true }); 75 | await fs.cp(src, dest, { recursive: true }); 76 | } catch (e) { 77 | console.log(e); 78 | app.logger.error('Some front-end assets are missing.'); 79 | app.logger.error('User of the oxide theme should not see this, or I must did something silly.'); 80 | } 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import * as fs from 'fs/promises'; 3 | 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import terser from '@rollup/plugin-terser'; 6 | import typescript from '@rollup/plugin-typescript'; 7 | import { createServer } from 'http-server'; 8 | import nested from 'postcss-nested'; 9 | import { defineConfig } from 'rollup'; 10 | import postcss from 'rollup-plugin-postcss'; 11 | 12 | export default defineConfig((args) => [ 13 | // TypeDoc plugin 14 | { 15 | plugins: [ 16 | typescript(), 17 | resolve({ extensions: ['.ts', '.tsx', '.json', '.node'] }), 18 | args.watch && typedocExampleBuild(), 19 | ], 20 | input: 'src/plugin/index.ts', 21 | external: /(node_modules)/, 22 | watch: { 23 | clearScreen: false, 24 | }, 25 | output: { 26 | dir: 'dist/plugin', 27 | format: 'esm', 28 | sourcemap: true, 29 | interop: 'auto', 30 | preserveModules: true, 31 | preserveModulesRoot: 'src/plugin', 32 | }, 33 | }, 34 | 35 | // Frontend assets 36 | { 37 | plugins: [ 38 | typescript(), 39 | resolve({ extensions: ['.ts', '.tsx', '.json', '.node'] }), 40 | postcss({ extract: true, minimize: !args.watch, sourceMap: true, plugins: [nested()] }), 41 | !args.watch && terser(), 42 | args.watch && typedocExampleCopyAssets(), 43 | ], 44 | input: 'src/assets/index.ts', 45 | context: 'globalThis', 46 | watch: { 47 | clearScreen: false, 48 | }, 49 | output: { 50 | dir: 'dist/assets', 51 | format: 'iife', 52 | interop: 'auto', 53 | sourcemap: true, 54 | }, 55 | }, 56 | ]); 57 | 58 | /** @returns {import('rollup').Plugin} */ 59 | function typedocExampleBuild() { 60 | return { 61 | name: 'typedoc-example-build', 62 | async writeBundle() { 63 | const child = spawn('yarn', ['run', 'example:build'], { 64 | cwd: import.meta.dirname, 65 | stdio: 'inherit', 66 | }); 67 | await new Promise((resolve, reject) => { 68 | child.once('exit', resolve); 69 | child.once('error', reject); 70 | }); 71 | 72 | startServer(); 73 | }, 74 | }; 75 | } 76 | 77 | /** @returns {import('rollup').Plugin} */ 78 | function typedocExampleCopyAssets() { 79 | return { 80 | name: 'typedoc-example-copy-assets', 81 | async writeBundle() { 82 | const src = './dist/assets'; 83 | const dest = './example/docs/assets/oxide'; 84 | 85 | try { 86 | await fs.access(`${src}/rustdoc`, fs.constants.F_OK); 87 | await fs.cp(src, dest, { recursive: true }); 88 | } catch { 89 | this.warn('Rustdoc front-end assets are missing.'); 90 | this.warn('Please run `yarn download`.'); 91 | } 92 | 93 | startServer(); 94 | }, 95 | }; 96 | } 97 | 98 | function startServer() { 99 | if (!globalThis.httpServer) { 100 | globalThis.httpServer = createServer({ root: './example/docs' }); 101 | globalThis.httpServer.listen(3000); 102 | } 103 | 104 | console.log('┌──────────────────────────────────────────┐'); 105 | console.log('│ Serving example on http://localhost:3000 │'); 106 | console.log('└──────────────────────────────────────────┘'); 107 | } 108 | -------------------------------------------------------------------------------- /scripts/download-assets.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import { join } from 'path'; 3 | 4 | import CleanCSS from 'clean-css'; 5 | import { minify } from 'terser'; 6 | import { fetch } from 'undici'; 7 | 8 | const urlPrefix = 'https://cdn.jsdelivr.net/gh/rust-lang/rust@1.86.0/src/librustdoc/html/static'; 9 | const destPrefix = join(import.meta.dirname, '..', 'dist', 'assets', 'rustdoc'); 10 | 11 | await main(); 12 | 13 | async function main() { 14 | await fs.rm(destPrefix, { recursive: true, force: true }); 15 | await fs.mkdir(destPrefix, { recursive: true }); 16 | 17 | await downloadStyle(); 18 | await downloadMainJs(); 19 | await downloadSettingsJs(); 20 | } 21 | 22 | async function downloadStyle() { 23 | console.log('Downloading and processing rustdoc.css'); 24 | 25 | const src = `${urlPrefix}/css/rustdoc.css`; 26 | const dest = join(destPrefix, 'rustdoc.css'); 27 | 28 | const response = await fetch(src); 29 | let source = await response.text(); 30 | 31 | // The hash has a fixed length of 8 32 | // https://github.com/rust-lang/rust/blob/1.86.0/src/librustdoc/build.rs#L49 33 | source = source.replace( 34 | /url\("([\w\d\.-]+)(-[0-9a-f]{8})(\.woff2|\.ttf\.woff2)"\)/g, 35 | (match, name, hash, ext) => `url("${urlPrefix}/fonts/${name}${ext}")`, 36 | ); 37 | 38 | source = [ 39 | `/**`, 40 | ` * Patched by typedoc-theme-oxide. Minified with clean-css.`, 41 | ` * Original file: ${src}`, 42 | ` */`, 43 | new CleanCSS().minify(source).styles, 44 | ].join('\n'); 45 | 46 | await fs.writeFile(dest, source); 47 | } 48 | 49 | async function downloadMainJs() { 50 | console.log('Downloading and processing main.js'); 51 | 52 | const src = `${urlPrefix}/js/main.js`; 53 | const dest = join(destPrefix, 'main.js'); 54 | 55 | const response = await fetch(src); 56 | let source = await response.text(); 57 | 58 | source = source.replace( 59 | 'function sendSearchForm() {', 60 | 'function sendSearchForm() { return;', 61 | ); 62 | 63 | // source = source.replace( 64 | // 'titleElement.textContent.replace(" - Rust", "")', 65 | // 'titleElement.textContent.replace(/ - .+$/, "")', 66 | // ); 67 | 68 | source = source.replace( 69 | 'const [item, module] = document.title.split(" in ");', 70 | 'const [item, module] = document.title.split(" - ")[0].split(" in ");', 71 | ); 72 | 73 | source = source.replace( 74 | 'copyContentToClipboard(path.join("::"));', 75 | 'copyContentToClipboard(path.join("."));', 76 | ); 77 | 78 | source = [ 79 | `/**`, 80 | ` * Patched by typedoc-theme-oxide. Minified with terser.`, 81 | ` * Original file: ${src}`, 82 | ` */`, 83 | (await minify(source)).code, 84 | ].join('\n'); 85 | 86 | await fs.writeFile(dest, source); 87 | } 88 | 89 | async function downloadSettingsJs() { 90 | console.log('Downloading and processing settings.js'); 91 | 92 | const src = `${urlPrefix}/js/settings.js`; 93 | const dest = join(destPrefix, 'settings.js'); 94 | 95 | const response = await fetch(src); 96 | let source = await response.text(); 97 | 98 | source = [ 99 | `/**`, 100 | ` * Patched by typedoc-theme-oxide. Minified with terser.`, 101 | ` * Original file: ${src}`, 102 | ` */`, 103 | (await minify(source)).code, 104 | ].join('\n'); 105 | 106 | await fs.writeFile(dest, source); 107 | } 108 | -------------------------------------------------------------------------------- /src/plugin/context/mixins/navigation.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ContainerReflection, 3 | JSX, 4 | PageEvent, 5 | Reflection, 6 | ReflectionCategory, 7 | ReflectionGroup, 8 | ReflectionKind, 9 | } from 'typedoc'; 10 | 11 | import { OxideContextBase } from '../base'; 12 | import { isNestedTable, partition, ReflectionSection, ReflectionWithLink } from '../utils'; 13 | 14 | export const NavigationMixin = (base: typeof OxideContextBase) => 15 | class extends base { 16 | navigation = (page: PageEvent) => { 17 | const { model } = page; 18 | 19 | return ( 20 | <> 21 | 31 | 32 | ); 33 | }; 34 | 35 | #modules(model: Reflection) { 36 | const parent = model.parent; 37 | if (parent instanceof ContainerReflection === false) { 38 | return; 39 | } 40 | 41 | const siblings = parent.getChildrenByKind(ReflectionKind.SomeModule); 42 | 43 | return ( 44 |
45 | 60 |
61 | ); 62 | } 63 | 64 | #sections(model: Reflection) { 65 | if (model instanceof ContainerReflection === false) { 66 | return; 67 | } 68 | 69 | const [tables1, categories] = partition(model.categories ?? [], isNestedTable); 70 | const [tables2, groups] = partition(model.groups ?? [], isNestedTable); 71 | 72 | const [modules, tables] = partition( 73 | [...tables1, ...tables2], 74 | (x) => x.children.every((x) => x.kindOf(ReflectionKind.ExportContainer)), 75 | ); 76 | 77 | return [ 78 | modules.map((x) => this.#table(x, true)), 79 | categories.map((x) => this.#category(x)), 80 | groups.map((x) => this.#group(x)), 81 | tables.map((x) => this.#table(x, true)), 82 | ]; 83 | } 84 | 85 | #category(category: ReflectionCategory) { 86 | return this.#table(category, false); 87 | } 88 | 89 | #group(group: ReflectionGroup) { 90 | if (group.categories) { 91 | return group.categories.map((x) => this.#category(x)); 92 | } 93 | 94 | return this.#table(group, false); 95 | } 96 | 97 | #table(section: ReflectionSection, forceNested: boolean) { 98 | const anchor = this.sectionSlug(section); 99 | 100 | return ( 101 | <> 102 |

103 | {section.title} 104 |

105 |
    106 | {section.children.map((x) => this.#item(x, forceNested))} 107 |
108 | 109 | ); 110 | } 111 | 112 | #item(item: ReflectionWithLink, forceNested: boolean) { 113 | return ( 114 |
  • 115 | {item.name} 116 |
  • 117 | ); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /src/plugin/context/utils.tsx: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import slug from 'slug'; 3 | import { 4 | BaseRouter, 5 | DeclarationReflection, 6 | DefaultThemeRenderContext, 7 | DocumentReflection, 8 | JSX, 9 | Reflection, 10 | ReflectionCategory, 11 | ReflectionGroup, 12 | ReflectionKind, 13 | Router, 14 | } from 'typedoc'; 15 | 16 | export type ReflectionSection = ReflectionGroup | ReflectionCategory; 17 | export type ReflectionWithLink = DeclarationReflection | DocumentReflection; 18 | export type URLFactory = Router | DefaultThemeRenderContext; 19 | 20 | export function isNestedTable(section: ReflectionSection) { 21 | return section.children.every(isNestedItem); 22 | } 23 | 24 | export function isNestedItem(item: ReflectionWithLink) { 25 | return item.kindOf([ 26 | ReflectionKind.ExportContainer, 27 | ReflectionKind.Interface, 28 | ReflectionKind.Class, 29 | ReflectionKind.Enum, 30 | ]); 31 | } 32 | 33 | export function transformElement( 34 | children: JSX.Children, 35 | transformer: (_: JSX.Element) => JSX.Element, 36 | ): JSX.Children { 37 | if (Array.isArray(children)) { 38 | return children.map((x) => transformElement(x, transformer)); 39 | } 40 | 41 | if (typeof children !== 'object' || children === null) { 42 | return children; 43 | } 44 | 45 | return { 46 | ...transformer(children), 47 | children: children.children.map((x) => transformElement(x, transformer)), 48 | }; 49 | } 50 | 51 | export function transformTypography(html: string): string { 52 | const $ = cheerio.load(html, null, false); 53 | 54 | $('h1').each((_, el) => { 55 | $(el).wrapInner('

    ').children(':first-child').unwrap(); 56 | }); 57 | 58 | $('a').find('>h1, >h2, >h3, >h4, >h5, >h6').each((_, el) => { 59 | const $heading = $(el); 60 | const $outerAnchor = $heading.parent(); 61 | const $innerAnchor = $(''); 62 | 63 | $outerAnchor.after($heading); 64 | $outerAnchor.remove(); 65 | 66 | $innerAnchor.attr('href', $outerAnchor.attr('href')); 67 | $innerAnchor.append($heading.contents()); 68 | 69 | $heading.attr('id', $outerAnchor.attr('id')); 70 | $heading.append($innerAnchor); 71 | }); 72 | 73 | return $.html(); 74 | } 75 | 76 | export function join(joiner: JSX.Children, list: readonly JSX.Children[]) { 77 | const result = []; 78 | 79 | for (const item of list) { 80 | if (result.length > 0) { 81 | result.push(joiner); 82 | } 83 | result.push(item); 84 | } 85 | 86 | return result; 87 | } 88 | 89 | export function breakable(str: string) { 90 | const sep = /([^0-9A-Za-z]+|[0-9]+|(?<=[a-z])(?=[A-Z]))/; 91 | const pieces = str.split(sep).filter((x) => x.length); 92 | return join(, pieces); 93 | } 94 | 95 | export function partition(items: T[], predicate: (_: T) => boolean): [T[], T[]] { 96 | const satisfied = []; 97 | const unsatisfied = []; 98 | 99 | for (const item of items) { 100 | if (predicate(item)) { 101 | satisfied.push(item); 102 | } else { 103 | unsatisfied.push(item); 104 | } 105 | } 106 | 107 | return [satisfied, unsatisfied]; 108 | } 109 | 110 | export const getUrl = (factory: URLFactory, item: Reflection) => { 111 | if (factory instanceof DefaultThemeRenderContext) { 112 | return factory.urlTo(item)!; 113 | } 114 | 115 | if (factory instanceof BaseRouter) { 116 | return factory.getFullUrl(item); 117 | } 118 | 119 | throw new Error('Unknown URL factory type'); 120 | }; 121 | 122 | export function sectionSlug(section: ReflectionSection) { 123 | const title = slug(section.title); 124 | return `section.${title}`; 125 | } 126 | 127 | export function itemSlug(item: ReflectionWithLink) { 128 | const kind = ReflectionKind.classString(item.kind).replace('tsd-kind-', ''); 129 | const name = slug(item.name); 130 | return `${kind}.${name}`; 131 | } 132 | 133 | export function itemLink(factory: URLFactory, item: ReflectionWithLink, forceNested: boolean) { 134 | if (forceNested || !item.parent || isNestedItem(item)) { 135 | return getUrl(factory, item); 136 | } 137 | 138 | const url = getUrl(factory, item.parent); 139 | const anchor = itemSlug(item); 140 | if (typeof url !== 'undefined') { 141 | return `${url}#${anchor}`; 142 | } 143 | 144 | return getUrl(factory, item); 145 | } 146 | -------------------------------------------------------------------------------- /src/assets/index.ts: -------------------------------------------------------------------------------- 1 | import './style.css'; 2 | 3 | import { Document } from 'flexsearch'; 4 | import { html, LitElement } from 'lit'; 5 | import { customElement, property } from 'lit/decorators.js'; 6 | import { map } from 'lit/directives/map.js'; 7 | import { throttle } from 'lodash-es'; 8 | import { ReflectionKind } from 'typedoc/models'; 9 | 10 | import { SearchItem } from '../lib/types'; 11 | import { getReflectionKindName, loadSearchIndex } from './data'; 12 | 13 | document.addEventListener('DOMContentLoaded', async () => { 14 | const form = document.querySelector('rustdoc-search .search-form') as HTMLFormElement; 15 | const input = document.querySelector('rustdoc-search .search-input') as HTMLInputElement; 16 | 17 | const main = document.getElementById('main-content')!; 18 | const alt = document.getElementById('alternative-display')!; 19 | 20 | form.addEventListener('submit', (event) => { 21 | event.preventDefault(); 22 | event.stopPropagation(); 23 | }); 24 | 25 | let index: Promise>; 26 | let controller: AbortController; 27 | 28 | input.addEventListener( 29 | 'input', 30 | throttle(async (event) => { 31 | if (!index) { 32 | index = loadSearchIndex(); 33 | } 34 | 35 | const query = event.target.value?.trim(); 36 | 37 | const [hide, show] = query ? [main, alt] : [alt, main]; 38 | hide.classList.add('hidden'); 39 | show.classList.remove('hidden'); 40 | 41 | controller?.abort(); 42 | controller = new AbortController(); 43 | const signal = controller.signal; 44 | 45 | await performSearch(query, signal, await index); 46 | }, 300), 47 | ); 48 | }); 49 | 50 | async function performSearch(query: string, signal: AbortSignal, index: Document) { 51 | if (signal.aborted || !query.length) { 52 | return; 53 | } 54 | 55 | const items = await index.search({ query, enrich: true, merge: true }); 56 | if (signal.aborted) { 57 | return; 58 | } 59 | 60 | const vars = document.querySelector('meta[name="rustdoc-vars"]') as HTMLMetaElement; 61 | const results = document.querySelector('oxide-search-results#search') as OxideSearchResults; 62 | 63 | results.loading = false; 64 | results.project = vars.dataset.currentCrate!; 65 | results.query = query; 66 | results.items = items.map((x) => x.doc).filter((x) => x !== null && x !== undefined); 67 | } 68 | 69 | @customElement('oxide-search-results') 70 | class OxideSearchResults extends LitElement { 71 | static base = document.documentElement.dataset.base!; 72 | 73 | static classes = { 74 | [ReflectionKind.ClassMember]: 'fn', 75 | [ReflectionKind.Function]: 'fn', 76 | [ReflectionKind.Property]: 'fn', 77 | [ReflectionKind.Method]: 'fn', 78 | [ReflectionKind.CallSignature]: 'fn', 79 | [ReflectionKind.IndexSignature]: 'fn', 80 | [ReflectionKind.GetSignature]: 'fn', 81 | [ReflectionKind.SetSignature]: 'fn', 82 | [ReflectionKind.Accessor]: 'fn', 83 | [ReflectionKind.ConstructorSignature]: 'primitive', 84 | [ReflectionKind.Constructor]: 'primitive', 85 | [ReflectionKind.EnumMember]: 'macro', 86 | [ReflectionKind.Variable]: 'macro', 87 | } as Record; 88 | 89 | @property({ attribute: false }) 90 | accessor loading: boolean = true; 91 | 92 | @property({ attribute: false }) 93 | accessor project: string = ''; 94 | 95 | @property({ attribute: false }) 96 | accessor query: string = ''; 97 | 98 | @property({ attribute: false }) 99 | accessor items: SearchItem[] = []; 100 | 101 | createRenderRoot() { 102 | // Disable Shadow DOM. 103 | return this; 104 | } 105 | 106 | render() { 107 | if (this.loading) { 108 | return html` 109 |

    Loading search results...

    110 | `; 111 | } 112 | 113 | const items = this.items.map((item) => { 114 | const classname = OxideSearchResults.classes[item.kind] ?? 'mod'; 115 | const trace = map( 116 | item.parent?.split('.').filter((x) => x) ?? [], 117 | (name) => html`${name}.`, 118 | ); 119 | 120 | return html` 121 | 122 | 123 | 124 | ${getReflectionKindName(item.kind)} 125 | 126 | 127 |
    128 | ${trace}${item.name} 129 |
    130 |
    131 |
    132 | `; 133 | }); 134 | 135 | const ddgQuery = encodeURIComponent(`${this.project} ${this.query}`); 136 | 137 | return html` 138 |
    139 |

    Results

    140 |
    141 | 142 |
    143 |
    144 | ${items} 145 |
    146 | 147 |
    148 | No results :( 149 |
    150 | Try on DuckDuckGo? 151 |
    152 |
    153 | `; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/plugin/context/mixins/layout.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, PageEvent, Reflection, RenderTemplate } from 'typedoc'; 2 | 3 | import { OxideContextBase } from '../base'; 4 | 5 | type Template = RenderTemplate>; 6 | 7 | export const LayoutMixin = (base: typeof OxideContextBase) => 8 | class extends base { 9 | defaultLayout = (template: Template, page: PageEvent) => { 10 | return ( 11 | 12 | {this.#head(page)} 13 | 14 | 15 | 16 |
    17 | This old browser is unsupported and will most likely display funky things. 18 |
    19 | 20 | 21 | {this.hook('body.begin', this)} 22 | 23 | {this.#topbar(page)} 24 | {this.#sidebar(page)} 25 | {this.#main(template, page)} 26 | 27 | {this.hook('body.end', this)} 28 | 29 | 30 | ); 31 | }; 32 | 33 | #head(page: PageEvent) { 34 | const { model, project } = page; 35 | 36 | let title = model.name; 37 | if (model.parent && !model.parent.isProject()) { 38 | title = `${title} in ${model.parent.getFriendlyFullName()}`; 39 | } 40 | if (!model.isProject()) { 41 | title = `${title} - ${project.name}`; 42 | } 43 | 44 | const fonts = [ 45 | 'fonts/SourceSerif4-Regular.ttf.woff2', 46 | 'fonts/FiraSans-Regular.woff2', 47 | 'fonts/FiraSans-Medium.woff2', 48 | 'fonts/SourceCodePro-Regular.ttf.woff2', 49 | 'fonts/SourceSerif4-Bold.ttf.woff2', 50 | 'fonts/SourceCodePro-Semibold.ttf.woff2', 51 | ]; 52 | 53 | return ( 54 | 55 | 56 | {this.hook('head.begin', this)} 57 | 58 | {title} 59 | 60 | 61 | 62 | {fonts.map((font) => ( 63 | 70 | ))} 71 | 72 | 73 | 78 | 79 | 91 | 92 | 93 | 94 | 97 | 98 | 99 | 100 | 101 | 102 | {this.options.getValue('customCss') && } 103 | {this.hook('head.end', this)} 104 | 105 | ); 106 | } 107 | 108 | #topbar(page: PageEvent) { 109 | const { project } = page; 110 | 111 | return ( 112 | 118 | ); 119 | } 120 | 121 | #sidebar(page: PageEvent) { 122 | const { project } = page; 123 | 124 | return ( 125 | <> 126 | 138 |