Merge branch 'ethdoc-plugin' of https://github.com/ethereum/remix-project into testethdoc
commit
a4d50ea7fc
@ -0,0 +1,34 @@ |
||||
{ |
||||
"extends": [ |
||||
"plugin:@nrwl/nx/react", |
||||
"../../.eslintrc.json" |
||||
], |
||||
"ignorePatterns": [ |
||||
"!**/*" |
||||
], |
||||
"overrides": [ |
||||
{ |
||||
"files": [ |
||||
"*.ts", |
||||
"*.tsx", |
||||
"*.js", |
||||
"*.jsx" |
||||
], |
||||
"rules": {} |
||||
}, |
||||
{ |
||||
"files": [ |
||||
"*.ts", |
||||
"*.tsx" |
||||
], |
||||
"rules": {} |
||||
}, |
||||
{ |
||||
"files": [ |
||||
"*.js", |
||||
"*.jsx" |
||||
], |
||||
"rules": {} |
||||
} |
||||
] |
||||
} |
@ -0,0 +1,13 @@ |
||||
{ |
||||
"semi": false, |
||||
"singleQuote": true, |
||||
"trailingComma": "all", |
||||
"printWidth": 120, |
||||
"tabWidth": 2, |
||||
"useTabs": false, |
||||
"arrowParens": "avoid", |
||||
"bracketSpacing": true, |
||||
"jsxBracketSameLine": false, |
||||
"jsxSingleQuote": false, |
||||
"endOfLine": "lf" |
||||
} |
@ -0,0 +1,59 @@ |
||||
{ |
||||
"name": "doc-gen", |
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json", |
||||
"sourceRoot": "apps/doc-gen/src", |
||||
"projectType": "application", |
||||
"implicitDependencies": [ |
||||
], |
||||
"targets": { |
||||
"build": { |
||||
"executor": "@nrwl/webpack:webpack", |
||||
"outputs": ["{options.outputPath}"], |
||||
"defaultConfiguration": "development", |
||||
"options": { |
||||
"compiler": "babel", |
||||
"outputPath": "dist/apps/doc-gen", |
||||
"index": "apps/doc-gen/src/index.html", |
||||
"baseHref": "/", |
||||
"main": "apps/doc-gen/src/main.tsx", |
||||
"tsConfig": "apps/doc-gen/tsconfig.app.json", |
||||
"assets": [ |
||||
"apps/doc-gen/src/favicon.ico" |
||||
], |
||||
"styles": [], |
||||
"scripts": [], |
||||
"webpackConfig": "apps/doc-gen/webpack.config.js" |
||||
}, |
||||
"configurations": { |
||||
"development": { |
||||
}, |
||||
"production": { |
||||
"fileReplacements": [ |
||||
{ |
||||
"replace": "apps/doc-gen/src/environments/environment.ts", |
||||
"with": "apps/doc-gen/src/environments/environment.prod.ts" |
||||
} |
||||
] |
||||
} |
||||
} |
||||
}, |
||||
"serve": { |
||||
"executor": "@nrwl/webpack:dev-server", |
||||
"defaultConfiguration": "development", |
||||
"options": { |
||||
"buildTarget": "doc-gen:build", |
||||
"hmr": true |
||||
}, |
||||
"configurations": { |
||||
"development": { |
||||
"buildTarget": "doc-gen:build:development", |
||||
"port": 6003 |
||||
}, |
||||
"production": { |
||||
"buildTarget": "doc-gen:build:production" |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
"tags": [] |
||||
} |
@ -0,0 +1,3 @@ |
||||
body { |
||||
margin: 0; |
||||
} |
@ -0,0 +1,42 @@ |
||||
import React, { useState, useEffect } from 'react' |
||||
import { |
||||
CompilationResult, |
||||
} from '@remixproject/plugin-api/' |
||||
|
||||
import './App.css' |
||||
import { DocGenClient } from './docgen-client' |
||||
import { Build } from './docgen/site' |
||||
|
||||
export const client = new DocGenClient() |
||||
|
||||
const App = () => { |
||||
const [themeType, setThemeType] = useState<string>('dark'); |
||||
const [hasBuild, setHasBuild] = useState<boolean>(false); |
||||
const [fileName, setFileName] = useState<string>(''); |
||||
|
||||
useEffect(() => { |
||||
const watchThemeSwitch = async () => { |
||||
client.eventEmitter.on('themeChanged', (theme: string) => { |
||||
setThemeType(theme) |
||||
}) |
||||
client.eventEmitter.on('compilationFinished', (build: Build, fileName: string) => { |
||||
setHasBuild(true) |
||||
setFileName(fileName) |
||||
}) |
||||
client.eventEmitter.on('docsGenerated', (docs: string[]) => { |
||||
console.log('docsGenerated', docs) |
||||
}) |
||||
} |
||||
watchThemeSwitch() |
||||
}, []) |
||||
|
||||
return ( |
||||
<div className="p-3"> |
||||
<h1>Remix Docgen</h1> |
||||
{fileName && <h4>File: {fileName.split('/')[1].split('.')[0].concat('.sol')}</h4>} |
||||
{hasBuild && <button className="btn btn-primary btn-block mt-4 rounded" onClick={() => client.generateDocs()}>Generate doc</button>} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
export default App |
@ -0,0 +1,80 @@ |
||||
import { PluginClient } from '@remixproject/plugin' |
||||
import { CompilationResult, SourceWithTarget } from '@remixproject/plugin-api' |
||||
import { createClient } from '@remixproject/plugin-webview' |
||||
import EventEmitter from 'events' |
||||
import { Config, defaults } from './docgen/config' |
||||
import { Build, buildSite } from './docgen/site' |
||||
import { loadTemplates } from './docgen/templates' |
||||
import { SolcInput, SolcOutput } from 'solidity-ast/solc' |
||||
import { render } from './docgen/render' |
||||
|
||||
export class DocGenClient extends PluginClient { |
||||
private currentTheme |
||||
public eventEmitter: EventEmitter |
||||
private build: Build |
||||
public docs: string[] = [] |
||||
private fileName: string = '' |
||||
|
||||
constructor() { |
||||
super() |
||||
this.eventEmitter = new EventEmitter() |
||||
this.methods = ['generateDocs', 'opendDocs'] |
||||
createClient(this) |
||||
this.onload().then(async () => { |
||||
await this.setListeners() |
||||
}) |
||||
} |
||||
|
||||
async setListeners() { |
||||
this.currentTheme = await this.call('theme', 'currentTheme') |
||||
|
||||
this.on('theme', 'themeChanged', (theme: any) => { |
||||
this.currentTheme = theme |
||||
this.eventEmitter.emit('themeChanged', this.currentTheme) |
||||
}); |
||||
this.eventEmitter.emit('themeChanged', this.currentTheme) |
||||
|
||||
this.on('solidity', 'compilationFinished', (fileName: string, source: SourceWithTarget, languageVersion: string, data: CompilationResult) => { |
||||
const input: SolcInput = { |
||||
sources: source.sources |
||||
} |
||||
const output: SolcOutput = { |
||||
sources: data.sources as any |
||||
} |
||||
this.build = { |
||||
input: input, |
||||
output: output |
||||
} |
||||
this.fileName = fileName |
||||
this.eventEmitter.emit('compilationFinished', this.build, fileName) |
||||
}) |
||||
} |
||||
|
||||
async docgen(builds: Build[], userConfig?: Config): Promise<void> { |
||||
const config = { ...defaults, ...userConfig } |
||||
const templates = await loadTemplates(config.theme, config.root, config.templates) |
||||
const site = buildSite(builds, config, templates.properties ?? {}) |
||||
const renderedSite = render(site, templates, config.collapseNewlines) |
||||
const docs: string[] = [] |
||||
for (const { id, contents } of renderedSite) { |
||||
const temp = `${this.fileName.split('/')[1].split('.')[0]}.${id.split('.')[1]}` |
||||
const newFileName = `docs/${temp}` |
||||
await this.call('fileManager', 'setFile', newFileName , contents) |
||||
docs.push(newFileName) |
||||
} |
||||
this.eventEmitter.emit('docsGenerated', docs) |
||||
this.emit('docgen' as any, 'docsGenerated', docs) |
||||
this.docs = docs |
||||
await this.opendDocs(docs) |
||||
} |
||||
|
||||
async opendDocs(docs: string[]) { |
||||
await this.call('manager', 'activatePlugin', 'doc-viewer') |
||||
await this.call('tabs' as any, 'focus', 'doc-viewer') |
||||
await this.call('doc-viewer' as any, 'viewDocs', docs) |
||||
} |
||||
|
||||
async generateDocs() { |
||||
this.docgen([this.build]) |
||||
} |
||||
} |
@ -0,0 +1,22 @@ |
||||
import { VariableDeclaration } from "solidity-ast"; |
||||
|
||||
export function trim(text: string) { |
||||
if (typeof text === 'string') { |
||||
return text.trim(); |
||||
} |
||||
} |
||||
|
||||
export function joinLines(text?: string) { |
||||
if (typeof text === 'string') { |
||||
return text.replace(/\n+/g, ' '); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Format a variable as its type followed by its name, if available. |
||||
*/ |
||||
export function formatVariable(v: VariableDeclaration): string { |
||||
return [v.typeName?.typeDescriptions.typeString].concat(v.name || []).join(' '); |
||||
} |
||||
|
||||
export const eq = (a: unknown, b: unknown) => a === b; |
@ -0,0 +1,138 @@ |
||||
import { EnumDefinition, ErrorDefinition, EventDefinition, FunctionDefinition, ModifierDefinition, ParameterList, StructDefinition, UserDefinedValueTypeDefinition, VariableDeclaration } from 'solidity-ast'; |
||||
import { findAll, isNodeType } from 'solidity-ast/utils'; |
||||
import { NatSpec, parseNatspec } from '../utils/natspec'; |
||||
import { DocItemContext, DOC_ITEM_CONTEXT } from '../site'; |
||||
import { mapValues } from '../utils/map-values'; |
||||
import { DocItem, docItemTypes } from '../doc-item'; |
||||
import { formatVariable } from './helpers'; |
||||
import { PropertyGetter } from '../templates'; |
||||
import { itemType } from '../utils/item-type'; |
||||
|
||||
type TypeDefinition = StructDefinition | EnumDefinition | UserDefinedValueTypeDefinition; |
||||
|
||||
export function type ({ item }: DocItemContext): string { |
||||
return itemType(item); |
||||
} |
||||
|
||||
export function natspec ({ item }: DocItemContext): NatSpec { |
||||
return parseNatspec(item); |
||||
} |
||||
|
||||
export function name({ item }: DocItemContext, original?: unknown): string { |
||||
if (item.nodeType === 'FunctionDefinition') { |
||||
return item.kind === 'function' ? original as string : item.kind; |
||||
} else { |
||||
return original as string; |
||||
} |
||||
} |
||||
|
||||
export function fullName ({ item, contract }: DocItemContext): string { |
||||
if (contract) { |
||||
return `${contract.name}.${item.name}`; |
||||
} else { |
||||
return `${item.name}`; |
||||
} |
||||
} |
||||
|
||||
export function signature ({ item }: DocItemContext): string | undefined { |
||||
switch (item.nodeType) { |
||||
case 'ContractDefinition': |
||||
return undefined; |
||||
|
||||
case 'FunctionDefinition': { |
||||
const { kind, name } = item; |
||||
const params = item.parameters.parameters; |
||||
const returns = item.returnParameters.parameters; |
||||
const head = (kind === 'function' || kind === 'freeFunction') ? [kind, name].join(' ') : kind; |
||||
const res = [ |
||||
`${head}(${params.map(formatVariable).join(', ')})`, |
||||
item.visibility, |
||||
]; |
||||
if (item.stateMutability !== 'nonpayable') { |
||||
res.push(item.stateMutability); |
||||
} |
||||
if (item.virtual) { |
||||
res.push('virtual'); |
||||
} |
||||
if (returns.length > 0) { |
||||
res.push(`returns (${returns.map(formatVariable).join(', ')})`); |
||||
} |
||||
return res.join(' '); |
||||
} |
||||
|
||||
case 'EventDefinition': { |
||||
const params = item.parameters.parameters; |
||||
return `event ${item.name}(${params.map(formatVariable).join(', ')})`; |
||||
} |
||||
|
||||
case 'ErrorDefinition': { |
||||
const params = item.parameters.parameters; |
||||
return `error ${item.name}(${params.map(formatVariable).join(', ')})`; |
||||
} |
||||
|
||||
case 'ModifierDefinition': { |
||||
const params = item.parameters.parameters; |
||||
return `modifier ${item.name}(${params.map(formatVariable).join(', ')})`; |
||||
} |
||||
|
||||
case 'VariableDeclaration': |
||||
return formatVariable(item); |
||||
} |
||||
} |
||||
|
||||
interface Param extends VariableDeclaration { |
||||
type: string; |
||||
natspec?: string; |
||||
}; |
||||
|
||||
function getParams (params: ParameterList, natspec: NatSpec['params'] | NatSpec['returns']): Param[] { |
||||
return params.parameters.map((p, i) => ({ |
||||
...p, |
||||
type: p.typeDescriptions.typeString!, |
||||
natspec: natspec?.find((q, j) => q.name === undefined ? i === j : p.name === q.name)?.description, |
||||
})); |
||||
} |
||||
|
||||
export function params ({ item }: DocItemContext): Param[] | undefined { |
||||
if ('parameters' in item) { |
||||
return getParams(item.parameters, natspec(item[DOC_ITEM_CONTEXT]).params); |
||||
} |
||||
} |
||||
|
||||
export function returns ({ item }: DocItemContext): Param[] | undefined { |
||||
if ('returnParameters' in item) { |
||||
return getParams(item.returnParameters, natspec(item[DOC_ITEM_CONTEXT]).returns); |
||||
} |
||||
} |
||||
|
||||
export function items ({ item }: DocItemContext): DocItem[] | undefined { |
||||
return (item.nodeType === 'ContractDefinition') |
||||
? item.nodes.filter(isNodeType(docItemTypes)).filter(n => !('visibility' in n) || n.visibility !== 'private') |
||||
: undefined; |
||||
} |
||||
|
||||
export function functions ({ item }: DocItemContext): FunctionDefinition[] | undefined { |
||||
return [...findAll('FunctionDefinition', item)].filter(f => f.visibility !== 'private'); |
||||
} |
||||
|
||||
export function events ({ item }: DocItemContext): EventDefinition[] | undefined { |
||||
return [...findAll('EventDefinition', item)]; |
||||
} |
||||
|
||||
export function modifiers ({ item }: DocItemContext): ModifierDefinition[] | undefined { |
||||
return [...findAll('ModifierDefinition', item)]; |
||||
} |
||||
|
||||
export function errors ({ item }: DocItemContext): ErrorDefinition[] | undefined { |
||||
return [...findAll('ErrorDefinition', item)]; |
||||
} |
||||
|
||||
export function variables ({ item }: DocItemContext): VariableDeclaration[] | undefined { |
||||
return (item.nodeType === 'ContractDefinition') |
||||
? item.nodes.filter(isNodeType('VariableDeclaration')).filter(v => v.stateVariable && v.visibility !== 'private') |
||||
: undefined; |
||||
} |
||||
|
||||
export function types ({ item }: DocItemContext): TypeDefinition[] | undefined { |
||||
return [...findAll(['StructDefinition', 'EnumDefinition', 'UserDefinedValueTypeDefinition'], item)]; |
||||
} |
@ -0,0 +1,84 @@ |
||||
import type { SourceUnit } from 'solidity-ast'; |
||||
import type { DocItem } from './doc-item'; |
||||
import type { PageAssigner, PageStructure } from './site'; |
||||
|
||||
export interface UserConfig { |
||||
/** |
||||
* The directory where rendered pages will be written. |
||||
* Defaults to 'docs'. |
||||
*/ |
||||
outputDir?: string; |
||||
|
||||
/** |
||||
* A directory of custom templates that should take precedence over the |
||||
* theme's templates. |
||||
*/ |
||||
templates?: string; |
||||
|
||||
/** |
||||
* The name of the built-in templates that will be used by default. |
||||
* Defaults to 'markdown'. |
||||
*/ |
||||
theme?: string; |
||||
|
||||
/** |
||||
* The way documentable items (contracts, functions, custom errors, etc.) |
||||
* will be organized in pages. Built in options are: |
||||
* - 'single': all items in one page |
||||
* - 'items': one page per item |
||||
* - 'files': one page per input Solidity file |
||||
* More customization is possible by defining a function that returns a page |
||||
* path given the AST node for the item and the source unit where it is |
||||
* defined. |
||||
* Defaults to 'single'. |
||||
*/ |
||||
pages?: 'single' | 'items' | 'files' | PageAssigner; |
||||
|
||||
/** |
||||
* An array of sources subdirectories that should be excluded from |
||||
* documentation, relative to the contract sources directory. |
||||
*/ |
||||
exclude?: string[]; |
||||
|
||||
/** |
||||
* Clean up the output by collapsing 3 or more contiguous newlines into only 2. |
||||
* Enabled by default. |
||||
*/ |
||||
collapseNewlines?: boolean; |
||||
|
||||
/** |
||||
* The extension for generated pages. |
||||
* Defaults to '.md'. |
||||
*/ |
||||
pageExtension?: string; |
||||
} |
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Other config parameters that will be provided by the environment (e.g. Hardhat)
|
||||
// rather than by the user manually, unless using the library directly.
|
||||
export interface Config extends UserConfig { |
||||
/** |
||||
* The root directory relative to which 'outputDir', 'sourcesDir', and |
||||
* 'templates' are specified. Defaults to the working directory. |
||||
*/ |
||||
root?: string; |
||||
|
||||
/** |
||||
* The Solidity sources directory. |
||||
*/ |
||||
sourcesDir?: string; |
||||
} |
||||
|
||||
export type FullConfig = Required<Config>; |
||||
|
||||
export const defaults: Omit<FullConfig, 'templates'> = { |
||||
root: process.cwd(), |
||||
sourcesDir: 'contracts', |
||||
outputDir: 'docs', |
||||
pages: 'single', |
||||
exclude: [], |
||||
theme: 'markdown', |
||||
collapseNewlines: true, |
||||
pageExtension: '.md', |
||||
}; |
@ -0,0 +1,27 @@ |
||||
import { ContractDefinition, ImportDirective, PragmaDirective, SourceUnit, UsingForDirective } from "solidity-ast"; |
||||
import { Node, NodeType, NodeTypeMap } from "solidity-ast/node"; |
||||
import { AssertEqual } from "./utils/assert-equal-types"; |
||||
|
||||
export type DocItem = Exclude< |
||||
SourceUnit['nodes'][number] | ContractDefinition['nodes'][number], |
||||
ImportDirective | PragmaDirective | UsingForDirective |
||||
>; |
||||
|
||||
export const docItemTypes = [ |
||||
'ContractDefinition', |
||||
'EnumDefinition', |
||||
'ErrorDefinition', |
||||
'EventDefinition', |
||||
'FunctionDefinition', |
||||
'ModifierDefinition', |
||||
'StructDefinition', |
||||
'UserDefinedValueTypeDefinition', |
||||
'VariableDeclaration', |
||||
] as const; |
||||
|
||||
// Make sure at compile time that docItemTypes contains exactly the node types of DocItem.
|
||||
const _: AssertEqual<typeof docItemTypes[number], DocItem['nodeType']> = true; |
||||
|
||||
export function isDocItem(node: Node): node is DocItem { |
||||
return (docItemTypes as readonly string[]).includes(node.nodeType); |
||||
} |
@ -0,0 +1,87 @@ |
||||
import Handlebars, { RuntimeOptions } from 'handlebars'; |
||||
import { Site, Page, DocItemWithContext, DOC_ITEM_CONTEXT } from './site'; |
||||
import { Templates } from './templates'; |
||||
import { itemType } from './utils/item-type'; |
||||
import fs from 'fs'; |
||||
|
||||
export interface RenderedPage { |
||||
id: string; |
||||
contents: string; |
||||
} |
||||
|
||||
interface TemplateOptions { |
||||
data: { |
||||
site: Site; |
||||
}; |
||||
} |
||||
|
||||
export function render(site: Site, templates: Templates, collapseNewlines?: boolean): RenderedPage[] { |
||||
const renderPage = buildRenderer(templates); |
||||
const renderedPages: RenderedPage[] = []; |
||||
for (const page of site.pages) { |
||||
let contents = renderPage(page, { data: { site } }); |
||||
if (collapseNewlines) { |
||||
contents = contents.replace(/\n{3,}/g, '\n\n'); |
||||
} |
||||
renderedPages.push({ |
||||
id: page.id, |
||||
contents, |
||||
}); |
||||
} |
||||
return renderedPages; |
||||
} |
||||
|
||||
export const itemPartialName = (item: DocItemWithContext) => itemType(item).replace(/ /g, '-').toLowerCase(); |
||||
|
||||
function itemPartial(item: DocItemWithContext, options?: RuntimeOptions) { |
||||
if (!item.__item_context) { |
||||
throw new Error(`Partial 'item' used in unsupported context (not a doc item)`); |
||||
} |
||||
const partial = options?.partials?.[itemPartialName(item)]; |
||||
if (!partial) { |
||||
throw new Error(`Missing partial '${itemPartialName(item)}'`); |
||||
} |
||||
return partial(item, options); |
||||
} |
||||
|
||||
function readmeHelper(H: typeof Handlebars, path: string, opts: RuntimeOptions) { |
||||
const items: DocItemWithContext[] = opts.data.root.items; |
||||
const renderedItems = Object.fromEntries( |
||||
items.map(item => [ |
||||
item.name, |
||||
new H.SafeString( |
||||
H.compile('{{>item}}')(item, opts), |
||||
), |
||||
]), |
||||
); |
||||
return new H.SafeString( |
||||
H.compile(fs.readFileSync(path, 'utf8'))(renderedItems, opts), |
||||
); |
||||
} |
||||
|
||||
function buildRenderer(templates: Templates): (page: Page, options: TemplateOptions) => string { |
||||
const pageTemplate = templates.partials?.page; |
||||
if (pageTemplate === undefined) { |
||||
throw new Error(`Missing 'page' template`); |
||||
} |
||||
|
||||
const H = Handlebars.create(); |
||||
|
||||
for (const [name, getBody] of Object.entries(templates.partials ?? {})) { |
||||
let partial: HandlebarsTemplateDelegate | undefined; |
||||
H.registerPartial(name, (...args) => { |
||||
partial ??= H.compile(getBody()); |
||||
return partial(...args); |
||||
}); |
||||
} |
||||
|
||||
H.registerHelper('readme', (path: string, opts: RuntimeOptions) => readmeHelper(H, path, opts)); |
||||
|
||||
for (const [name, fn] of Object.entries(templates.helpers ?? {})) { |
||||
H.registerHelper(name, fn); |
||||
} |
||||
|
||||
H.registerPartial('item', itemPartial); |
||||
|
||||
return H.compile('{{>page}}'); |
||||
} |
@ -0,0 +1,138 @@ |
||||
import path from 'path'; |
||||
import { ContractDefinition, SourceUnit } from 'solidity-ast'; |
||||
import { SolcOutput, SolcInput } from 'solidity-ast/solc'; |
||||
import { astDereferencer, ASTDereferencer, findAll, isNodeType, srcDecoder, SrcDecoder } from 'solidity-ast/utils'; |
||||
import { FullConfig } from './config'; |
||||
import { DocItem, docItemTypes, isDocItem } from './doc-item'; |
||||
import { Properties } from './templates'; |
||||
import { clone } from './utils/clone'; |
||||
import { isChild } from './utils/is-child'; |
||||
import { mapValues } from './utils/map-values'; |
||||
import { defineGetterMemoized } from './utils/memoized-getter'; |
||||
|
||||
export interface Build { |
||||
input: SolcInput; |
||||
output: SolcOutput; |
||||
} |
||||
|
||||
export interface BuildContext extends Build { |
||||
deref: ASTDereferencer; |
||||
decodeSrc: SrcDecoder; |
||||
} |
||||
|
||||
export type SiteConfig = Pick<FullConfig, 'pages' | 'exclude' | 'sourcesDir' | 'pageExtension'>; |
||||
export type PageStructure = SiteConfig['pages']; |
||||
export type PageAssigner = ((item: DocItem, file: SourceUnit, config: SiteConfig) => string | undefined); |
||||
|
||||
export const pageAssigner: Record<PageStructure & string, PageAssigner> = { |
||||
single: (_1, _2, { pageExtension: ext }) => 'index' + ext, |
||||
items: (item, _, { pageExtension: ext }) => item.name + ext, |
||||
files: (_, file, { pageExtension: ext, sourcesDir }) => |
||||
path.relative(sourcesDir, file.absolutePath).replace('.sol', ext), |
||||
}; |
||||
|
||||
export interface Site { |
||||
items: DocItemWithContext[]; |
||||
pages: Page[]; |
||||
} |
||||
|
||||
export interface Page { |
||||
id: string; |
||||
items: DocItemWithContext[]; |
||||
} |
||||
|
||||
export const DOC_ITEM_CONTEXT = '__item_context' as const; |
||||
export type DocItemWithContext = DocItem & { [DOC_ITEM_CONTEXT]: DocItemContext }; |
||||
|
||||
export interface DocItemContext { |
||||
page?: string; |
||||
item: DocItemWithContext; |
||||
contract?: ContractDefinition; |
||||
file: SourceUnit; |
||||
build: BuildContext; |
||||
} |
||||
|
||||
export function buildSite (builds: Build[], siteConfig: SiteConfig, properties: Properties = {}): Site { |
||||
const assign = typeof siteConfig.pages === 'string' ? pageAssigner[siteConfig.pages] : siteConfig.pages; |
||||
|
||||
const seen = new Set<string>(); |
||||
const items: DocItemWithContext[] = []; |
||||
const pages: Record<string, DocItemWithContext[]> = {}; |
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
for (let { input, output } of builds) { |
||||
// Clone because we will mutate in order to add item context.
|
||||
output = { ...output, sources: clone(output.sources) }; |
||||
|
||||
const deref = astDereferencer(output); |
||||
const decodeSrc = srcDecoder(input, output); |
||||
const build = { input, output, deref, decodeSrc }; |
||||
|
||||
for (const { ast: file } of Object.values(output.sources)) { |
||||
const isNewFile = !seen.has(file.absolutePath); |
||||
seen.add(file.absolutePath); |
||||
|
||||
for (const topLevelItem of file.nodes) { |
||||
if (!isDocItem(topLevelItem)) continue; |
||||
|
||||
const page = assignIfIncludedSource(assign, topLevelItem, file, siteConfig); |
||||
|
||||
const withContext = defineContext(topLevelItem, build, file, page); |
||||
defineProperties(withContext, properties); |
||||
|
||||
if (isNewFile && page !== undefined) { |
||||
(pages[page] ??= []).push(withContext); |
||||
items.push(withContext); |
||||
} |
||||
|
||||
if (!isNodeType('ContractDefinition', topLevelItem)) { |
||||
continue; |
||||
} |
||||
|
||||
for (const item of topLevelItem.nodes) { |
||||
if (!isDocItem(item)) continue; |
||||
if (isNewFile && page !== undefined) items.push(item as DocItemWithContext); |
||||
const contract = topLevelItem.nodeType === 'ContractDefinition' ? topLevelItem : undefined; |
||||
const withContext = defineContext(item, build, file, page, contract); |
||||
defineProperties(withContext, properties); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return { |
||||
items, |
||||
pages: Object.entries(pages).map(([id, pageItems]) => ({ id, items: pageItems })), |
||||
}; |
||||
} |
||||
|
||||
function defineContext (item: DocItem, build: BuildContext, file: SourceUnit, page?: string, contract?: ContractDefinition): DocItemWithContext { |
||||
return Object.assign(item, { |
||||
[DOC_ITEM_CONTEXT]: { build, file, contract, page, item: item as DocItemWithContext }, |
||||
}); |
||||
} |
||||
|
||||
function defineProperties (item: DocItemWithContext, properties: Properties) { |
||||
for (const [prop, fn] of Object.entries(properties)) { |
||||
const original: unknown = (item as any)[prop]; |
||||
defineGetterMemoized(item as any, prop, () => fn(item.__item_context, original)); |
||||
} |
||||
} |
||||
|
||||
function assignIfIncludedSource ( |
||||
assign: PageAssigner, |
||||
item: DocItem, |
||||
file: SourceUnit, |
||||
config: SiteConfig, |
||||
) { |
||||
return isFileIncluded(file.absolutePath, config) |
||||
? assign(item, file, config) |
||||
: undefined; |
||||
} |
||||
|
||||
function isFileIncluded (file: string, config: SiteConfig) { |
||||
return ( |
||||
isChild(file, config.sourcesDir) && |
||||
config.exclude.every(e => !isChild(file, path.join(config.sourcesDir, e))) |
||||
); |
||||
} |
@ -0,0 +1,90 @@ |
||||
import { mapKeys } from './utils/map-keys'; |
||||
import { DocItemContext } from './site'; |
||||
|
||||
import * as defaultProperties from './common/properties'; |
||||
|
||||
export type PropertyGetter = (ctx: DocItemContext, original?: unknown) => unknown; |
||||
export type Properties = Record<string, PropertyGetter>; |
||||
|
||||
export interface Templates { |
||||
partials?: Record<string, () => string>; |
||||
helpers?: Record<string, (...args: unknown[]) => string>; |
||||
properties?: Record<string, PropertyGetter>; |
||||
} |
||||
|
||||
/** |
||||
* Loads the templates that will be used for rendering a site based on a |
||||
* default theme and user templates. |
||||
* |
||||
* The result contains all partials, helpers, and property getters defined in |
||||
* the user templates and the default theme, where the user's take precedence |
||||
* if there is a clash. Additionally, all theme partials and helpers are |
||||
* included with the theme prefix, e.g. `markdown/contract` will be a partial. |
||||
*/ |
||||
export async function loadTemplates(defaultTheme: string, root: string, userTemplatesPath?: string): Promise<Templates> { |
||||
const themes = await readThemes(); |
||||
|
||||
// Initialize templates with the default theme.
|
||||
const templates: Required<Templates> = { |
||||
partials: { ...themes[defaultTheme]?.partials }, |
||||
helpers: { ...themes[defaultTheme]?.helpers }, |
||||
properties: { ...defaultProperties }, |
||||
}; |
||||
|
||||
|
||||
// Add partials and helpers from all themes, prefixed with the theme name.
|
||||
for (const [themeName, theme] of Object.entries(themes)) { |
||||
const addPrefix = (k: string) => `${themeName}/${k}`; |
||||
Object.assign(templates.partials, mapKeys(theme.partials, addPrefix)); |
||||
Object.assign(templates.helpers, mapKeys(theme.helpers, addPrefix)); |
||||
} |
||||
|
||||
return templates; |
||||
} |
||||
|
||||
/** |
||||
* Read templates and helpers from a directory. |
||||
*/ |
||||
export async function readTemplates(): Promise<Required<Templates>> { |
||||
return { |
||||
partials: await readPartials(), |
||||
helpers: await readHelpers('helpers'), |
||||
properties: await readHelpers('properties'), |
||||
}; |
||||
} |
||||
|
||||
async function readPartials() { |
||||
const partials: NonNullable<Templates['partials']> = {}; |
||||
const partialNames = ["common", "contract", "enum", "error", "event", "function", "modifier", "page", "struct", "variable", "user-defined-value-type"] |
||||
for (const name of partialNames) { |
||||
const p = await import('raw-loader!./themes/markdown/' + name + '.hbs') |
||||
partials[name] = () => p.default |
||||
} |
||||
return partials; |
||||
} |
||||
|
||||
async function readHelpers(name: string) { |
||||
let helpersPath; |
||||
|
||||
const h = await import('./themes/markdown/helpers'); |
||||
const helpers: Record<string, (...args: any[]) => any> = {}; |
||||
|
||||
for (const name in h) { |
||||
if (typeof h[name] === 'function') { |
||||
helpers[name] = h[name]; |
||||
} |
||||
} |
||||
|
||||
return helpers; |
||||
} |
||||
|
||||
/** |
||||
* Reads all built-in themes into an object. Partials will always be found in |
||||
* src/themes, whereas helpers may instead be found in dist/themes if TypeScript |
||||
* can't be imported directly. |
||||
*/ |
||||
async function readThemes(): Promise<Record<string, Required<Templates>>> { |
||||
const themes: Record<string, Required<Templates>> = {} |
||||
themes['markdown'] = await readTemplates() |
||||
return themes |
||||
} |
@ -0,0 +1,34 @@ |
||||
{{h}} {{name}} |
||||
|
||||
{{#if signature}} |
||||
```solidity |
||||
{{{signature}}} |
||||
``` |
||||
{{/if}} |
||||
|
||||
{{{natspec.notice}}} |
||||
|
||||
{{#if natspec.dev}} |
||||
_{{{natspec.dev}}}_ |
||||
{{/if}} |
||||
|
||||
{{#if natspec.params}} |
||||
{{h 2}} Parameters |
||||
|
||||
| Name | Type | Description | |
||||
| ---- | ---- | ----------- | |
||||
{{#each params}} |
||||
| {{name}} | {{type}} | {{{joinLines natspec}}} | |
||||
{{/each}} |
||||
{{/if}} |
||||
|
||||
{{#if natspec.returns}} |
||||
{{h 2}} Return Values |
||||
|
||||
| Name | Type | Description | |
||||
| ---- | ---- | ----------- | |
||||
{{#each returns}} |
||||
| {{#if name}}{{name}}{{else}}[{{@index}}]{{/if}} | {{type}} | {{{joinLines natspec}}} | |
||||
{{/each}} |
||||
{{/if}} |
||||
|
@ -0,0 +1,8 @@ |
||||
{{>common}} |
||||
|
||||
{{#each items}} |
||||
{{#hsection}} |
||||
{{>item}} |
||||
{{/hsection}} |
||||
|
||||
{{/each}} |
@ -0,0 +1,9 @@ |
||||
{{>common}} |
||||
|
||||
```solidity |
||||
enum {{name}} { |
||||
{{#each members}} |
||||
{{name}}{{#unless @last}},{{/unless}} |
||||
{{/each}} |
||||
} |
||||
``` |
@ -0,0 +1 @@ |
||||
{{>common}} |
@ -0,0 +1 @@ |
||||
{{>common}} |
@ -0,0 +1 @@ |
||||
{{>common}} |
@ -0,0 +1,49 @@ |
||||
import { HelperOptions, Utils } from 'handlebars'; |
||||
|
||||
export * from '../../common/helpers'; |
||||
|
||||
/** |
||||
* Returns a Markdown heading marker. An optional number increases the heading level. |
||||
* |
||||
* Input Output |
||||
* {{h}} {{name}} # Name |
||||
* {{h 2}} {{name}} ## Name |
||||
*/ |
||||
export function h(opts: HelperOptions): string; |
||||
export function h(hsublevel: number, opts: HelperOptions): string; |
||||
export function h(hsublevel: number | HelperOptions, opts?: HelperOptions) { |
||||
const { hlevel } = getHLevel(hsublevel, opts); |
||||
return new Array(hlevel).fill('#').join(''); |
||||
}; |
||||
|
||||
/** |
||||
* Delineates a section where headings should be increased by 1 or a custom number. |
||||
* |
||||
* {{#hsection}} |
||||
* {{>partial-with-headings}} |
||||
* {{/hsection}} |
||||
*/ |
||||
export function hsection(opts: HelperOptions): string; |
||||
export function hsection(hsublevel: number, opts: HelperOptions): string; |
||||
export function hsection(this: unknown, hsublevel: number | HelperOptions, opts?: HelperOptions) { |
||||
let hlevel; |
||||
({ hlevel, opts } = getHLevel(hsublevel, opts)); |
||||
opts.data = Utils.createFrame(opts.data); |
||||
opts.data.hlevel = hlevel; |
||||
return opts.fn(this as unknown, opts); |
||||
} |
||||
|
||||
/** |
||||
* Helper for dealing with the optional hsublevel argument. |
||||
*/ |
||||
function getHLevel(hsublevel: number | HelperOptions, opts?: HelperOptions) { |
||||
if (typeof hsublevel === 'number') { |
||||
opts = opts!; |
||||
hsublevel = Math.max(1, hsublevel); |
||||
} else { |
||||
opts = hsublevel; |
||||
hsublevel = 1; |
||||
} |
||||
const contextHLevel: number = opts.data?.hlevel ?? 0; |
||||
return { opts, hlevel: contextHLevel + hsublevel }; |
||||
} |
@ -0,0 +1 @@ |
||||
{{>common}} |
@ -0,0 +1,8 @@ |
||||
# Solidity API |
||||
|
||||
{{#each items}} |
||||
{{#hsection}} |
||||
{{>item}} |
||||
{{/hsection}} |
||||
|
||||
{{/each}} |
@ -0,0 +1,9 @@ |
||||
{{>common}} |
||||
|
||||
```solidity |
||||
struct {{name}} { |
||||
{{#each members}} |
||||
{{{typeName.typeDescriptions.typeString}}} {{name}}; |
||||
{{/each}} |
||||
} |
||||
``` |
@ -0,0 +1 @@ |
||||
{{>common}} |
@ -0,0 +1 @@ |
||||
{{>common}} |
@ -0,0 +1,13 @@ |
||||
import { DocItemWithContext, DOC_ITEM_CONTEXT } from '../site'; |
||||
|
||||
export class ItemError extends Error { |
||||
constructor(msg: string, item: DocItemWithContext) { |
||||
const ctx = item[DOC_ITEM_CONTEXT]; |
||||
const src = ctx && ctx.build.decodeSrc(item); |
||||
if (src) { |
||||
super(msg + ` (${src})`); |
||||
} else { |
||||
super(msg); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
export function arraysEqual<T>(a: T[], b: T[]): boolean; |
||||
export function arraysEqual<T, U>(a: T[], b: T[], mapFn: (x: T) => U): boolean; |
||||
export function arraysEqual<T>(a: T[], b: T[], mapFn = (x: T) => x): boolean { |
||||
return a.length === b.length && a.every((x, i) => mapFn(x) === mapFn(b[i]!)); |
||||
} |
@ -0,0 +1 @@ |
||||
export type AssertEqual<T, U> = [T, U] extends [U, T] ? true : never; |
@ -0,0 +1,6 @@ |
||||
/** |
||||
* Deep cloning good enough for simple objects like solc output. Types are not |
||||
* sound because the function may lose information: non-enumerable properties, |
||||
* symbols, undefined values, prototypes, etc. |
||||
*/ |
||||
export const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)); |
@ -0,0 +1,12 @@ |
||||
// The function below would not be correctly typed if the return type was T[]
|
||||
// because T may itself be an array type and Array.isArray would not know the
|
||||
// difference. Adding IfArray<T> makes sure the return type is always correct.
|
||||
type IfArray<T> = T extends any[] ? T : never; |
||||
|
||||
export function ensureArray<T>(x: T | T[]): T[] | IfArray<T> { |
||||
if (Array.isArray(x)) { |
||||
return x; |
||||
} else { |
||||
return [x]; |
||||
} |
||||
} |
@ -0,0 +1,18 @@ |
||||
/** |
||||
* Iterates over all contiguous matches of the regular expression over the |
||||
* text. Stops as soon as the regular expression no longer matches at the |
||||
* current position. |
||||
*/ |
||||
export function* execAll(re: RegExp, text: string) { |
||||
re = new RegExp(re, re.flags + (re.sticky ? '' : 'y')); |
||||
|
||||
while (true) { |
||||
const match = re.exec(text); |
||||
|
||||
// We break out of the loop if there is no match or if the empty string is
|
||||
// matched because no progress will be made and it will loop indefinitely.
|
||||
if (!match?.[0]) break; |
||||
|
||||
yield match; |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
import path from 'path'; |
||||
|
||||
export function isChild(file: string, parent: string) { |
||||
return path.normalize(file + path.sep).startsWith(path.normalize(parent + path.sep)); |
||||
} |
@ -0,0 +1,7 @@ |
||||
import { DocItem } from '../doc-item'; |
||||
|
||||
export function itemType(item: DocItem): string { |
||||
return item.nodeType |
||||
.replace(/(Definition|Declaration)$/, '') |
||||
.replace(/(\w)([A-Z])/g, '$1 $2'); |
||||
} |
@ -0,0 +1,4 @@ |
||||
export function mapKeys<T>(obj: Record<string, T>, fn: (key: string) => string): Record<string, T> { |
||||
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [fn(k), v])); |
||||
} |
||||
|
@ -0,0 +1,19 @@ |
||||
export function mapValues<T, U>(obj: Record<string, T>, fn: (value: T) => U): Record<string, U> { |
||||
const res: Record<string, U> = {}; |
||||
for (const [k, v] of Object.entries(obj)) { |
||||
res[k] = fn(v); |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
export function filterValues<T, U extends T>(obj: Record<string, T>, fn: (value: T) => value is U): Record<string, U>; |
||||
export function filterValues<T>(obj: Record<string, T>, fn: (value: T) => boolean): Record<string, T>; |
||||
export function filterValues<T>(obj: Record<string, T>, fn: (value: T) => boolean): Record<string, T> { |
||||
const res: Record<string, T> = {}; |
||||
for (const [k, v] of Object.entries(obj)) { |
||||
if (fn(v)) { |
||||
res[k] = v; |
||||
} |
||||
} |
||||
return res; |
||||
} |
@ -0,0 +1,23 @@ |
||||
export function defineGetterMemoized<K extends keyof any, T, O extends { [k in K]?: T }>(obj: O, key: K, getter: () => T) { |
||||
let state: 'todo' | 'doing' | 'done' = 'todo'; |
||||
let value: T; |
||||
|
||||
Object.defineProperty(obj, key, { |
||||
enumerable: true, |
||||
get() { |
||||
switch (state) { |
||||
case 'done':
|
||||
return value; |
||||
|
||||
case 'doing': |
||||
throw new Error("Detected recursion"); |
||||
|
||||
case 'todo': |
||||
state = 'doing'; |
||||
value = getter(); |
||||
state = 'done'; |
||||
return value; |
||||
} |
||||
} |
||||
}); |
||||
} |
@ -0,0 +1,145 @@ |
||||
import { FunctionDefinition } from 'solidity-ast'; |
||||
import { findAll } from 'solidity-ast/utils'; |
||||
import { DocItemWithContext, DOC_ITEM_CONTEXT } from '../site'; |
||||
import { arraysEqual } from './arrays-equal'; |
||||
import { execAll } from './execall'; |
||||
import { itemType } from './item-type'; |
||||
import { ItemError } from './ItemError'; |
||||
import { readItemDocs } from './read-item-docs'; |
||||
import { getContractsInScope } from './scope'; |
||||
|
||||
export interface NatSpec { |
||||
title?: string; |
||||
notice?: string; |
||||
dev?: string; |
||||
params?: { |
||||
name: string; |
||||
description: string; |
||||
}[]; |
||||
returns?: { |
||||
name?: string; |
||||
description: string; |
||||
}[]; |
||||
custom?: { |
||||
[tag: string]: string; |
||||
}; |
||||
} |
||||
|
||||
export function parseNatspec(item: DocItemWithContext): NatSpec { |
||||
if (!item[DOC_ITEM_CONTEXT]) throw new Error(`Not an item or item is missing context`); |
||||
|
||||
let res: NatSpec = {}; |
||||
|
||||
const docSource = readItemDocs(item); |
||||
const docString = docSource !== undefined |
||||
? cleanUpDocstringFromSource(docSource) |
||||
: 'documentation' in item && item.documentation |
||||
? typeof item.documentation === 'string' |
||||
? item.documentation |
||||
: cleanUpDocstringFromSolc(item.documentation.text) |
||||
: ''; |
||||
|
||||
const tagMatches = execAll( |
||||
/^(?:@(\w+|custom:[a-z][a-z-]*) )?((?:(?!^@(?:\w+|custom:[a-z][a-z-]*) )[^])*)/m, |
||||
docString, |
||||
); |
||||
|
||||
let inheritFrom: FunctionDefinition | undefined; |
||||
|
||||
for (const [, tag = 'notice', content] of tagMatches) { |
||||
if (content === undefined) throw new ItemError('Unexpected error', item); |
||||
|
||||
if (tag === 'dev' || tag === 'notice') { |
||||
res[tag] ??= ''; |
||||
res[tag] += content; |
||||
} |
||||
|
||||
if (tag === 'title') { |
||||
res.title = content.trim(); |
||||
} |
||||
|
||||
if (tag === 'param') { |
||||
const paramMatches = content.match(/(\w+) ([^]*)/); |
||||
if (paramMatches) { |
||||
const [, name, description] = paramMatches as [string, string, string]; |
||||
res.params ??= []; |
||||
res.params.push({ name, description: description.trim() }); |
||||
} |
||||
} |
||||
|
||||
if (tag === 'return') { |
||||
if (!('returnParameters' in item)) { |
||||
throw new ItemError(`Item does not contain return parameters`, item); |
||||
} |
||||
res.returns ??= []; |
||||
const i = res.returns.length; |
||||
const p = item.returnParameters.parameters[i]; |
||||
if (p === undefined) { |
||||
throw new ItemError('Got more @return tags than expected', item); |
||||
} |
||||
if (!p.name) { |
||||
res.returns.push({ description: content.trim() }); |
||||
} else { |
||||
const paramMatches = content.match(/(\w+)( ([^]*))?/); |
||||
if (!paramMatches || paramMatches[1] !== p.name) { |
||||
throw new ItemError(`Expected @return tag to start with name '${p.name}'`, item); |
||||
} |
||||
const [, name, description] = paramMatches as [string, string, string?]; |
||||
res.returns.push({ name, description: description?.trim() ?? '' }); |
||||
} |
||||
} |
||||
|
||||
if (tag?.startsWith('custom:')) { |
||||
const key = tag.replace(/^custom:/, ''); |
||||
res.custom ??= {}; |
||||
res.custom[key] ??= ''; |
||||
res.custom[key] += content; |
||||
} |
||||
|
||||
if (tag === 'inheritdoc') { |
||||
if (!(item.nodeType === 'FunctionDefinition' || item.nodeType === 'VariableDeclaration')) { |
||||
throw new ItemError(`Expected function or variable but saw ${itemType(item)}`, item); |
||||
} |
||||
const parentContractName = content.trim(); |
||||
const parentContract = getContractsInScope(item)[parentContractName]; |
||||
if (!parentContract) { |
||||
throw new ItemError(`Parent contract '${parentContractName}' not found`, item); |
||||
} |
||||
inheritFrom = [...findAll('FunctionDefinition', parentContract)].find(f => item.baseFunctions?.includes(f.id)); |
||||
} |
||||
} |
||||
|
||||
if (docString.length === 0) { |
||||
if ('baseFunctions' in item && item.baseFunctions?.length === 1) { |
||||
const baseFn = item[DOC_ITEM_CONTEXT].build.deref('FunctionDefinition', item.baseFunctions[0]!); |
||||
const shouldInherit = item.nodeType === 'VariableDeclaration' || arraysEqual(item.parameters.parameters, baseFn.parameters.parameters, p => p.name); |
||||
if (shouldInherit) { |
||||
inheritFrom = baseFn; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (res.dev) res.dev = res.dev.trim(); |
||||
if (res.notice) res.notice = res.notice.trim(); |
||||
|
||||
if (inheritFrom) { |
||||
res = { ...parseNatspec(inheritFrom as DocItemWithContext), ...res }; |
||||
} |
||||
|
||||
return res; |
||||
} |
||||
|
||||
// Fix solc buggy parsing of doc comments.
|
||||
// Reverse engineered from solc behavior.
|
||||
function cleanUpDocstringFromSolc(text: string) { |
||||
return text |
||||
.replace(/\n\n?^[ \t]*(?:\*|\/\/\/)/mg, '\n\n') |
||||
.replace(/^[ \t]?/mg, ''); |
||||
} |
||||
|
||||
function cleanUpDocstringFromSource(text: string) { |
||||
return text |
||||
.replace(/^\/\*\*(.*)\*\/$/s, '$1') |
||||
.trim() |
||||
.replace(/^[ \t]*(\*|\/\/\/)[ \t]?/mg, ''); |
||||
} |
@ -0,0 +1,26 @@ |
||||
import { DocItemWithContext, DOC_ITEM_CONTEXT, Build } from '../site'; |
||||
|
||||
export function readItemDocs(item: DocItemWithContext): string | undefined { |
||||
const { build } = item[DOC_ITEM_CONTEXT]; |
||||
// Note that Solidity 0.5 has item.documentation: string even though the
|
||||
// types do not reflect that. This is why we check typeof === object.
|
||||
if ('documentation' in item && item.documentation && typeof item.documentation === 'object') { |
||||
const { source, start, length } = decodeSrc(item.documentation.src, build); |
||||
const content = build.input.sources[source]?.content; |
||||
if (content !== undefined) { |
||||
return Buffer.from(content, 'utf8').slice(start, start + length).toString('utf8'); |
||||
} |
||||
} |
||||
} |
||||
|
||||
function decodeSrc(src: string, build: Build): { source: string; start: number; length: number } { |
||||
const [start, length, sourceId] = src.split(':').map(s => parseInt(s)); |
||||
if (start === undefined || length === undefined || sourceId === undefined) { |
||||
throw new Error(`Bad source string ${src}`); |
||||
} |
||||
const source = Object.keys(build.output.sources).find(s => build.output.sources[s]?.id === sourceId); |
||||
if (source === undefined) { |
||||
throw new Error(`No source with id ${sourceId}`); |
||||
} |
||||
return { source, start, length }; |
||||
} |
@ -0,0 +1,63 @@ |
||||
import { ContractDefinition, SourceUnit } from "solidity-ast"; |
||||
import { findAll, isNodeType } from "solidity-ast/utils"; |
||||
import { DocItemWithContext } from "../site"; |
||||
import { filterValues, mapValues } from './map-values'; |
||||
import { mapKeys } from './map-keys'; |
||||
|
||||
type Definition = SourceUnit['nodes'][number] & { name: string }; |
||||
type Scope = { [name in string]: () => { namespace: Scope } | { definition: Definition } }; |
||||
|
||||
export function getContractsInScope(item: DocItemWithContext) { |
||||
const cache = new WeakMap<SourceUnit, Scope>(); |
||||
|
||||
return filterValues( |
||||
flattenScope(run(item.__item_context.file)), |
||||
isNodeType('ContractDefinition'), |
||||
); |
||||
|
||||
function run(file: SourceUnit): Scope { |
||||
if (cache.has(file)) { |
||||
return cache.get(file)!; |
||||
} |
||||
|
||||
const scope: Scope = {}; |
||||
|
||||
cache.set(file, scope); |
||||
|
||||
for (const c of file.nodes) { |
||||
if ('name' in c) { |
||||
scope[c.name] = () => ({ definition: c }); |
||||
} |
||||
} |
||||
|
||||
for (const i of findAll('ImportDirective', file)) { |
||||
const importedFile = item.__item_context.build.deref('SourceUnit', i.sourceUnit); |
||||
const importedScope = run(importedFile); |
||||
if (i.unitAlias) { |
||||
scope[i.unitAlias] = () => ({ namespace: importedScope }); |
||||
} else if (i.symbolAliases.length === 0) { |
||||
Object.assign(scope, importedScope); |
||||
} else { |
||||
for (const a of i.symbolAliases) { |
||||
// Delayed function call supports circular dependencies
|
||||
scope[a.local ?? a.foreign.name] = importedScope[a.foreign.name] ?? (() => importedScope[a.foreign.name]!()); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
return scope; |
||||
} |
||||
} |
||||
|
||||
function flattenScope(scope: Scope): Record<string, Definition> { |
||||
return Object.fromEntries( |
||||
Object.entries(scope).flatMap(([k, fn]) => { |
||||
const v = fn(); |
||||
if ('definition' in v) { |
||||
return [[k, v.definition] as const]; |
||||
} else { |
||||
return Object.entries(mapKeys(flattenScope(v.namespace), k2 => k + '.' + k2)); |
||||
} |
||||
}), |
||||
); |
||||
} |
@ -0,0 +1,37 @@ |
||||
import { useState } from "react"; |
||||
|
||||
export function useLocalStorage(key: string, initialValue: any) { |
||||
// State to store our value
|
||||
// Pass initial state function to useState so logic is only executed once
|
||||
const [storedValue, setStoredValue] = useState(() => { |
||||
try { |
||||
// Get from local storage by key
|
||||
const item = window.localStorage.getItem(key); |
||||
// Parse stored json or if none return initialValue
|
||||
return item ? JSON.parse(item) : initialValue; |
||||
} catch (error) { |
||||
// If error also return initialValue
|
||||
console.log(error); |
||||
return initialValue; |
||||
} |
||||
}); |
||||
|
||||
// Return a wrapped version of useState's setter function that ...
|
||||
// ... persists the new value to localStorage.
|
||||
const setValue = (value: any) => { |
||||
try { |
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = |
||||
value instanceof Function ? value(storedValue) : value; |
||||
// Save state
|
||||
setStoredValue(valueToStore); |
||||
// Save to local storage
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore)); |
||||
} catch (error) { |
||||
// A more advanced implementation would handle the error case
|
||||
console.log(error); |
||||
} |
||||
}; |
||||
|
||||
return [storedValue, setValue]; |
||||
} |
@ -0,0 +1,31 @@ |
||||
import React from "react"; |
||||
|
||||
export const ErrorView: React.FC = () => { |
||||
return ( |
||||
<div |
||||
style={{ |
||||
width: "100%", |
||||
display: "flex", |
||||
flexDirection: "column", |
||||
alignItems: "center", |
||||
}} |
||||
> |
||||
<img |
||||
style={{ paddingBottom: "2em" }} |
||||
width="250" |
||||
src="https://res.cloudinary.com/key-solutions/image/upload/v1580400635/solid/error-png.png" |
||||
alt="Error page" |
||||
/> |
||||
<h5>Sorry, something unexpected happened. </h5> |
||||
<h5> |
||||
Please raise an issue:{" "} |
||||
<a |
||||
style={{ color: "red" }} |
||||
href="https://github.com/Machinalabs/remix-ethdoc-plugin/issues" |
||||
> |
||||
Here |
||||
</a> |
||||
</h5> |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1 @@ |
||||
|
After Width: | Height: | Size: 3.1 KiB |
@ -0,0 +1,13 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="utf-8" /> |
||||
<title>Remix Docgen</title> |
||||
<base href="/" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" /> |
||||
</head> |
||||
<body> |
||||
<div id="root"></div> |
||||
</body> |
||||
</html> |
@ -0,0 +1,11 @@ |
||||
import React from "react"; |
||||
import ReactDOM from "react-dom"; |
||||
import App from "./app/App"; |
||||
// import { Routes } from "./routes";
|
||||
|
||||
ReactDOM.render( |
||||
<React.StrictMode> |
||||
<App /> |
||||
</React.StrictMode>, |
||||
document.getElementById("root") |
||||
); |
@ -0,0 +1,11 @@ |
||||
export type Documentation = string |
||||
|
||||
export interface EthDocumentation { |
||||
[contractName: string]: Documentation |
||||
} |
||||
|
||||
export type ContractName = string |
||||
|
||||
export type FileName = string |
||||
|
||||
export type PublishedSite = string |
@ -0,0 +1,23 @@ |
||||
{ |
||||
"extends": "./tsconfig.json", |
||||
"compilerOptions": { |
||||
"outDir": "../../dist/out-tsc", |
||||
"types": ["node"] |
||||
}, |
||||
"files": [ |
||||
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts", |
||||
"../../node_modules/@nrwl/react/typings/image.d.ts" |
||||
], |
||||
"exclude": [ |
||||
"jest.config.ts", |
||||
"**/*.spec.ts", |
||||
"**/*.test.ts", |
||||
"**/*.spec.tsx", |
||||
"**/*.test.tsx", |
||||
"**/*.spec.js", |
||||
"**/*.test.js", |
||||
"**/*.spec.jsx", |
||||
"**/*.test.jsx" |
||||
], |
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] |
||||
} |
@ -0,0 +1,16 @@ |
||||
{ |
||||
"extends": "../../tsconfig.base.json", |
||||
"compilerOptions": { |
||||
"jsx": "react-jsx", |
||||
"allowJs": true, |
||||
"esModuleInterop": true, |
||||
"allowSyntheticDefaultImports": true |
||||
}, |
||||
"files": [], |
||||
"include": [], |
||||
"references": [ |
||||
{ |
||||
"path": "./tsconfig.app.json" |
||||
} |
||||
] |
||||
} |
@ -0,0 +1,82 @@ |
||||
const { composePlugins, withNx } = require('@nrwl/webpack') |
||||
const { withReact } = require('@nrwl/react') |
||||
const webpack = require('webpack') |
||||
const TerserPlugin = require("terser-webpack-plugin") |
||||
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin") |
||||
|
||||
// Nx plugins for webpack.
|
||||
module.exports = composePlugins(withNx(), withReact(), (config) => { |
||||
// Update the webpack config as needed here.
|
||||
// e.g. `config.plugins.push(new MyPlugin())`
|
||||
|
||||
// add fallback for node modules
|
||||
config.resolve.fallback = { |
||||
...config.resolve.fallback, |
||||
"crypto": require.resolve("crypto-browserify"), |
||||
"stream": require.resolve("stream-browserify"), |
||||
"path": require.resolve("path-browserify"), |
||||
"http": require.resolve("stream-http"), |
||||
"https": require.resolve("https-browserify"), |
||||
"constants": require.resolve("constants-browserify"), |
||||
"os": false, //require.resolve("os-browserify/browser"),
|
||||
"timers": false, // require.resolve("timers-browserify"),
|
||||
"zlib": require.resolve("browserify-zlib"), |
||||
"fs": false, |
||||
"module": false, |
||||
"tls": false, |
||||
"net": false, |
||||
"readline": false, |
||||
"child_process": false, |
||||
"buffer": require.resolve("buffer/"), |
||||
"vm": require.resolve('vm-browserify'), |
||||
} |
||||
|
||||
// add externals
|
||||
config.externals = { |
||||
...config.externals, |
||||
solc: 'solc', |
||||
} |
||||
|
||||
// add public path
|
||||
config.output.publicPath = '/' |
||||
|
||||
// add copy & provide plugin
|
||||
config.plugins.push( |
||||
new webpack.ProvidePlugin({ |
||||
Buffer: ['buffer', 'Buffer'], |
||||
url: ['url', 'URL'], |
||||
process: 'process/browser', |
||||
}), |
||||
new webpack.DefinePlugin({ |
||||
|
||||
}), |
||||
) |
||||
|
||||
// souce-map loader
|
||||
config.module.rules.push({ |
||||
test: /\.js$/, |
||||
use: ["source-map-loader"], |
||||
enforce: "pre" |
||||
}) |
||||
|
||||
config.ignoreWarnings = [/Failed to parse source map/] // ignore source-map-loader warnings
|
||||
|
||||
// set minimizer
|
||||
config.optimization.minimizer = [ |
||||
new TerserPlugin({ |
||||
parallel: true, |
||||
terserOptions: { |
||||
ecma: 2015, |
||||
compress: false, |
||||
mangle: false, |
||||
format: { |
||||
comments: false, |
||||
}, |
||||
}, |
||||
extractComments: false, |
||||
}), |
||||
new CssMinimizerPlugin(), |
||||
]; |
||||
|
||||
return config; |
||||
}) |
@ -0,0 +1,59 @@ |
||||
{ |
||||
"name": "doc-viewer", |
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json", |
||||
"sourceRoot": "apps/doc-viewer/src", |
||||
"projectType": "application", |
||||
"implicitDependencies": [ |
||||
], |
||||
"targets": { |
||||
"build": { |
||||
"executor": "@nrwl/webpack:webpack", |
||||
"outputs": ["{options.outputPath}"], |
||||
"defaultConfiguration": "development", |
||||
"options": { |
||||
"compiler": "babel", |
||||
"outputPath": "dist/apps/doc-viewer", |
||||
"index": "apps/doc-viewer/src/index.html", |
||||
"baseHref": "/", |
||||
"main": "apps/doc-viewer/src/main.tsx", |
||||
"tsConfig": "apps/doc-viewer/tsconfig.app.json", |
||||
"assets": [ |
||||
"apps/doc-viewer/src/favicon.ico" |
||||
], |
||||
"styles": [], |
||||
"scripts": [], |
||||
"webpackConfig": "apps/doc-viewer/webpack.config.js" |
||||
}, |
||||
"configurations": { |
||||
"development": { |
||||
}, |
||||
"production": { |
||||
"fileReplacements": [ |
||||
{ |
||||
"replace": "apps/doc-viewer/src/environments/environment.ts", |
||||
"with": "apps/doc-viewer/src/environments/environment.prod.ts" |
||||
} |
||||
] |
||||
} |
||||
} |
||||
}, |
||||
"serve": { |
||||
"executor": "@nrwl/webpack:dev-server", |
||||
"defaultConfiguration": "development", |
||||
"options": { |
||||
"buildTarget": "doc-viewer:build", |
||||
"hmr": true |
||||
}, |
||||
"configurations": { |
||||
"development": { |
||||
"buildTarget": "doc-viewer:build:development", |
||||
"port": 7003 |
||||
}, |
||||
"production": { |
||||
"buildTarget": "doc-viewer:build:production" |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
"tags": [] |
||||
} |
@ -0,0 +1,23 @@ |
||||
import React, { useEffect, useState } from "react" |
||||
import { DocViewer } from "./docviewer" |
||||
import ReactMarkdown from 'react-markdown' |
||||
import remarkGfm from 'remark-gfm' |
||||
|
||||
const client = new DocViewer() |
||||
|
||||
export default function App() { |
||||
const [contents, setContents] = useState('') |
||||
useEffect(() => { |
||||
client.eventEmitter.on('contentsReady', (fileContents: string) => { |
||||
setContents(fileContents) |
||||
}) |
||||
|
||||
}, []) |
||||
return ( |
||||
<> |
||||
<div className="m-5 p-2"> |
||||
<ReactMarkdown children={contents} remarkPlugins={[remarkGfm]}/> |
||||
</div> |
||||
</> |
||||
) |
||||
} |
@ -0,0 +1,22 @@ |
||||
import { PluginClient } from '@remixproject/plugin' |
||||
import { createClient } from '@remixproject/plugin-webview' |
||||
import EventEmitter from 'events' |
||||
|
||||
export class DocViewer extends PluginClient { |
||||
mdFile: string |
||||
eventEmitter: EventEmitter |
||||
constructor() { |
||||
super() |
||||
this.eventEmitter = new EventEmitter() |
||||
this.methods = ['viewDocs'] |
||||
createClient(this) |
||||
this.mdFile = '' |
||||
this.onload() |
||||
} |
||||
|
||||
async viewDocs(docs: string[]) { |
||||
this.mdFile = docs[0] |
||||
const contents = await this.call('fileManager', 'readFile', this.mdFile) |
||||
this.eventEmitter.emit('contentsReady', contents) |
||||
} |
||||
} |
After Width: | Height: | Size: 3.1 KiB |
@ -0,0 +1,13 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="utf-8" /> |
||||
<title>Doc Viewer</title> |
||||
<base href="/" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" /> |
||||
</head> |
||||
<body> |
||||
<div id="root"></div> |
||||
</body> |
||||
</html> |
@ -0,0 +1,10 @@ |
||||
import React from 'react' |
||||
import ReactDOM from 'react-dom' |
||||
import App from './app/App' |
||||
|
||||
ReactDOM.render( |
||||
<React.StrictMode> |
||||
<App /> |
||||
</React.StrictMode>, |
||||
document.getElementById("root") |
||||
); |
@ -0,0 +1,23 @@ |
||||
{ |
||||
"extends": "./tsconfig.json", |
||||
"compilerOptions": { |
||||
"outDir": "../../dist/out-tsc", |
||||
"types": ["node"] |
||||
}, |
||||
"files": [ |
||||
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts", |
||||
"../../node_modules/@nrwl/react/typings/image.d.ts" |
||||
], |
||||
"exclude": [ |
||||
"jest.config.ts", |
||||
"**/*.spec.ts", |
||||
"**/*.test.ts", |
||||
"**/*.spec.tsx", |
||||
"**/*.test.tsx", |
||||
"**/*.spec.js", |
||||
"**/*.test.js", |
||||
"**/*.spec.jsx", |
||||
"**/*.test.jsx" |
||||
], |
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] |
||||
} |
@ -0,0 +1,16 @@ |
||||
{ |
||||
"extends": "../../tsconfig.base.json", |
||||
"compilerOptions": { |
||||
"jsx": "react-jsx", |
||||
"allowJs": true, |
||||
"esModuleInterop": true, |
||||
"allowSyntheticDefaultImports": true |
||||
}, |
||||
"files": [], |
||||
"include": [], |
||||
"references": [ |
||||
{ |
||||
"path": "./tsconfig.app.json" |
||||
} |
||||
] |
||||
} |
@ -0,0 +1,82 @@ |
||||
const { composePlugins, withNx } = require('@nrwl/webpack') |
||||
const { withReact } = require('@nrwl/react') |
||||
const webpack = require('webpack') |
||||
const TerserPlugin = require("terser-webpack-plugin") |
||||
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin") |
||||
|
||||
// Nx plugins for webpack.
|
||||
module.exports = composePlugins(withNx(), withReact(), (config) => { |
||||
// Update the webpack config as needed here.
|
||||
// e.g. `config.plugins.push(new MyPlugin())`
|
||||
|
||||
// add fallback for node modules
|
||||
config.resolve.fallback = { |
||||
...config.resolve.fallback, |
||||
"crypto": require.resolve("crypto-browserify"), |
||||
"stream": require.resolve("stream-browserify"), |
||||
"path": require.resolve("path-browserify"), |
||||
"http": require.resolve("stream-http"), |
||||
"https": require.resolve("https-browserify"), |
||||
"constants": require.resolve("constants-browserify"), |
||||
"os": false, //require.resolve("os-browserify/browser"),
|
||||
"timers": false, // require.resolve("timers-browserify"),
|
||||
"zlib": require.resolve("browserify-zlib"), |
||||
"fs": false, |
||||
"module": false, |
||||
"tls": false, |
||||
"net": false, |
||||
"readline": false, |
||||
"child_process": false, |
||||
"buffer": require.resolve("buffer/"), |
||||
"vm": require.resolve('vm-browserify'), |
||||
} |
||||
|
||||
// add externals
|
||||
config.externals = { |
||||
...config.externals, |
||||
solc: 'solc', |
||||
} |
||||
|
||||
// add public path
|
||||
config.output.publicPath = '/' |
||||
|
||||
// add copy & provide plugin
|
||||
config.plugins.push( |
||||
new webpack.ProvidePlugin({ |
||||
Buffer: ['buffer', 'Buffer'], |
||||
url: ['url', 'URL'], |
||||
process: 'process/browser', |
||||
}), |
||||
new webpack.DefinePlugin({ |
||||
|
||||
}), |
||||
) |
||||
|
||||
// souce-map loader
|
||||
config.module.rules.push({ |
||||
test: /\.js$/, |
||||
use: ["source-map-loader"], |
||||
enforce: "pre" |
||||
}) |
||||
|
||||
config.ignoreWarnings = [/Failed to parse source map/] // ignore source-map-loader warnings
|
||||
|
||||
// set minimizer
|
||||
config.optimization.minimizer = [ |
||||
new TerserPlugin({ |
||||
parallel: true, |
||||
terserOptions: { |
||||
ecma: 2015, |
||||
compress: false, |
||||
mangle: false, |
||||
format: { |
||||
comments: false, |
||||
}, |
||||
}, |
||||
extractComments: false, |
||||
}), |
||||
new CssMinimizerPlugin(), |
||||
]; |
||||
|
||||
return config; |
||||
}) |
@ -1,294 +0,0 @@ |
||||
/* eslint-disable prefer-const */ |
||||
import domToImage from 'dom-to-image'; |
||||
import { jsPDF } from 'jspdf'; |
||||
|
||||
const _cloneNode = (node, javascriptEnabled) => { |
||||
let child = node.firstChild |
||||
const clone = node.nodeType === 3 ? document.createTextNode(node.nodeValue) : node.cloneNode(false) |
||||
while (child) { |
||||
if (javascriptEnabled === true || child.nodeType !== 1 || child.nodeName !== 'SCRIPT') { |
||||
clone.appendChild(_cloneNode(child, javascriptEnabled)) |
||||
} |
||||
child = child.nextSibling |
||||
} |
||||
if (node.nodeType === 1) { |
||||
if (node.nodeName === 'CANVAS') { |
||||
clone.width = node.width |
||||
clone.height = node.height |
||||
clone.getContext('2d').drawImage(node, 0, 0) |
||||
} else if (node.nodeName === 'TEXTAREA' || node.nodeName === 'SELECT') { |
||||
clone.value = node.value |
||||
} |
||||
clone.addEventListener('load', (() => { |
||||
clone.scrollTop = node.scrollTop |
||||
clone.scrollLeft = node.scrollLeft |
||||
}), true) |
||||
} |
||||
return clone |
||||
} |
||||
|
||||
const _createElement = (tagName, {className, innerHTML, style}) => { |
||||
let i |
||||
let scripts |
||||
const el = document.createElement(tagName) |
||||
if (className) { |
||||
el.className = className |
||||
} |
||||
if (innerHTML) { |
||||
el.innerHTML = innerHTML |
||||
scripts = el.getElementsByTagName('script') |
||||
i = scripts.length |
||||
while (i-- > 0) { |
||||
scripts[i].parentNode.removeChild(scripts[i]) |
||||
} |
||||
} |
||||
for (const key in style) { |
||||
el.style[key] = style[key]; |
||||
} |
||||
return el; |
||||
}; |
||||
|
||||
const _isCanvasBlank = canvas => { |
||||
const blank = document.createElement('canvas'); |
||||
blank.width = canvas.width; |
||||
blank.height = canvas.height; |
||||
const ctx = blank.getContext('2d'); |
||||
ctx.fillStyle = '#FFFFFF'; |
||||
ctx.fillRect(0, 0, blank.width, blank.height); |
||||
return canvas.toDataURL() === blank.toDataURL(); |
||||
}; |
||||
|
||||
const downloadPdf = (dom, options, cb) => { |
||||
const a4Height = 841.89; |
||||
const a4Width = 595.28; |
||||
let overrideWidth; |
||||
let container; |
||||
let containerCSS; |
||||
let containerWidth; |
||||
let elements; |
||||
let excludeClassNames; |
||||
let excludeTagNames; |
||||
let filename; |
||||
let filterFn; |
||||
let innerRatio; |
||||
let overlay; |
||||
let overlayCSS; |
||||
let pageHeightPx; |
||||
let proxyUrl; |
||||
let compression = 'NONE'; |
||||
let scale; |
||||
let opts; |
||||
let offsetHeight; |
||||
let offsetWidth; |
||||
let scaleObj; |
||||
let style; |
||||
const transformOrigin = 'top left'; |
||||
const pdfOptions: any = { |
||||
orientation: 'l', |
||||
unit: 'pt', |
||||
format: 'a4' |
||||
}; |
||||
|
||||
({filename, excludeClassNames = [], excludeTagNames = ['button', 'input', 'select'], overrideWidth, proxyUrl, compression, scale} = options); |
||||
|
||||
overlayCSS = { |
||||
position: 'fixed', |
||||
zIndex: 1000, |
||||
opacity: 0, |
||||
left: 0, |
||||
right: 0, |
||||
bottom: 0, |
||||
top: 0, |
||||
backgroundColor: 'rgba(0,0,0,0.8)' |
||||
}; |
||||
if (overrideWidth) { |
||||
overlayCSS.width = `${overrideWidth}px`; |
||||
} |
||||
containerCSS = { |
||||
position: 'absolute', |
||||
left: 0, |
||||
right: 0, |
||||
top: 0, |
||||
height: 'auto', |
||||
margin: 'auto', |
||||
overflow: 'auto', |
||||
backgroundColor: 'white' |
||||
}; |
||||
overlay = _createElement('div', { |
||||
style: overlayCSS, |
||||
className: '', |
||||
innerHTML: '' |
||||
}); |
||||
container = _createElement('div', { |
||||
style: containerCSS, |
||||
className: '', |
||||
innerHTML: '' |
||||
}); |
||||
//@ts-ignore
|
||||
container.appendChild(_cloneNode(dom)); |
||||
overlay.appendChild(container); |
||||
document.body.appendChild(overlay); |
||||
innerRatio = a4Height / a4Width; |
||||
containerWidth = overrideWidth || container.getBoundingClientRect().width; |
||||
pageHeightPx = Math.floor(containerWidth * innerRatio); |
||||
elements = container.querySelectorAll('*'); |
||||
|
||||
for (let i = 0, len = excludeClassNames.length; i < len; i++) { |
||||
const clName = excludeClassNames[i]; |
||||
container.querySelectorAll(`.${clName}`).forEach(function(a) { |
||||
return a.remove(); |
||||
}); |
||||
} |
||||
|
||||
for (let j = 0, len1 = excludeTagNames.length; j < len1; j++) { |
||||
const tName = excludeTagNames[j]; |
||||
let els = container.getElementsByTagName(tName); |
||||
|
||||
for (let k = els.length - 1; k >= 0; k--) { |
||||
if (!els[k]) { |
||||
continue; |
||||
} |
||||
els[k].parentNode.removeChild(els[k]); |
||||
} |
||||
} |
||||
|
||||
Array.prototype.forEach.call(elements, el => { |
||||
let clientRect; |
||||
let endPage; |
||||
let nPages; |
||||
let pad; |
||||
let rules; |
||||
let startPage; |
||||
rules = { |
||||
before: false, |
||||
after: false, |
||||
avoid: true |
||||
}; |
||||
clientRect = el.getBoundingClientRect(); |
||||
if (rules.avoid && !rules.before) { |
||||
startPage = Math.floor(clientRect.top / pageHeightPx); |
||||
endPage = Math.floor(clientRect.bottom / pageHeightPx); |
||||
nPages = Math.abs(clientRect.bottom - clientRect.top) / pageHeightPx; |
||||
// Turn on rules.before if the el is broken and is at most one page long.
|
||||
if (endPage !== startPage && nPages <= 1) { |
||||
rules.before = true; |
||||
} |
||||
// Before: Create a padding div to push the element to the next page.
|
||||
if (rules.before) { |
||||
pad = _createElement('div', { |
||||
className: '', |
||||
innerHTML: '', |
||||
style: { |
||||
display: 'block', |
||||
height: `${pageHeightPx - clientRect.top % pageHeightPx}px` |
||||
} |
||||
}); |
||||
return el.parentNode.insertBefore(pad, el); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
// Remove unnecessary elements from result pdf
|
||||
filterFn = ({classList, tagName}) => { |
||||
let cName; |
||||
let j; |
||||
let len; |
||||
let ref; |
||||
if (classList) { |
||||
for (j = 0, len = excludeClassNames.length; j < len; j++) { |
||||
cName = excludeClassNames[j]; |
||||
if (Array.prototype.indexOf.call(classList, cName) >= 0) { |
||||
return false; |
||||
} |
||||
} |
||||
} |
||||
ref = tagName != null ? tagName.toLowerCase() : undefined; |
||||
return excludeTagNames.indexOf(ref) < 0; |
||||
}; |
||||
|
||||
opts = { |
||||
filter: filterFn, |
||||
proxy: proxyUrl |
||||
}; |
||||
|
||||
if (scale) { |
||||
offsetWidth = container.offsetWidth; |
||||
offsetHeight = container.offsetHeight; |
||||
style = { |
||||
transform: 'scale(' + scale + ')', |
||||
transformOrigin: transformOrigin, |
||||
width: offsetWidth + 'px', |
||||
height: offsetHeight + 'px' |
||||
}; |
||||
scaleObj = { |
||||
width: offsetWidth * scale, |
||||
height: offsetHeight * scale, |
||||
quality: 1, |
||||
style: style |
||||
}; |
||||
opts = Object.assign(opts, scaleObj); |
||||
} |
||||
|
||||
return domToImage.toCanvas(container, opts).then(canvas => { |
||||
let h; |
||||
let imgData; |
||||
let nPages; |
||||
let page; |
||||
let pageCanvas; |
||||
let pageCtx; |
||||
let pageHeight; |
||||
let pdf; |
||||
let pxFullHeight; |
||||
let w; |
||||
// Remove overlay
|
||||
document.body.removeChild(overlay); |
||||
// Initialize the PDF.
|
||||
pdf = new jsPDF(pdfOptions); |
||||
// Calculate the number of pages.
|
||||
pxFullHeight = canvas.height; |
||||
nPages = Math.ceil(pxFullHeight / pageHeightPx); |
||||
// Define pageHeight separately so it can be trimmed on the final page.
|
||||
pageHeight = a4Height; |
||||
pageCanvas = document.createElement('canvas'); |
||||
pageCtx = pageCanvas.getContext('2d'); |
||||
pageCanvas.width = canvas.width; |
||||
pageCanvas.height = pageHeightPx; |
||||
page = 0; |
||||
while (page < nPages) { |
||||
if (page === nPages - 1 && pxFullHeight % pageHeightPx !== 0) { |
||||
pageCanvas.height = pxFullHeight % pageHeightPx; |
||||
pageHeight = pageCanvas.height * a4Width / pageCanvas.width; |
||||
} |
||||
w = pageCanvas.width; |
||||
h = pageCanvas.height; |
||||
pageCtx.fillStyle = 'white'; |
||||
pageCtx.fillRect(0, 0, w, h); |
||||
pageCtx.drawImage(canvas, 0, page * pageHeightPx, w, h, 0, 0, w, h); |
||||
// Don't create blank pages
|
||||
if (_isCanvasBlank(pageCanvas)) { |
||||
++page; |
||||
continue; |
||||
} |
||||
// Add the page to the PDF.
|
||||
if (page) { |
||||
pdf.addPage(); |
||||
} |
||||
imgData = pageCanvas.toDataURL('image/PNG'); |
||||
pdf.addImage(imgData, 'PNG', 0, 0, a4Width, pageHeight, undefined, compression); |
||||
++page; |
||||
} |
||||
if (typeof cb === "function") { |
||||
cb(pdf); |
||||
} |
||||
return pdf.save(filename); |
||||
}).catch(error => { |
||||
// Remove overlay
|
||||
document.body.removeChild(overlay); |
||||
if (typeof cb === "function") { |
||||
cb(null); |
||||
} |
||||
return console.error(error); |
||||
}); |
||||
}; |
||||
|
||||
module.exports = downloadPdf; |
Loading…
Reference in new issue