참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,497 @@
/**
* Copyright 2017 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
const fs = require('fs');
const path = require('path');
const md = require('../markdown');
const docs = require('./documentation');
/** @typedef {import('../markdown').MarkdownNode} MarkdownNode */
/** @typedef {import('../markdown').MarkdownHeaderNode} MarkdownHeaderNode */
/** @typedef {import('../markdown').MarkdownLiNode} MarkdownLiNode */
/** @typedef {import('../markdown').MarkdownTextNode} MarkdownTextNode */
class ApiParser {
/**
* @param {string} apiDir
* @param {string=} paramsPath
*/
constructor(apiDir, paramsPath) {
let bodyParts = [];
for (const name of fs.readdirSync(apiDir)) {
if (!name.endsWith('.md'))
continue;
if (name === 'params.md')
paramsPath = path.join(apiDir, name);
else
bodyParts.push(fs.readFileSync(path.join(apiDir, name)).toString());
}
const body = md.parse(bodyParts.join('\n'));
const params = paramsPath ? md.parse(fs.readFileSync(paramsPath).toString()) : undefined;
checkNoDuplicateParamEntries(params);
const api = params ? applyTemplates(body, params) : body;
/** @type {Map<string, docs.Class>} */
this.classes = new Map();
md.visitAll(api, node => {
if (node.type === 'h1')
this.parseClass(node);
});
md.visitAll(api, node => {
if (node.type === 'h2')
this.parseMember(node);
});
md.visitAll(api, node => {
if (node.type === 'h3')
this.parseArgument(node);
});
this.documentation = new docs.Documentation([...this.classes.values()]);
this.documentation.index();
}
/**
* @param {MarkdownHeaderNode} node
*/
parseClass(node) {
let extendsName = null;
const name = node.text.substring('class: '.length);
for (const member of node.children) {
if (member.type.startsWith('h'))
continue;
if (member.type === 'li' && member.liType === 'bullet' && member.text.startsWith('extends: [')) {
extendsName = member.text.substring('extends: ['.length, member.text.indexOf(']'));
continue;
}
}
const metainfo = extractMetainfo(node);
const clazz = new docs.Class(metainfo, name, [], extendsName, extractComments(node));
if (metainfo.hidden)
return;
this.classes.set(clazz.name, clazz);
}
/**
* @param {MarkdownHeaderNode} spec
*/
parseMember(spec) {
const match = spec.text.match(/(event|method|property|async method|optional method|optional async method): ([^.]+)\.(.*)/);
if (!match)
throw new Error('Invalid member: ' + spec.text);
const metainfo = extractMetainfo(spec);
if (metainfo.hidden)
return;
const name = match[3];
let returnType = null;
let optional = false;
for (const item of spec.children || []) {
if (item.type === 'li' && item.liType === 'default') {
const parsed = this.parseType(item, metainfo.since ?? 'v1.0');
returnType = parsed.type;
optional = parsed.optional;
}
}
if (!returnType)
returnType = new docs.Type('void');
const comments = extractComments(spec);
let member;
if (match[1] === 'event')
member = docs.Member.createEvent(metainfo, name, returnType, comments);
if (match[1] === 'property')
member = docs.Member.createProperty(metainfo, name, returnType, comments, !optional);
if (['method', 'async method', 'optional method', 'optional async method'].includes(match[1])) {
member = docs.Member.createMethod(metainfo, name, [], returnType, comments);
if (match[1].includes('async'))
member.async = true;
if (match[1].includes('optional'))
member.required = false;
}
if (!member)
throw new Error('Unknown member: ' + spec.text);
const clazz = /** @type {docs.Class} */(this.classes.get(match[2]));
if (!clazz)
throw new Error(`Unknown class ${match[2]} for member: ` + spec.text);
const existingMember = clazz.membersArray.find(m => m.name === name && m.kind === member.kind);
if (existingMember && isTypeOverride(existingMember, member)) {
for (const lang of member?.langs?.only || []) {
existingMember.langs.types = existingMember.langs.types || {};
existingMember.langs.types[lang] = returnType;
}
} else {
clazz.membersArray.push(member);
}
}
/**
* @param {MarkdownHeaderNode} spec
*/
parseArgument(spec) {
const match = spec.text.match(/(param|option): (.*)/);
if (!match)
throw `Something went wrong with matching ${spec.text}`;
const metainfo = extractMetainfo(spec);
if (metainfo.hidden)
return null;
// For "test.describe.only.title":
// - className is "test"
// - methodName is "describe.only"
// - argument name is "title"
const parts = match[2].split('.');
const className = parts[0];
const name = parts[parts.length - 1];
const methodName = parts.slice(1, parts.length - 1).join('.');
const clazz = this.classes.get(className);
if (!clazz)
throw new Error('Invalid class ' + className);
const method = clazz.membersArray.find(m => m.kind === 'method' && m.name === methodName);
if (!method)
throw new Error(`Invalid method ${className}.${methodName} when parsing: ${match[0]}`);
if (!name)
throw new Error('Invalid member name ' + spec.text);
if (match[1] === 'param') {
const arg = this.parseProperty(spec, match[2]);
if (!arg)
return;
arg.name = name;
const existingArg = method.argsArray.find(m => m.name === arg.name);
if (existingArg && isTypeOverride(existingArg, arg)) {
if (!arg.langs || !arg.langs.only)
throw new Error('Override does not have lang: ' + spec.text);
for (const lang of arg.langs.only) {
existingArg.langs.overrides = existingArg.langs.overrides || {};
existingArg.langs.overrides[lang] = arg;
}
} else {
method.argsArray.push(arg);
}
} else {
// match[1] === 'option'
const p = this.parseProperty(spec, match[2]);
if (!p)
return;
let options = method.argsArray.find(o => o.name === 'options');
if (!options) {
const type = new docs.Type('Object', []);
options = docs.Member.createProperty({ langs: {}, since: method.since, deprecated: undefined, discouraged: undefined }, 'options', type, undefined, false);
method.argsArray.push(options);
}
p.required = false;
options.type?.properties?.push(p);
}
}
/**
* @param {MarkdownHeaderNode} spec
* @param {string} memberName
* @returns {docs.Member | null}
*/
parseProperty(spec, memberName) {
const param = childrenWithoutProperties(spec)[0];
const text = /** @type {string}*/(param.text);
if (text.substring(text.lastIndexOf('>') + 1).trim())
throw new Error(`Extra information after type while processing "${memberName}".\nYou probably need an extra empty line before the description.\n================\n${text}`);
let typeStart = text.indexOf('<');
while ('?e'.includes(text[typeStart - 1]))
typeStart--;
const name = text.substring(0, typeStart).replace(/\`/g, '').trim();
const comments = extractComments(spec);
const metainfo = extractMetainfo(spec);
if (metainfo.hidden)
return null;
const { type, optional } = this.parseType(/** @type {MarkdownLiNode} */(param), metainfo.since ?? 'v1.0');
return docs.Member.createProperty(metainfo, name, type, comments, !optional);
}
/**
* @param {MarkdownLiNode} spec
* @param {string} since
* @return {{ type: docs.Type, optional: boolean }}
*/
parseType(spec, since) {
const arg = parseVariable(spec.text);
const properties = [];
/** @type {Object<string, string>} */
const langAliases = {};
for (const child of /** @type {MarkdownLiNode[]} */ (spec.children) || []) {
const childText = /** @type {string} */(child.text);
const aliasMatch = childText.match(/^alias(?:-(\w+))?\s*:\s*(.*)$/);
if (aliasMatch) {
langAliases[aliasMatch[1] || 'default'] = aliasMatch[2].trim();
continue;
}
const { name, text } = parseVariable(childText);
const comments = /** @type {MarkdownNode[]} */ ([{ type: 'text', text }]);
const childType = this.parseType(child, since);
properties.push(docs.Member.createProperty({ langs: {}, since, deprecated: undefined, discouraged: undefined }, name, childType.type, comments, !childType.optional));
}
const type = docs.Type.parse(arg.type, properties, langAliases);
return { type, optional: arg.optional };
}
}
/**
* @param {string} line
* @returns {{ name: string, type: string, text: string, optional: boolean }}
*/
function parseVariable(line) {
let match = line.match(/^`([^`]+)` (.*)/);
if (!match)
match = line.match(/^(returns): (.*)/);
if (!match)
match = line.match(/^(type): (.*)/);
if (!match)
match = line.match(/^(argument): (.*)/);
if (!match)
throw new Error('Invalid argument: ' + line);
const name = match[1];
let remainder = match[2];
let optional = false;
while ('?'.includes(remainder[0])) {
if (remainder[0] === '?')
optional = true;
remainder = remainder.substring(1);
}
if (!remainder.startsWith('<'))
throw new Error(`Bad argument: "${name}" in "${line}"`);
let depth = 0;
for (let i = 0; i < remainder.length; ++i) {
const c = remainder.charAt(i);
if (c === '<')
++depth;
if (c === '>')
--depth;
if (depth === 0)
return { name, type: remainder.substring(1, i), text: remainder.substring(i + 2), optional };
}
throw new Error('Should not be reached, line: ' + line);
}
/**
* @param {MarkdownNode[]} body
* @param {MarkdownNode[]} params
*/
function applyTemplates(body, params) {
const paramsMap = new Map();
for (const node of params)
paramsMap.set('%%-' + node.text + '-%%', node);
const visit = (node, parent) => {
if (node.text && node.text.includes('-inline- = %%')) {
const [name, key] = node.text.split('-inline- = ');
const list = paramsMap.get(key);
const newChildren = [];
if (!list)
throw new Error('Bad template: ' + key);
for (const prop of list.children) {
const template = paramsMap.get(prop.text);
if (!template)
throw new Error('Bad template: ' + prop.text);
const children = childrenWithoutProperties(template);
const { name: argName } = parseVariable(children[0].text || '');
newChildren.push({
type: node.type,
text: name + argName,
children: [...node.children, ...template.children.map(c => md.clone(c))]
});
}
const nodeIndex = parent.children.indexOf(node);
parent.children = [...parent.children.slice(0, nodeIndex), ...newChildren, ...parent.children.slice(nodeIndex + 1)];
} else if (node.text && node.text.includes(' = %%')) {
const [name, key] = node.text.split(' = ');
node.text = name;
const template = paramsMap.get(key);
if (!template)
throw new Error('Bad template: ' + key);
// Insert right after all metadata options like "* since",
// keeping any additional text like **Usage** below the template.
let index = node.children.findIndex(child => child.type !== 'li');
if (index === -1)
index = 0;
node.children.splice(index, 0, ...template.children.map(c => md.clone(c)));
} else if (node.text && node.text.includes('%%-template-')) {
node.text.replace(/%%-template-[^%]+-%%/, templateName => {
const template = paramsMap.get(templateName);
if (!template)
throw new Error('Bad template: ' + templateName);
const nodeIndex = parent.children.indexOf(node);
parent.children = [...parent.children.slice(0, nodeIndex), ...template.children, ...parent.children.slice(nodeIndex + 1)];
});
}
for (const child of node.children || [])
visit(child, node);
if (node.children)
node.children = node.children.filter(child => !child.text || !child.text.includes('-inline- = %%'));
};
for (const node of body)
visit(node, null);
return body;
}
/**
* @param {MarkdownHeaderNode} item
* @returns {MarkdownNode[]}
*/
function extractComments(item) {
return childrenWithoutProperties(item).filter(c => {
if (c.type.startsWith('h'))
return false;
if (c.type === 'li' && c.liType === 'default')
return false;
return true;
});
}
/**
* @param {string} apiDir
* @param {string=} paramsPath
*/
function parseApi(apiDir, paramsPath) {
return new ApiParser(apiDir, paramsPath).documentation;
}
/**
* @param {MarkdownHeaderNode} spec
* @returns {import('./documentation').Metainfo & { hidden: boolean }}
*/
function extractMetainfo(spec) {
return {
langs: extractLangs(spec),
since: extractSince(spec),
deprecated: extractAttribute(spec, 'deprecated'),
discouraged: extractAttribute(spec, 'discouraged'),
hidden: extractHidden(spec),
};
}
/**
* @param {MarkdownNode} spec
* @returns {import('./documentation').Langs}
*/
function extractLangs(spec) {
for (const child of spec.children || []) {
if (child.type !== 'li' || child.liType !== 'bullet' || !child.text.startsWith('langs:'))
continue;
const only = child.text.substring('langs:'.length).trim();
/** @type {Object<string, string>} */
const aliases = {};
for (const p of child.children || []) {
const match = /** @type {string}*/(p.text).match(/alias-(\w+)[\s]*:(.*)/);
if (match)
aliases[match[1].trim()] = match[2].trim();
}
return {
only: only ? only.split(',').map(l => l.trim()) : undefined,
aliases,
types: {},
overrides: {}
};
}
return {};
}
/**
* @param {MarkdownHeaderNode} spec
* @returns {string}
*/
function extractSince(spec) {
for (const child of spec.children) {
if (child.type !== 'li' || child.liType !== 'bullet' || !child.text.startsWith('since:'))
continue;
return child.text.substring(child.text.indexOf(':') + 1).trim();
}
console.error('Missing since: v1.** declaration in node:');
console.error(spec);
process.exit(1);
}
/**
* @param {MarkdownHeaderNode} spec
* @returns {boolean}
*/
function extractHidden(spec) {
for (const child of spec.children) {
if (child.type === 'li' && child.liType === 'bullet' && child.text === 'hidden')
return true;
}
return false;
}
/**
* @param {MarkdownHeaderNode} spec
* @param {string} name
* @returns {string | undefined}
*/
function extractAttribute(spec, name) {
for (const child of spec.children) {
if (child.type !== 'li' || child.liType !== 'bullet' || !child.text.startsWith(name + ':'))
continue;
return child.text.substring(child.text.indexOf(':') + 1).trim() || undefined;
}
}
/**
* @param {MarkdownHeaderNode} spec
* @returns {MarkdownNode[]}
*/
function childrenWithoutProperties(spec) {
return (spec.children || []).filter(c => {
const isProperty = c.type === 'li' && c.liType === 'bullet' && (c.text.startsWith('langs:') || c.text.startsWith('since:') || c.text.startsWith('deprecated:') || c.text.startsWith('discouraged:') || c.text === 'hidden');
return !isProperty;
});
}
/**
* @param {docs.Member} existingMember
* @param {docs.Member} member
* @returns {boolean}
*/
function isTypeOverride(existingMember, member) {
if (!existingMember.langs.only || !member.langs.only)
return true;
const existingOnly = existingMember.langs.only;
if (member.langs.only.every(l => existingOnly.includes(l))) {
return true;
} else if (member.langs.only.some(l => existingOnly.includes(l))) {
throw new Error(`Ambiguous language override for: ${member.name}`);
}
return false;
}
/**
* @param {MarkdownNode[]=} params
*/
function checkNoDuplicateParamEntries(params) {
if (!params)
return;
const entries = new Set();
for (const node of params) {
if (entries.has(node.text))
throw new Error('Duplicate param entry, for language-specific params use prefix (e.g. js-...): ' + node.text);
entries.add(node.text);
}
}
module.exports = { parseApi };

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env node
/**
* Copyright 2017 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//@ts-check
const playwright = require('playwright-core');
const fs = require('fs');
const path = require('path');
const { parseApi } = require('./api_parser');
const md = require('../markdown');
const docs = require('./documentation');
const toKebabCase = require('lodash/kebabCase')
/** @typedef {import('./documentation').Type} Type */
/** @typedef {import('../markdown').MarkdownNode} MarkdownNode */
const PROJECT_DIR = path.join(__dirname, '..', '..');
const dirtyFiles = new Set();
run().catch(e => {
console.error(e);
process.exit(1);
});;
function getAllMarkdownFiles(dirPath, filePaths = []) {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
filePaths.push(path.join(dirPath, entry.name));
else if (entry.isDirectory())
getAllMarkdownFiles(path.join(dirPath, entry.name), filePaths);
}
return filePaths;
}
async function run() {
// Patch README.md
const versions = await getBrowserVersions();
{
const params = new Map();
const { chromium, firefox, webkit } = versions;
params.set('chromium-version', chromium);
params.set('firefox-version', firefox);
params.set('webkit-version', webkit);
params.set('chromium-version-badge', `[![Chromium version](https://img.shields.io/badge/chromium-${chromium}-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)`);
params.set('firefox-version-badge', `[![Firefox version](https://img.shields.io/badge/firefox-${firefox}-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)`);
params.set('webkit-version-badge', `[![WebKit version](https://img.shields.io/badge/webkit-${webkit}-blue.svg?logo=safari)](https://webkit.org/)`);
let content = fs.readFileSync(path.join(PROJECT_DIR, 'README.md')).toString();
content = content.replace(/<!-- GEN:([^ ]+) -->([^<]*)<!-- GEN:stop -->/ig, (match, p1) => {
if (!params.has(p1)) {
console.log(`ERROR: Invalid generate parameter "${p1}" in "${match}"`);
process.exit(1);
}
return `<!-- GEN:${p1} -->${params.get(p1)}<!-- GEN:stop -->`;
});
writeAssumeNoop(path.join(PROJECT_DIR, 'README.md'), content, dirtyFiles);
}
let playwrightVersion = require(path.join(PROJECT_DIR, 'package.json')).version;
if (playwrightVersion.endsWith('-next'))
playwrightVersion = playwrightVersion.substring(0, playwrightVersion.indexOf('-next'));
// Ensure browser versions in browsers.json. This is most important for WebKit
// since its version is hardcoded in Playwright library rather then in browser builds.
// @see https://github.com/microsoft/playwright/issues/15702
{
const browsersJSONPath = path.join(__dirname, '..', '..', 'packages/playwright-core/browsers.json');
const browsersJSON = JSON.parse(await fs.promises.readFile(browsersJSONPath, 'utf8'));
for (const browser of browsersJSON.browsers) {
if (versions[browser.name])
browser.browserVersion = versions[browser.name];
}
writeAssumeNoop(browsersJSONPath, JSON.stringify(browsersJSON, null, 2) + '\n', dirtyFiles);
}
// Update device descriptors
{
const devicesDescriptorsSourceFile = path.join(PROJECT_DIR, 'packages', 'playwright-core', 'src', 'server', 'deviceDescriptorsSource.json')
const devicesDescriptors = require(devicesDescriptorsSourceFile)
for (const deviceName of Object.keys(devicesDescriptors)) {
switch (devicesDescriptors[deviceName].defaultBrowserType) {
case 'chromium':
devicesDescriptors[deviceName].userAgent = devicesDescriptors[deviceName].userAgent.replace(
/(.*Chrome\/)(.*?)( .*)/,
`$1${versions.chromium}$3`
).replace(
/(.*Edg\/)(.*?)$/,
`$1${versions.chromium}`
)
break;
case 'firefox':
devicesDescriptors[deviceName].userAgent = devicesDescriptors[deviceName].userAgent.replace(
/^(.*Firefox\/)(.*?)( .*?)?$/,
`$1${versions.firefox}$3`
).replace(/^(.*rv:)(.*)(\).*?)$/, `$1${versions.firefox}$3`)
break;
case 'webkit':
devicesDescriptors[deviceName].userAgent = devicesDescriptors[deviceName].userAgent.replace(
/(.*Version\/)(.*?)( .*)/,
`$1${versions.webkit}$3`
)
break;
default:
break;
}
}
const invalidConfigurations = Object.entries(devicesDescriptors).filter(([_, deviceDescriptor]) => deviceDescriptor.isMobile && deviceDescriptor.defaultBrowserType === 'firefox').map(([deviceName, deviceDescriptor]) => deviceName);
if (invalidConfigurations.length > 0)
throw new Error(`Invalid Device Configurations. isMobile with Firefox not supported: ${invalidConfigurations.join(', ')}`);
writeAssumeNoop(devicesDescriptorsSourceFile, JSON.stringify(devicesDescriptors, null, 2), dirtyFiles);
}
// Validate links/code snippet langs
{
const langs = ['js', 'java', 'python', 'csharp'];
const documentationRoot = path.join(PROJECT_DIR, 'docs', 'src');
const apiRoot = path.join(documentationRoot, 'api');
const testApiRoot = path.join(documentationRoot, 'test-api');
const testReporterApiRoot = path.join(documentationRoot, 'test-reporter-api');
const electronApiRoot = path.join(documentationRoot, 'electron-api');
const mobileApiRoot = path.join(documentationRoot, 'mobile-api');
for (const lang of langs) {
try {
let documentation = parseApi(apiRoot);
if (lang === 'js') {
documentation = documentation.mergeWith(
parseApi(testApiRoot, path.join(documentationRoot, 'api', 'params.md'))
).mergeWith(
parseApi(testReporterApiRoot)
).mergeWith(
parseApi(electronApiRoot, path.join(documentationRoot, 'api', 'params.md'))
).mergeWith(
parseApi(mobileApiRoot, path.join(documentationRoot, 'api', 'params.md'))
);
}
documentation.filterForLanguage(lang);
// This validates member links.
documentation.setLinkRenderer(() => undefined);
// This validates code snippet groups in comments.
documentation.setCodeGroupsTransformer(lang, tabs => tabs.map(tab => tab.spec));
documentation.generateSourceCodeComments();
const mdLinks = [];
const mdSections = new Set();
for (const cls of documentation.classesArray) {
const filePath = path.join(documentationRoot, 'api', 'class-' + cls.name.toLowerCase() + '.md');
for (const member of cls.membersArray) {
const memberHash = filePath + '#' + toKebabCase(cls.name).toLowerCase() + '-' + toKebabCase(member.name).toLowerCase()
mdSections.add(memberHash);
for (const arg of member.argsArray) {
mdSections.add(memberHash + '-option-' + toKebabCase(arg.name).toLowerCase());
if (arg.name === "options" && arg.type) {
for (const option of arg.type.deepProperties())
mdSections.add(memberHash + '-option-' + toKebabCase(option.name).toLowerCase());
}
}
}
for (const event of cls.eventsArray)
mdSections.add(filePath + '#' + toKebabCase(cls.name).toLowerCase() + '-event-' + toKebabCase(event.name).toLowerCase());
}
for (const filePath of getAllMarkdownFiles(documentationRoot)) {
if (!filePath.includes(`-${lang}`) && langs.some(other => other !== lang && filePath.includes(`-${other}`)))
continue;
// Standardise naming and remove the filter in the file name
// Also, Internally (playwright.dev generator) we merge test-api and test-reporter-api into api.
const canonicalName = filePath.replace(/(-(js|python|csharp|java))+/, '').replace(/(\/|\\)(test-api|test-reporter-api|electron-api|mobile-api)(\/|\\)/, `${path.sep}api${path.sep}`);
mdSections.add(canonicalName);
const data = fs.readFileSync(filePath, 'utf-8');
let rootNode = md.filterNodesForLanguage(md.parse(data), lang);
// Validates code snippet groups.
rootNode = docs.processCodeGroups(rootNode, lang, tabs => tabs.map(tab => tab.spec));
// Renders links.
if (!filePath.startsWith(apiRoot) && !filePath.startsWith(testApiRoot) && !filePath.startsWith(testReporterApiRoot) && !filePath.startsWith(electronApiRoot) && !filePath.startsWith(mobileApiRoot))
documentation.renderLinksInNodes(rootNode);
// Validate links.
{
md.visitAll(rootNode, node => {
if (node.type === 'code') {
const allowedCodeLangs = new Set([
'csharp',
'java',
'css',
'js',
'markdown',
'ts',
'python',
'py',
'java',
'powershell',
'batch',
'ini',
'txt',
'html',
'xml',
'yml',
'yaml',
'json',
'groovy',
'html',
'bash',
'sh',
'Dockerfile',
]);
if (!allowedCodeLangs.has(node.codeLang.split(' ')[0]))
throw new Error(`${path.relative(PROJECT_DIR, filePath)} contains code block with invalid code block language "${node.codeLang}"`);
}
if (node.type.startsWith('h')) {
const hash = mdSectionHash(node.text || '');
mdSections.add(canonicalName + '#' + hash);
}
if (!node.text)
return;
// Match links in a lax way (.+), so they can include spaces, backticks etc.
for (const [, mdLinkName, mdLink] of node.text.matchAll(/\[(.+)\]\((.*?)\)/g)) {
const isExternal = mdLink.startsWith('http://') || mdLink.startsWith('https://');
if (isExternal)
continue;
const [beforeHash, hash] = mdLink.split('#');
let linkWithoutHash = canonicalName;
if (beforeHash) {
// Not same-file link.
linkWithoutHash = path.join(path.dirname(filePath), beforeHash);
if (path.extname(linkWithoutHash) !== '.md')
linkWithoutHash += '.md';
}
mdLinks.push({ filePath, linkTarget: linkWithoutHash + (hash ? '#' + hash : ''), name: mdLinkName });
}
});
}
}
const badLinks = [];
for (const { filePath, linkTarget, name } of mdLinks) {
if (linkTarget.startsWith(path.join(documentationRoot, 'images')))
continue;
if (!mdSections.has(linkTarget))
badLinks.push(`${path.relative(PROJECT_DIR, filePath)} references to '${linkTarget}' as '${name}' which does not exist.`);
}
if (badLinks.length)
throw new Error('Broken links found:\n' + badLinks.join('\n'));
} catch (e) {
e.message = `While processing "${lang}"\n` + e.message;
throw e;
}
}
}
if (dirtyFiles.size) {
console.log('============================')
console.log('ERROR: generated files have changed, this is only error if happens in CI:');
[...dirtyFiles].forEach(f => console.log(f));
console.log('============================')
process.exit(1);
}
process.exit(0);
}
/**
* @param {string} name
* @param {string} content
* @param {Set<string>} dirtyFiles
*/
function writeAssumeNoop(name, content, dirtyFiles) {
fs.mkdirSync(path.dirname(name), { recursive: true });
const oldContent = fs.existsSync(name) ? fs.readFileSync(name).toString() : '';
if (oldContent !== content) {
fs.writeFileSync(name, content);
dirtyFiles.add(name);
}
}
async function getBrowserVersions() {
const names = ['chromium', 'firefox', 'webkit'];
const browsers = await Promise.all(names.map(name => playwright[name].launch()));
const result = {};
for (let i = 0; i < names.length; i++) {
result[names[i]] = browsers[i].version();
}
await Promise.all(browsers.map(browser => browser.close()));
return result;
}
/**
* @param {string} text
* @returns {string}
*/
function mdSectionHash(text) {
return text.toLowerCase().replace(/\s/g, '-').replace(/[^-_a-z0-9]/g, '').replace(/^-+/, '');
}

View File

@@ -0,0 +1,983 @@
/**
* Copyright 2017 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
const md = require('../markdown');
/** @typedef {import('../markdown').MarkdownNode} MarkdownNode */
/**
* @typedef {{
* name: string,
* args: ParsedType | null,
* retType: ParsedType | null,
* template: ParsedType | null,
* union: ParsedType | null,
* unionName?: string,
* next: ParsedType | null,
* }} ParsedType
*/
/**
* @typedef {{
* only?: string[],
* aliases?: Object<string, string>,
* types?: Object<string, Type>,
* overrides?: Object<string, Member>,
* }} Langs
*/
/**
* @typedef {function({
* clazz?: Class,
* member?: Member,
* param?: { name: string, alias: string },
* option?: { name: string, alias: string },
* href?: string,
* }): string|undefined} Renderer
*/
/**
* @typedef {{
* langs: Langs,
* since: string,
* deprecated?: string | undefined,
* discouraged?: string | undefined,
* }} Metainfo
*/
/**
* @typedef {{
* csharpOptionOverloadsShortNotation?: boolean,
* }} LanguageOptions
*/
/** @typedef {{
* value: string, groupId: string, spec: MarkdownNode
* }} CodeGroup */
/** @typedef {function(CodeGroup[]): MarkdownNode[]} CodeGroupTransformer */
class Documentation {
/**
* @param {!Array<!Class>} classesArray
*/
constructor(classesArray) {
this.classesArray = classesArray;
/** @type {!Map<string, !Class>} */
this.classes = new Map();
this.index();
}
/**
* @param {!Documentation} documentation
* @return {!Documentation}
*/
mergeWith(documentation) {
return new Documentation([...this.classesArray, ...documentation.classesArray].map(cls => cls.clone()));
}
/**
* @param {string[]} errors
*/
copyDocsFromSuperclasses(errors) {
for (const [name, clazz] of this.classes.entries()) {
clazz.sortMembers();
if (!clazz.extends || ['Error', 'Exception', 'RuntimeException'].includes(clazz.extends))
continue;
const superClass = this.classes.get(clazz.extends);
if (!superClass) {
errors.push(`Undefined superclass: ${superClass} in ${name}`);
continue;
}
for (const memberName of clazz.members.keys()) {
if (superClass.members.has(memberName))
errors.push(`Member documentation overrides base: ${name}.${memberName} over ${clazz.extends}.${memberName}`);
}
clazz.membersArray = [...clazz.membersArray, ...superClass.membersArray.map(c => c.clone())];
clazz.index();
}
}
/**
* @param {string} lang
* @param {LanguageOptions=} options
*/
filterForLanguage(lang, options = {}) {
const classesArray = [];
for (const clazz of this.classesArray) {
if (clazz.langs.only && !clazz.langs.only.includes(lang))
continue;
clazz.filterForLanguage(lang, options);
classesArray.push(clazz);
}
this.classesArray = classesArray;
this.index();
}
index() {
for (const cls of this.classesArray) {
this.classes.set(cls.name, cls);
cls.index();
}
}
/**
* @param {Renderer} linkRenderer
*/
setLinkRenderer(linkRenderer) {
// @type {Map<string, Class>}
const classesMap = new Map();
const membersMap = new Map();
for (const clazz of this.classesArray) {
classesMap.set(clazz.name, clazz);
for (const member of clazz.membersArray)
membersMap.set(`${member.kind}: ${clazz.name}.${member.name}`, member);
}
/**
* @param {Class|Member|undefined} classOrMember
* @param {string} text
*/
this._patchLinksInText = (classOrMember, text) => patchLinksInText(classOrMember, text, classesMap, membersMap, linkRenderer);
for (const clazz of this.classesArray)
clazz.visit(item => item.spec && this.renderLinksInNodes(item.spec, item));
}
/**
* @param {MarkdownNode[]} nodes
* @param {Class|Member=} classOrMember
*/
renderLinksInNodes(nodes, classOrMember) {
if (classOrMember instanceof Member) {
classOrMember.discouraged = classOrMember.discouraged ? this.renderLinksInText(classOrMember.discouraged, classOrMember) : undefined;
classOrMember.deprecated = classOrMember.deprecated ? this.renderLinksInText(classOrMember.deprecated, classOrMember) : undefined;
}
md.visitAll(nodes, node => {
if (!node.text)
return;
node.text = this.renderLinksInText(node.text, classOrMember);
});
}
/**
* @param {string} text
* @param {Class|Member=} classOrMember
*/
renderLinksInText(text, classOrMember) {
return this._patchLinksInText?.(classOrMember, text);
}
/**
* @param {string} lang
* @param {CodeGroupTransformer} transformer
*/
setCodeGroupsTransformer(lang, transformer) {
this._codeGroupsTransformer = { lang, transformer };
}
generateSourceCodeComments() {
for (const clazz of this.classesArray) {
clazz.visit(item => {
let spec = item.spec;
if (spec && this._codeGroupsTransformer)
spec = processCodeGroups(spec, this._codeGroupsTransformer.lang, this._codeGroupsTransformer.transformer);
item.comment = generateSourceCodeComment(spec);
});
}
}
clone() {
return new Documentation(this.classesArray.map(cls => cls.clone()));
}
}
class Class {
/**
* @param {Metainfo} metainfo
* @param {string} name
* @param {!Array<!Member>} membersArray
* @param {?string=} extendsName
* @param {MarkdownNode[]=} spec
*/
constructor(metainfo, name, membersArray, extendsName = null, spec = undefined) {
this.langs = metainfo.langs;
this.since = metainfo.since;
this.deprecated = metainfo.deprecated;
this.discouraged = metainfo.discouraged;
this.name = name;
this.membersArray = membersArray;
this.spec = spec;
this.extends = extendsName;
this.comment = '';
this.index();
const match = /** @type {string[]} */(name.match(/(API|JS|CDP|[A-Z])(.*)/));
this.varName = match[1].toLowerCase() + match[2];
/** @type {!Map<string, !Member>} */
this.members = new Map();
/** @type {!Map<string, !Member>} */
this.properties = new Map();
/** @type {!Array<!Member>} */
this.propertiesArray = [];
/** @type {!Map<string, !Member>} */
this.methods = new Map();
/** @type {!Array<!Member>} */
this.methodsArray = [];
/** @type {!Map<string, !Member>} */
this.events = new Map();
/** @type {!Array<!Member>} */
this.eventsArray = [];
}
index() {
this.members = new Map();
this.properties = new Map();
this.propertiesArray = [];
this.methods = new Map();
this.methodsArray = [];
this.events = new Map();
this.eventsArray = [];
for (const member of this.membersArray) {
this.members.set(member.name, member);
if (member.kind === 'method') {
this.methods.set(member.name, member);
this.methodsArray.push(member);
} else if (member.kind === 'property') {
this.properties.set(member.name, member);
this.propertiesArray.push(member);
} else if (member.kind === 'event') {
this.events.set(member.name, member);
this.eventsArray.push(member);
}
member.clazz = this;
member.index();
}
}
clone() {
const cls = new Class({ langs: this.langs, since: this.since, deprecated: this.deprecated, discouraged: this.discouraged }, this.name, this.membersArray.map(m => m.clone()), this.extends, this.spec);
cls.comment = this.comment;
return cls;
}
/**
* @param {string} lang
* @param {LanguageOptions=} options
*/
filterForLanguage(lang, options = {}) {
const membersArray = [];
for (const member of this.membersArray) {
if (member.langs.only && !member.langs.only.includes(lang))
continue;
member.filterForLanguage(lang, options);
membersArray.push(member);
}
this.membersArray = membersArray;
}
sortMembers() {
/**
* @param {Member} member
*/
function sortKey(member) {
return { 'event': 'a', 'method': 'b', 'property': 'c' }[member.kind] + member.alias;
}
this.membersArray.sort((m1, m2) => {
return sortKey(m1).localeCompare(sortKey(m2), 'en', { sensitivity: 'base' });
});
// Ideally, we would automatically make options the last argument.
// However, that breaks Java, since options are not always last in Java, for example
// in page.waitForFileChooser(options, callback).
// So, the order must be carefully setup in the md file!
}
/**
* @param {function(Member|Class): void} visitor
*/
visit(visitor) {
visitor(this);
for (const p of this.propertiesArray)
p.visit(visitor);
for (const m of this.methodsArray)
m.visit(visitor);
for (const e of this.eventsArray)
e.visit(visitor);
}
}
class Member {
/**
* @param {string} kind
* @param {Metainfo} metainfo
* @param {string} name
* @param {?Type} type
* @param {!Array<!Member>} argsArray
* @param {MarkdownNode[]=} spec
* @param {boolean=} required
*/
constructor(kind, metainfo, name, type, argsArray, spec = undefined, required = true) {
this.kind = kind;
this.langs = metainfo.langs;
this.since = metainfo.since;
this.deprecated = metainfo.deprecated;
this.discouraged = metainfo.discouraged;
this.name = name;
this.type = type;
this.spec = spec;
this.argsArray = argsArray;
this.required = required;
this.comment = '';
/** @type {!Map<string, !Member>} */
this.args = new Map();
this.index();
/** @type {!Class | null} */
this.clazz = null;
/** @type {Member=} */
this.enclosingMethod = undefined;
/** @type {Member=} */
this.parent = undefined;
this.async = false;
this.alias = name;
this.overloadIndex = 0;
if (name.includes('#')) {
const match = /** @type {string[]} */(name.match(/(.*)#(.*)/));
this.alias = match[1];
this.overloadIndex = (+match[2]) - 1;
}
}
index() {
this.args = new Map();
if (this.kind === 'method')
this.enclosingMethod = this;
const indexArg = (/** @type {Member} */ arg) => {
arg.type?.deepProperties().forEach(p => {
p.enclosingMethod = this;
p.parent = arg;
indexArg(p);
});
}
for (const arg of this.argsArray) {
this.args.set(arg.name, arg);
arg.enclosingMethod = this;
if (arg.name === 'options')
arg.type?.properties?.sort((p1, p2) => p1.name.localeCompare(p2.name));
indexArg(arg);
}
// Also index return type properties so they have enclosingMethod set.
if (this.kind === 'method' || this.kind === 'property')
indexArg(this);
}
/**
* @param {string} lang
* @param {LanguageOptions=} options
*/
filterForLanguage(lang, options = {}) {
if (!this.type)
return;
if (this.langs.aliases && this.langs.aliases[lang])
this.alias = this.langs.aliases[lang];
if (this.langs.types && this.langs.types[lang])
this.type = this.langs.types[lang];
this.type.filterForLanguage(lang, options);
const argsArray = [];
for (const arg of this.argsArray) {
if (arg.langs.only && !arg.langs.only.includes(lang))
continue;
const overriddenArg = (arg.langs.overrides && arg.langs.overrides[lang]) || arg;
overriddenArg.filterForLanguage(lang, options);
if (overriddenArg.name === 'options' && !overriddenArg.type?.properties?.length)
continue;
overriddenArg.type?.filterForLanguage(lang, options);
argsArray.push(overriddenArg);
}
this.argsArray = argsArray;
const optionsArg = this.argsArray.find(arg => arg.name === 'options');
if (lang === 'csharp' && optionsArg) {
try {
patchCSharpOptionOverloads(optionsArg, options);
} catch (e) {
throw new Error(`Error processing csharp options in ${this.clazz?.name}.${this.name}: ` + e.message);
}
}
}
clone() {
const result = new Member(this.kind, { langs: this.langs, since: this.since, deprecated: this.deprecated, discouraged: this.discouraged }, this.name, this.type?.clone(), this.argsArray.map(arg => arg.clone()), this.spec, this.required);
result.alias = this.alias;
result.async = this.async;
return result;
}
/**
* @param {Metainfo} metainfo
* @param {string} name
* @param {!Array<!Member>} argsArray
* @param {?Type} returnType
* @param {MarkdownNode[]=} spec
* @return {!Member}
*/
static createMethod(metainfo, name, argsArray, returnType, spec) {
return new Member('method', metainfo, name, returnType, argsArray, spec);
}
/**
* @param {Metainfo} metainfo
* @param {!string} name
* @param {!Type} type
* @param {!MarkdownNode[]=} spec
* @param {boolean=} required
* @return {!Member}
*/
static createProperty(metainfo, name, type, spec, required) {
return new Member('property', metainfo, name, type, [], spec, required);
}
/**
* @param {Metainfo} metainfo
* @param {string} name
* @param {?Type=} type
* @param {MarkdownNode[]=} spec
* @return {!Member}
*/
static createEvent(metainfo, name, type = null, spec) {
return new Member('event', metainfo, name, type, [], spec);
}
/**
* @param {function(Member|Class): void} visitor
*/
visit(visitor) {
visitor(this);
if (this.type)
this.type.visit(visitor);
for (const arg of this.argsArray)
arg.visit(visitor);
for (const lang in this.langs.overrides || {})
this.langs.overrides?.[lang].visit(visitor);
}
}
class Type {
/**
* @param {string} expression
* @param {!Array<!Member>=} properties
* @param {!Object<string, string>=} langAliases
* @return {Type}
*/
static parse(expression, properties = [], langAliases = {}) {
expression = expression.replace(/\\\(/g, '(').replace(/\\\)/g, ')');
const type = Type.fromParsedType(parseTypeExpression(expression));
type.expression = expression;
if (type.name === 'number')
throw new Error('Number types should be either int or float, not number in: ' + expression);
const hasAliases = Object.keys(langAliases).length > 0;
if (!properties.length && !hasAliases)
return type;
const types = [];
type._collectAllTypes(types);
let assignedToObject = false;
for (const t of types) {
if (t.name === 'Object') {
if (properties.length)
t.properties = properties;
if (hasAliases)
t.langAliases = { ...langAliases };
assignedToObject = true;
}
}
if (!assignedToObject) {
if (properties.length)
throw new Error('Nested properties given, but there are no objects in type expression: ' + expression);
if (hasAliases)
type.langAliases = { ...langAliases };
}
return type;
}
/**
* @param {ParsedType} parsedType
* @return {Type}
*/
static fromParsedType(parsedType, inUnion = false) {
if (!inUnion && !parsedType.unionName && isStringUnion(parsedType))
throw new Error('Enum must have a name:\n' + JSON.stringify(parsedType, null, 2));
if (!inUnion && (parsedType.union || parsedType.unionName)) {
const type = new Type(parsedType.unionName || '');
type.union = [];
for (let /** @type {ParsedType | null} */ t = parsedType; t; t = t.union) {
const nestedUnion = !!t.unionName && t !== parsedType;
type.union.push(Type.fromParsedType(t, !nestedUnion));
if (nestedUnion)
break;
}
return type;
}
if (parsedType.args || parsedType.retType) {
const type = new Type('function');
type.args = [];
for (let t = parsedType.args; t; t = t.next)
type.args.push(Type.fromParsedType(t));
type.returnType = parsedType.retType ? Type.fromParsedType(parsedType.retType) : undefined;
return type;
}
if (parsedType.template) {
const type = new Type(parsedType.name);
type.templates = [];
for (let /** @type {ParsedType | null} */ t = parsedType.template; t; t = t.next)
type.templates.push(Type.fromParsedType(t));
return type;
}
return new Type(parsedType.name);
}
/**
* @param {string} name
* @param {!Array<!Member>=} properties
*/
constructor(name, properties) {
this.name = name.replace(/^\[/, '').replace(/\]$/, '');
/** @type {Member[] | undefined} */
this.properties = this.name === 'Object' ? properties : undefined;
/** @type {Type[] | undefined} */
this.union = undefined;
/** @type {Type[] | undefined} */
this.args = undefined;
/** @type {Type | undefined} */
this.returnType = undefined;
/** @type {Type[] | undefined} */
this.templates = undefined;
/** @type {string | undefined} */
this.expression = undefined;
/** @type {Object<string, string> | undefined} */
this.langAliases = undefined;
}
visit(visitor) {
const types = [];
this._collectAllTypes(types);
for (const type of types) {
for (const p of type.properties || [])
p.visit(visitor);
}
}
clone() {
const type = new Type(this.name, this.properties ? this.properties.map(prop => prop.clone()) : undefined);
if (this.union)
type.union = this.union.map(type => type.clone());
if (this.args)
type.args = this.args.map(type => type.clone());
if (this.returnType)
type.returnType = this.returnType.clone();
if (this.templates)
type.templates = this.templates.map(type => type.clone());
type.expression = this.expression;
if (this.langAliases)
type.langAliases = { ...this.langAliases };
return type;
}
/**
* @returns {Member[]}
*/
deepProperties() {
const types = [];
this._collectAllTypes(types);
for (const type of types) {
if (type.properties && type.properties.length)
return type.properties;
}
return [];
}
/**
* @param {string} lang
* @param {LanguageOptions=} options
*/
filterForLanguage(lang, options = {}) {
if (!this.properties)
return;
const properties = [];
for (const prop of this.properties) {
if (prop.langs.only && !prop.langs.only.includes(lang))
continue;
prop.filterForLanguage(lang, options);
properties.push(prop);
}
this.properties = properties;
}
/**
* @param {Type[]} result
*/
_collectAllTypes(result) {
result.push(this);
for (const t of this.union || [])
t._collectAllTypes(result);
for (const t of this.args || [])
t._collectAllTypes(result);
for (const t of this.templates || [])
t._collectAllTypes(result);
if (this.returnType)
this.returnType._collectAllTypes(result);
}
}
/**
* @param {ParsedType | null} type
* @returns {boolean}
*/
function isStringUnion(type) {
while (type) {
if (!type.name.startsWith('"') || !type.name.endsWith('"'))
return false;
type = type.union;
}
return true;
}
/**
* @param {string} type
* @returns {ParsedType}
*/
function parseTypeExpression(type) {
type = type.trim();
let name = type;
let next = null;
let template = null;
let args = null;
let retType = null;
let firstTypeLength = type.length;
for (let i = 0; i < type.length; i++) {
if (type[i] === '<') {
name = type.substring(0, i);
const matching = matchingBracket(type.substring(i), '<', '>');
template = parseTypeExpression(type.substring(i + 1, i + matching - 1));
firstTypeLength = i + matching;
break;
}
if (type[i] === '(') {
name = type.substring(0, i);
const matching = matchingBracket(type.substring(i), '(', ')');
const argsString = type.substring(i + 1, i + matching - 1);
args = argsString ? parseTypeExpression(argsString) : null;
i = i + matching;
if (type[i] === ':') {
retType = parseTypeExpression(type.substring(i + 1));
next = retType.next;
retType.next = null;
break;
}
}
if (type[i] === '|' || type[i] === ',') {
name = type.substring(0, i);
firstTypeLength = i;
break;
}
}
let union = null;
if (type[firstTypeLength] === '|')
union = parseTypeExpression(type.substring(firstTypeLength + 1));
else if (type[firstTypeLength] === ',')
next = parseTypeExpression(type.substring(firstTypeLength + 1));
if (template && !template.unionName && isStringUnion(template)) {
template.unionName = name;
return template;
}
return {
name,
args,
retType,
template,
union,
next
};
}
/**
* @param {string} str
* @param {any} open
* @param {any} close
*/
function matchingBracket(str, open, close) {
let count = 1;
let i = 1;
for (; i < str.length && count; i++) {
if (str[i] === open)
count++;
else if (str[i] === close)
count--;
}
return i;
}
/**
* @param {Class|Member|undefined} classOrMember
* @param {string} text
* @param {Map<string, Class>} classesMap
* @param {Map<string, Member>} membersMap
* @param {Renderer} linkRenderer
*/
function patchLinksInText(classOrMember, text, classesMap, membersMap, linkRenderer) {
text = text.replace(/\[`(\w+): ([^\]]+)`\](?:\(([^)]*?)\))?/g, (match, p1, p2, href) => {
if (['event', 'method', 'property'].includes(p1)) {
const memberName = p1 + ': ' + p2;
const member = membersMap.get(memberName);
if (!member)
throw new Error(`Undefined member reference: ${match}\n=========\n${text}`);
return linkRenderer({ member, href }) || match;
}
if (p1 === 'param' || p1 === 'option') {
let /** @type {string } */ name;
let /** @type {Member} */ member;
if (p2.includes('.')) {
// fully-qualified name
const [className, memberName, ...rest] = p2.split('.');
const maybeMember = membersMap.get(`method: ${className}.${memberName}`);
if (!maybeMember)
throw new Error(`Undefined reference: ${match}\n=========\n${text}`);
member = maybeMember;
name = rest.join('.');
} else {
// non-fully-qualified param/option reference from the same method.
if (!classOrMember || !(classOrMember instanceof Member)) {
Error.stackTraceLimit = 100;
throw new Error(`No parent method to find referenced ${match}\n=========\n${text}`);
}
const maybeMember = classOrMember.enclosingMethod;
if (!maybeMember)
throw new Error(`Undefined reference: ${match}\n=========\n${text}`);
member = maybeMember;
name = p2;
}
if (p1 === 'param') {
const param = member.argsArray.find(a => a.name === name);
if (!param)
throw new Error(`Referenced parameter ${match} not found in the parent method ${member.name}\n=========\n${text}`);
return linkRenderer({ member, param: { name, alias: param.alias }, href }) || match;
} else {
// p1 === 'option'
const options = member.argsArray.find(a => a.name === 'options');
const parts = name.split('.');
const optionName = parts[0];
const option = options?.type?.properties?.find(a => a.name === optionName);
if (!option)
throw new Error(`Referenced option ${match} not found in the parent method ${member.name}\n=========\n${text}`);
parts[0] = option.alias;
return linkRenderer({ member, option: { name: optionName, alias: parts.join('.') }, href }) || match;
}
}
throw new Error(`Undefined link prefix, expected event|method|property|param|option, got: ` + match);
});
text = text.replace(/\[([\w]+)\](?:\(([^)]*?)\))?/g, (match, p1, href) => {
const clazz = classesMap.get(p1);
if (clazz)
return linkRenderer({ clazz, href }) || match;
return match;
});
return text;
}
/**
* @param {MarkdownNode[] | undefined} spec
*/
function generateSourceCodeComment(spec) {
const comments = (spec || []).filter(n => !n.type.startsWith('h') && (n.type !== 'li' || n.liType !== 'default')).map(c => md.clone(c));
md.visitAll(comments, node => {
if (node.type === 'li' && node.liType === 'bullet')
node.liType = 'default';
if (node.type === 'code' && node.codeLang)
node.codeLang = parseCodeLang(node.codeLang).highlighter;
});
// 5 is a typical member doc offset.
return md.render(comments, { maxColumns: 120 - 5, omitLastCR: true, flattenText: true, noteMode: 'compact' });
}
/**
* @param {Member} optionsArg
* @param {LanguageOptions=} options
*/
function patchCSharpOptionOverloads(optionsArg, options = {}) {
const props = optionsArg.type?.properties;
if (!props)
return;
const propsToDelete = new Set();
const propsToAdd = [];
for (const prop of props) {
const union = prop.type?.union;
if (!union)
continue;
const isEnum = union[0].name.startsWith('"');
const isNullable = union.length === 2 && union.some(type => type.name === 'null');
if (isEnum || isNullable)
continue;
const shortNotation = [];
propsToDelete.add(prop);
for (const type of union) {
const suffix = csharpOptionOverloadSuffix(prop.name, type.name);
if (options.csharpOptionOverloadsShortNotation) {
if (type.name === 'string')
shortNotation.push(prop.alias);
else
shortNotation.push(prop.alias + suffix);
continue;
}
const newProp = prop.clone();
newProp.name = prop.name + suffix;
newProp.alias = prop.alias + suffix;
newProp.type = type;
propsToAdd.push(newProp);
if (type.name === 'string') {
const stringProp = prop.clone();
stringProp.type = type;
propsToAdd.push(stringProp);
}
}
if (options.csharpOptionOverloadsShortNotation) {
const newProp = prop.clone();
newProp.name = prop.name;
newProp.alias = shortNotation.join('|');
propsToAdd.push(newProp);
}
}
for (const prop of propsToDelete)
props.splice(props.indexOf(prop), 1);
props.push(...propsToAdd);
}
/**
* @param {string} option
* @param {string} type
*/
function csharpOptionOverloadSuffix(option, type) {
switch (type) {
case 'string': return 'String';
case 'RegExp': return 'Regex';
case 'function': return 'Func';
case 'Buffer': return 'Byte';
case 'Serializable': return 'Object';
case 'int': return 'Int';
case 'long': return 'Int64';
case 'Date': return 'Date';
}
throw new Error(`CSharp option "${option}" has unsupported type overload "${type}"`);
}
/**
* @param {MarkdownNode[]} spec
* @param {string} language
* @param {CodeGroupTransformer} transformer
* @returns {MarkdownNode[]}
*/
function processCodeGroups(spec, language, transformer) {
/** @type {MarkdownNode[]} */
const newSpec = [];
for (let i = 0; i < spec.length; ++i) {
/** @type {{value: string, groupId: string, spec: MarkdownNode}[]} */
const tabs = [];
for (;i < spec.length; i++) {
const codeLang = spec[i].codeLang;
if (!codeLang)
break;
let parsed;
try {
parsed = parseCodeLang(codeLang);
} catch (e) {
throw new Error(e.message + '\n while processing:\n' + md.render([spec[i]]));
}
if (!parsed.codeGroup)
break;
if (parsed.language && parsed.language !== language)
continue;
const [groupId, value] = parsed.codeGroup.split('-');
const clone = md.clone(spec[i]);
clone.codeLang = parsed.highlighter;
tabs.push({ groupId, value, spec: clone });
}
if (tabs.length) {
if (tabs.length === 1)
throw new Error(`Lonely tab "${tabs[0].spec.codeLang}". Make sure there are at least two tabs in the group.\n` + md.render([tabs[0].spec]));
// Validate group consistency.
const groupId = tabs[0].groupId;
const values = new Set();
for (const tab of tabs) {
if (tab.groupId !== groupId)
throw new Error('Mixed group ids: ' + md.render(spec));
if (values.has(tab.value))
throw new Error(`Duplicated tab "${tab.value}"\n` + md.render(tabs.map(tab => tab.spec)));
values.add(tab.value);
}
// Append transformed nodes.
newSpec.push(...transformer(tabs));
}
if (i < spec.length)
newSpec.push(spec[i]);
}
return newSpec;
}
/**
* @param {string} codeLang
* @return {{ highlighter: string, language: string|undefined, codeGroup: string|undefined}}
*/
function parseCodeLang(codeLang) {
if (codeLang === 'python async')
return { highlighter: 'py', codeGroup: 'python-async', language: 'python' };
if (codeLang === 'python sync')
return { highlighter: 'py', codeGroup: 'python-sync', language: 'python' };
const [highlighter] = codeLang.split(' ');
if (!highlighter)
throw new Error(`Cannot parse code block lang: "${codeLang}"`);
const languageMatch = codeLang.match(/ lang=([\w\d]+)/);
let language = languageMatch ? languageMatch[1] : undefined;
if (!language) {
if (highlighter === 'ts')
language = 'js';
else if (highlighter === 'py')
language = 'python';
else if (['js', 'python', 'csharp', 'java'].includes(highlighter))
language = highlighter;
}
const tabMatch = codeLang.match(/ tab=([\w\d-]+)/);
return { highlighter, language, codeGroup: tabMatch ? tabMatch[1] : '' };
}
module.exports = { Documentation, Class, Member, Type, processCodeGroups, parseCodeLang };

View File

@@ -0,0 +1,169 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
const Documentation = require('./documentation');
const { visitAll, render } = require('../markdown');
/**
* @param {Documentation.MarkdownNode[]} nodes
* @param {number} maxColumns
*/
function renderXmlDoc(nodes, maxColumns = 80, prefix = '/// ') {
if (!nodes)
return [];
const renderResult = _innerRenderNodes(nodes, maxColumns);
const doc = [];
_wrapInNode('summary', renderResult.summary, doc);
_wrapInNode('remarks', renderResult.remarks, doc);
return doc.map(x => `${prefix}${x}`);
}
function _innerRenderNodes(nodes, maxColumns = 80, wrapParagraphs = true) {
const summary = [];
const remarks = [];
const handleListItem = (lastNode, node) => {
if (node && node.type === 'li' && (!lastNode || lastNode.type !== 'li'))
summary.push(`<list type="${node.liType}">`);
else if (lastNode && lastNode.type === 'li' && (!node || node.type !== 'li'))
summary.push('</list>');
};
let lastNode;
visitAll(nodes, node => {
// handle special cases first
if (_nodeShouldBeIgnored(node))
return;
if (node.text && node.text.startsWith('extends: ')) {
remarks.push('Inherits from ' + node.text.replace('extends: ', ''));
return;
}
handleListItem(lastNode, node);
if (node.type === 'text') {
if (wrapParagraphs)
_wrapInNode('para', _wrapAndEscape(node, maxColumns), summary);
else
summary.push(..._wrapAndEscape(node, maxColumns));
} else if (node.type === 'code' && node.codeLang === 'csharp') {
_wrapInNode('code', _wrapCode(node.lines), summary);
} else if (node.type === 'li') {
_wrapInNode('item><description', _wrapAndEscape(node, maxColumns), summary, '/description></item');
} else if (node.type === 'note') {
_wrapInNode('para', _wrapAndEscape({
type: 'text',
text: render(node.children ?? []).replaceAll('\n', '↵'),
}, maxColumns), remarks);
}
lastNode = node;
});
handleListItem(lastNode, null);
return { summary, remarks };
}
function _wrapCode(lines) {
let i = 0;
const out = [];
for (let line of lines) {
line = line.replace(/[&]/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
if (i < lines.length - 1)
line = line + '<br/>';
out.push(line);
i++;
}
return out;
}
function _wrapInNode(tag, nodes, target, closingTag = null) {
if (nodes.length === 0)
return;
if (!closingTag)
closingTag = `/${tag}`;
if (nodes.length === 1) {
target.push(`<${tag}>${nodes[0]}<${closingTag}>`);
return;
}
target.push(`<${tag}>`);
target.push(...nodes);
target.push(`<${closingTag}>`);
}
/**
*
* @param {Documentation.MarkdownNode} node
*/
function _wrapAndEscape(node, maxColumns = 0) {
const lines = [];
const pushLine = text => {
if (text === '')
return;
text = text.trim();
lines.push(text);
};
let text = (node.text || '').replace(/↵/g, ' ');
text = text.replace(/\[([^\]]*)\]\((.*?)\)/g, (match, linkName, linkUrl) => {
const isInternal = !linkUrl.startsWith('http://') && !linkUrl.startsWith('https://');
if (isInternal)
linkUrl = new URL(linkUrl.replace('.md', ''), 'https://playwright.dev/dotnet/docs/api/').toString();
return `<a href="${linkUrl}">${linkName}</a>`;
});
text = text.replace(/(?<!`)\[(.*?)\]/g, (match, link) => `<see cref="${link}"/>`);
text = text.replace(/`([^`]*)`/g, (match, code) => `<c>${code.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</c>`);
text = text.replace(/ITimeoutError/, 'TimeoutException');
text = text.replace(/Promise/, 'Task');
const words = text.split(' ');
let line = '';
for (let i = 0; i < words.length; i++) {
line = line + ' ' + words[i];
if (line.length >= maxColumns) {
pushLine(line);
line = '';
}
}
pushLine(line);
return lines;
}
/**
*
* @param {Documentation.MarkdownNode} node
*/
function _nodeShouldBeIgnored(node) {
if (!node
|| (node.text === 'extends: [EventEmitter]'))
return true;
return false;
}
/**
* @param {Documentation.MarkdownNode[]} nodes
*/
function renderTextOnly(nodes, maxColumns = 80) {
const result = _innerRenderNodes(nodes, maxColumns, false);
return result.summary;
}
module.exports = { renderXmlDoc, renderTextOnly };

View File

@@ -0,0 +1,135 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
const path = require('path');
const { parseApi } = require('./api_parser');
const PROJECT_DIR = path.join(__dirname, '..', '..');
{
const documentation = parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'api'));
documentation.setLinkRenderer(item => {
const { clazz, param, option } = item;
if (param)
return `\`${param.alias}\``;
if (option)
return `\`${option.alias}\``;
if (clazz)
return `\`${clazz.name}\``;
});
documentation.generateSourceCodeComments();
const result = serialize(documentation);
console.log(JSON.stringify(result));
}
/**
* @param {import('./documentation').Documentation} documentation
*/
function serialize(documentation) {
return documentation.classesArray.map(serializeClass);
}
/**
* @param {import('./documentation').Class} clazz
*/
function serializeClass(clazz) {
const result = { name: clazz.name, spec: clazz.spec };
if (clazz.extends)
result.extends = clazz.extends;
serializeLangs(clazz, result);
if (clazz.comment)
result.comment = clazz.comment;
if (clazz.since)
result.since = clazz.since;
result.members = clazz.membersArray.map(serializeMember);
return result;
}
/**
* @param {import('./documentation').Member} member
*/
function serializeMember(member) {
const result = /** @type {any} */ ({ ...member });
sanitize(result);
result.args = member.argsArray.map(serializeProperty);
if (member.type)
result.type = serializeType(member.type);
serializeLangs(member, result);
return result;
}
/**
* @param {import('./documentation').Member | import('./documentation').Class} from
* @param {any} to
*/
function serializeLangs(from, to) {
if (!from.langs)
return;
to.langs = { ...from.langs };
sanitize(to.langs);
if (from.langs.overrides) {
for (const key in from.langs.overrides)
to.langs.overrides[key] = serializeMember(from.langs.overrides[key]);
}
if (from.langs.types) {
for (const key in from.langs.types)
to.langs.types[key] = serializeType(from.langs.types[key]);
}
}
/**
* @param {import('./documentation').Member} arg
*/
function serializeProperty(arg) {
const result = { ...arg };
sanitize(result);
if (arg.type)
result.type = serializeType(arg.type);
serializeLangs(arg, result);
return result;
}
/**
* @param {object} result
*/
function sanitize(result) {
delete result.args;
delete result.argsArray;
delete result.clazz;
delete result.enclosingMethod;
delete result.parent;
}
/**
* @param {import('./documentation').Type} type
*/
function serializeType(type) {
/** @type {any} */
const result = { ...type };
sanitize(result);
if (type.properties)
result.properties = type.properties.map(serializeProperty);
if (type.union)
result.union = type.union.map(type => serializeType(type));
if (type.templates)
result.templates = type.templates.map(type => serializeType(type));
if (type.args)
result.args = type.args.map(type => serializeType(type));
if (type.returnType)
result.returnType = serializeType(type.returnType);
return result;
}

View File

@@ -0,0 +1,891 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
const path = require('path');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const Documentation = require('./documentation');
const XmlDoc = require('./dotnetXmlDocumentation');
const PROJECT_DIR = path.join(__dirname, '..', '..');
const fs = require('fs');
const { parseApi } = require('./api_parser');
const { Type } = require('./documentation');
const { EOL } = require('os');
const maxDocumentationColumnWidth = 80;
Error.stackTraceLimit = 100;
/** @type {Map<string, Documentation.Type>} */
const modelTypes = new Map(); // this will hold types that we discover, because of .NET specifics, like results
/** @type {Map<string, string>} */
const documentedResults = new Map(); // will hold documentation for new types
/** @type {Map<string, string[]>} */
const enumTypes = new Map();
/** @type {Map<string, Documentation.Type>} */
const optionTypes = new Map();
const customTypeNames = new Map([
['domcontentloaded', 'DOMContentLoaded'],
['networkidle', 'NetworkIdle'],
]);
const outputDir = process.argv[2] || path.join(__dirname, 'generate_types', 'csharp');
const apiDir = path.join(outputDir, 'API', 'Generated');
const optionsDir = path.join(outputDir, 'API', 'Generated', 'Options');
const enumsDir = path.join(outputDir, 'API', 'Generated', 'Enums');
const typesDir = path.join(outputDir, 'API', 'Generated', 'Types');
for (const dir of [apiDir, optionsDir, enumsDir, typesDir])
fs.mkdirSync(dir, { recursive: true });
const documentation = parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'api'));
documentation.filterForLanguage('csharp');
documentation.setLinkRenderer(item => {
const asyncSuffix = item.member && item.member.async ? 'Async' : '';
if (item.clazz)
return `<see cref="I${toTitleCase(item.clazz.name)}"/>`;
else if (item.member)
return `<see cref="I${toTitleCase(item.member.clazz.name)}.${toMemberName(item.member)}${asyncSuffix}"/>`;
else if (item.option)
return `<paramref name="${item.option.name}"/>`;
else if (item.param)
return `<paramref name="${item.param.name}"/>`;
else
throw new Error('Unknown link format.');
});
// get the template for a class
const template = fs.readFileSync(path.join(__dirname, 'templates', 'interface.cs'), 'utf-8');
// map the name to a C# friendly one (we prepend an I to denote an interface)
const classNameMap = new Map(documentation.classesArray.map(x => [x.name, `I${toTitleCase(x.name)}`]));
// map some types that we know of
classNameMap.set('Error', 'Exception');
classNameMap.set('TimeoutError', 'TimeoutException');
classNameMap.set('EvaluationArgument', 'object');
classNameMap.set('boolean', 'bool');
classNameMap.set('any', 'object');
classNameMap.set('Buffer', 'byte[]');
classNameMap.set('path', 'string');
classNameMap.set('Date', 'DateTime');
classNameMap.set('URL', 'string');
classNameMap.set('RegExp', 'Regex');
classNameMap.set('Readable', 'Stream');
classNameMap.set('Disposable', 'IAsyncDisposable');
classNameMap.set('Promise', 'Task');
/**
*
* @param {string} kind
* @param {string} name
* @param {Documentation.MarkdownNode[]|null} spec
* @param {string[]} body
* @param {string} folder
* @param {string|null} extendsName
*/
function writeFile(kind, name, spec, body, folder, extendsName = null) {
const out = [];
// console.log(`Generating ${name}`);
if (spec) {
out.push(...XmlDoc.renderXmlDoc(spec, maxDocumentationColumnWidth));
} else {
const ownDocumentation = documentedResults.get(name);
if (ownDocumentation) {
out.push('/// <summary>');
out.push(`/// ${ownDocumentation}`);
out.push('/// </summary>');
}
}
if (extendsName === 'IEventEmitter')
extendsName = null;
if (body[0] === '')
body = body.slice(1);
out.push(`${kind} ${name}${extendsName ? ` : ${extendsName}` : ''}`);
out.push('{');
out.push(...body);
out.push('}');
const content = template.replace('[CONTENT]', out.join(EOL));
fs.writeFileSync(path.join(folder, name + '.cs'), content);
}
/**
* @param {Documentation.Class} clazz
*/
function renderClass(clazz) {
const name = classNameMap.get(clazz.name);
if (name === 'TimeoutException')
return;
if (name === 'IAsyncDisposable')
return;
const body = [];
for (const member of clazz.membersArray) {
// Classes inherit it from IAsyncDisposable
if (member.name === 'dispose')
continue;
if (member.alias.startsWith('RunAnd'))
renderMember(member, clazz, { trimRunAndPrefix: true }, body);
renderMember(member, clazz, {}, body);
}
/** @type {Documentation.MarkdownNode[]} */
const spec = [];
if (clazz.deprecated)
spec.push({ type: 'text', text: '**DEPRECATED** ' + clazz.deprecated });
if (clazz.discouraged)
spec.push({ type: 'text', text: clazz.discouraged });
if (clazz.spec)
spec.push(...clazz.spec);
writeFile(
'public partial interface',
name,
spec,
body,
apiDir,
clazz.extends ? `I${toTitleCase(clazz.extends)}` : null);
}
/**
* @param {string} name
* @param {Documentation.Type} type
*/
function renderModelType(name, type) {
const body = [];
// TODO: consider how this could be merged with the `translateType` check
if (type.union
&& type.union[0].name === 'null'
&& type.union.length === 2)
type = type.union[1];
if (type.name === 'Array') {
throw new Error('Array at this stage is unexpected.');
} else if (type.properties) {
for (const member of type.properties) {
const fakeType = new Type(name, null);
renderMember(member, fakeType, {}, body);
}
} else {
console.log(type);
throw new Error(`Not sure what to do in this case.`);
}
writeFile('public partial class', name, null, body, typesDir);
}
/**
* @param {string} name
* @param {string[]} literals
*/
function renderEnum(name, literals) {
const body = [];
for (let literal of literals) {
// strip out the quotes
literal = literal.replace(/[\"]/g, ``);
const escapedName = literal.replace(/[-]/g, ' ')
.split(' ')
.map(word => customTypeNames.get(word) || word[0].toUpperCase() + word.substring(1)).join('');
body.push(`[EnumMember(Value = "${literal}")]`);
body.push(`${escapedName},`);
}
writeFile('public enum', name, null, body, enumsDir);
}
/**
* @param {string} name
* @param {Documentation.Type} type
*/
function renderOptionType(name, type) {
const body = [];
renderConstructors(name, type, body);
for (const member of type.properties)
renderMember(member, member.type, {}, body);
writeFile('public class', name, null, body, optionsDir);
}
for (const element of documentation.classesArray)
renderClass(element);
for (const [name, type] of optionTypes)
renderOptionType(name, type);
for (const [name, type] of modelTypes)
renderModelType(name, type);
for (const [name, literals] of enumTypes)
renderEnum(name, literals);
/**
* @param {string} name
*/
function toArgumentName(name) {
return name === 'event' ? `@${name}` : name;
}
/**
* @param {Documentation.Member} member
*/
function toMemberName(member, makeAsync = false) {
const assumedName = toTitleCase(member.alias || member.name);
if (member.kind === 'interface')
return `I${assumedName}`;
if (makeAsync && member.async)
return assumedName + 'Async';
if (!makeAsync && assumedName.endsWith('Async'))
return assumedName.substring(0, assumedName.length - 'Async'.length);
return assumedName;
}
/**
* @param {string} name
* @returns {string}
*/
function toTitleCase(name) {
return name.charAt(0).toUpperCase() + name.substring(1);
}
/**
*
* @param {string} name
* @param {Documentation.Type} type
* @param {string[]} out
*/
function renderConstructors(name, type, out) {
out.push(`public ${name}(){}`);
out.push('');
out.push(`public ${name}(${name} clone) {`);
out.push(`if(clone == null) return;`);
type.properties.forEach(p => {
const propType = translateType(p.type, type, t => generateNameDefault(p, name, t, type));
const propName = toMemberName(p);
const overloads = getPropertyOverloads(propType, p, propName, p.type);
for (const { name } of overloads)
out.push(`${name} = clone.${name};`);
});
out.push(`}`);
}
/**
* @param {Documentation.Member} member
* @param {string[]} out
*/
function renderMemberDoc(member, out) {
/** @type {Documentation.MarkdownNode[]} */
const nodes = [];
if (member.deprecated)
nodes.push({ type: 'text', text: '**DEPRECATED** ' + member.deprecated });
if (member.discouraged)
nodes.push({ type: 'text', text: member.discouraged });
if (member.spec)
nodes.push(...member.spec);
out.push(...XmlDoc.renderXmlDoc(nodes, maxDocumentationColumnWidth));
}
/**
* @param {Documentation.Member} member
* @param {Documentation.Class|Documentation.Type} parent
* @param {{nojson?: boolean, trimRunAndPrefix?: boolean}} options
* @param {string[]} out
*/
function renderMember(member, parent, options, out) {
const name = toMemberName(member);
if (member.kind === 'method') {
renderMethod(member, parent, name, { trimRunAndPrefix: options.trimRunAndPrefix }, out);
return;
}
let type = translateType(member.type, parent, t => generateNameDefault(member, name, t, parent));
if (member.kind === 'event') {
if (!member.type)
throw new Error(`No Event Type for ${name} in ${parent.name}`);
out.push('');
renderMemberDoc(member, out);
if (member.deprecated)
out.push(`[System.Obsolete]`);
if (type === 'void')
out.push(`event EventHandler ${name};`);
else
out.push(`event EventHandler<${type}> ${name};`);
return;
}
if (member.kind === 'property') {
if (parent && member && member.name === 'children') { // this is a special hack for Accessibility
console.warn(`children property found in ${parent.name}, assuming array.`);
type = `IEnumerable<${parent.name}>`;
}
const overloads = getPropertyOverloads(type, member, name, parent);
for (const overload of overloads) {
const { name, jsonName } = overload;
let { type } = overload;
out.push('');
renderMemberDoc(member, out);
if (!member.clazz)
out.push(`${member.required ? '[Required]\n' : ''}[JsonPropertyName("${jsonName}")]`);
if (member.deprecated)
out.push(`[System.Obsolete]`);
if (!type.endsWith('?') && !member.required)
type = `${type}?`;
const requiredSuffix = type.endsWith('?') ? '' : ' = default!;';
if (member.clazz)
out.push(`public ${type} ${name} { get; }`);
else
out.push(`public ${type} ${name} { get; set; }${requiredSuffix}`);
}
return;
}
throw new Error(`Problem rendering a member: ${type} - ${name} (${member.kind})`);
}
/**
*
* @param {string} type
* @param {Documentation.Member} member
* @param {string} name
* @param {Documentation.Class|Documentation.Type} parent
* @returns [{ type: string; name: string; jsonName: string; }]
*/
function getPropertyOverloads(type, member, name, parent) {
const overloads = [];
if (type) {
let jsonName = member.name;
if (member.type.expression === '[string]|[float]')
jsonName = `${member.name}String`;
overloads.push({ type, name, jsonName });
}
return overloads;
}
/**
*
* @param {Documentation.Member} member
* @param {string} name
* @param {Documentation.Type} t
* @param {*} parent
*/
function generateNameDefault(member, name, t, parent) {
const structName = t.langAliases?.csharp || t.langAliases?.default;
if (structName) {
registerModelType(structName, t);
return structName;
}
if (!t.properties
&& !t.templates
&& !t.union
&& t.expression === '[Object]')
return 'object';
// we'd get this call for enums, primarily
const enumName = generateEnumNameIfApplicable(t);
if (!enumName && member) {
if (member.kind === 'method' || member.kind === 'property') {
const names = [
parent.alias || parent.name,
toTitleCase(member.alias || member.name),
toTitleCase(name),
];
if (names[2] === names[1])
names.pop(); // get rid of duplicates, cheaply
let attemptedName = names.pop();
const typesDiffer = function(/** @type {Documentation.Type} */ left, /** @type {Documentation.Type} */ right) {
if (left.expression && right.expression)
return left.expression !== right.expression;
const toExpression = (/** @type {Documentation.Member} */ t) => t.name + t.type?.expression;
const leftOverRightProperties = new Set(left.properties?.map(toExpression) ?? []);
for (const prop of right.properties ?? []) {
const expression = toExpression(prop);
if (!leftOverRightProperties.has(expression))
return true;
leftOverRightProperties.delete(expression);
}
return leftOverRightProperties.size > 0;
};
while (true) {
// crude attempt at removing plurality
if (attemptedName.endsWith('s')
&& !['properties', 'httpcredentials'].includes(attemptedName.toLowerCase()))
attemptedName = attemptedName.substring(0, attemptedName.length - 1);
const probableType = modelTypes.get(attemptedName);
if ((probableType && typesDiffer(t, probableType))
|| (['Value'].includes(attemptedName))) {
if (!names.length)
throw new Error(`Ran out of possible names: ${attemptedName}`);
attemptedName = `${names.pop()}${attemptedName}`;
continue;
} else {
registerModelType(attemptedName, t);
}
break;
}
return attemptedName;
}
if (member.kind === 'event')
return `${name}Payload`;
}
return enumName || t.name;
}
/**
*
* @param {Documentation.Type} type
* @returns
*/
function generateEnumNameIfApplicable(type) {
if (!type.union)
return null;
const potentialValues = type.union.filter(u => u.name.startsWith('"'));
if ((potentialValues.length !== type.union.length)
&& !(type.union[0].name === 'null' && potentialValues.length === type.union.length - 1))
return null; // this isn't an enum, so we don't care, we let the caller generate the name
return type.name;
}
/**
* Rendering a method is so _special_, with so many weird edge cases, that it
* makes sense to put it separate from the other logic.
* @param {Documentation.Member} member
* @param {Documentation.Class | Documentation.Type} parent
* @param {string} name
* @param {{
* nodocs?: boolean,
* abstract?: boolean,
* public?: boolean,
* trimRunAndPrefix?: boolean,
* }} options
* @param {string[]} out
*/
function renderMethod(member, parent, name, options, out) {
out.push('');
if (options.trimRunAndPrefix)
name = name.substring('RunAnd'.length);
/** @type {Map<string, string[]>} */
const paramDocs = new Map();
const addParamsDoc = (paramName, docs) => {
if (paramName.startsWith('@'))
paramName = paramName.substring(1);
if (paramDocs.get(paramName) && paramDocs.get(paramName) !== docs)
throw new Error(`Parameter ${paramName} already exists in the docs.`);
paramDocs.set(paramName, docs);
};
let type = translateType(member.type, parent, t => generateNameDefault(member, name, t, parent), false, true);
// TODO: this is something that will probably go into the docs
// translate simple getters into read-only properties, and simple
// set-only methods to settable properties
if (member.args.size === 0
&& type !== 'void'
&& !name.startsWith('Get')
&& name !== 'CreateFormData'
&& !name.startsWith('PostDataJSON')
&& !name.startsWith('As')
&& name !== 'ConnectToServer') {
if (!member.async) {
if (member.spec && !options.nodocs)
out.push(...XmlDoc.renderXmlDoc(member.spec, maxDocumentationColumnWidth));
if (member.deprecated)
out.push(`[System.Obsolete]`);
out.push(`${type} ${name} { get; }`);
return;
}
}
// HACK: special case for generics handling!
if (type === 'T')
name = `${name}<T>`;
// adjust the return type for async methods
if (member.async) {
if (type === 'void')
type = `Task`;
else
type = `Task<${type}>`;
}
// render args
/** @type {string[]} */
const args = [];
/** @type {string[]} */
const explodedArgs = [];
/** @type {Map<string, string>} */
const argTypeMap = new Map([]);
/**
*
* @param {string} innerArgType
* @param {string} innerArgName
* @param {Documentation.Member} argument
* @param {boolean} isExploded
*/
function pushArg(innerArgType, innerArgName, argument, isExploded = false) {
if (innerArgType === 'null')
return;
const requiredPrefix = (argument.required || isExploded) ? '' : '?';
const requiredSuffix = (argument.required || isExploded) ? '' : ' = default';
const push = `${innerArgType}${requiredPrefix} ${innerArgName}${requiredSuffix}`;
if (isExploded)
explodedArgs.push(push);
else
args.push(push);
argTypeMap.set(push, innerArgName);
}
/**
* @param {Documentation.Member} arg
*/
function processArg(arg) {
if (options.trimRunAndPrefix && arg.name === 'action')
return;
if (arg.name === 'options') {
const optionsType = rewriteSuggestedOptionsName(member.clazz.name + name.replace('<T>', '') + 'Options');
if (!optionTypes.has(optionsType) || arg.type.properties.length > optionTypes.get(optionsType).properties.length)
optionTypes.set(optionsType, arg.type);
args.push(`${optionsType}? options = default`);
argTypeMap.set(`${optionsType}? options = default`, 'options');
addParamsDoc('options', ['Call options']);
return;
}
if (arg.type.expression === '[string]|[path]') {
const argName = toArgumentName(arg.name);
pushArg('string?', `${argName} = default`, arg);
pushArg('string?', `${argName}Path = default`, arg);
if (arg.spec) {
addParamsDoc(argName, XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth));
addParamsDoc(`${argName}Path`, [`Instead of specifying <paramref name="${argName}"/>, gives the file name to load from.`]);
}
return;
} else if (arg.type.expression === '[boolean]|[Array]<[string]>') {
// HACK: this hurts my brain too
// we split this into two args, one boolean, with the logical name
const argName = toArgumentName(arg.name);
const leftArgType = translateType(arg.type.union[0], parent, t => { throw new Error('Not supported'); });
const rightArgType = translateType(arg.type.union[1], parent, t => { throw new Error('Not supported'); });
pushArg(leftArgType, argName, arg);
pushArg(rightArgType, `${argName}Values`, arg);
addParamsDoc(argName, XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth));
addParamsDoc(`${argName}Values`, [`The values to take into account when <paramref name="${argName}"/> is <code>true</code>.`]);
return;
}
const argName = toArgumentName(arg.alias || arg.name);
const argType = translateType(arg.type, parent, t => generateNameDefault(member, argName, t, parent));
if (argType === null && arg.type.union) {
// we might have to split this into multiple arguments
const translatedArguments = arg.type.union.map(t => translateType(t, parent, x => generateNameDefault(member, argName, x, parent)));
if (translatedArguments.includes(null))
throw new Error('Unexpected null in translated argument types. Aborting.');
const argDocumentation = XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth);
for (const newArg of translatedArguments) {
pushArg(newArg, argName, arg, true); // push the exploded arg
addParamsDoc(argName, argDocumentation);
}
args.push(arg.required ? 'EXPLODED_ARG' : 'OPTIONAL_EXPLODED_ARG');
return;
}
addParamsDoc(argName, XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth));
if (argName === 'timeout' && argType === 'decimal') {
args.push(`int timeout = 0`); // a special argument, we ignore our convention
return;
}
pushArg(argType, argName, arg);
}
let modifiers = '';
if (options.abstract)
modifiers = 'protected abstract ';
if (options.public)
modifiers = 'public ';
member.argsArray
.sort((a, b) => b.alias === 'options' ? -1 : 0) // move options to the back to the arguments list
.forEach(processArg);
if (!explodedArgs.length) {
if (!options.nodocs) {
renderMemberDoc(member, out);
paramDocs.forEach((value, i) => printArgDoc(i, value, out));
}
if (member.deprecated)
out.push(`[System.Obsolete]`);
out.push(`${modifiers}${type} ${toAsync(name, member.async)}(${args.join(', ')});`);
} else {
let containsOptionalExplodedArgs = false;
explodedArgs.forEach((explodedArg, argIndex) => {
if (!options.nodocs)
renderMemberDoc(member, out);
const overloadedArgs = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === 'EXPLODED_ARG' || arg === 'OPTIONAL_EXPLODED_ARG') {
containsOptionalExplodedArgs = arg === 'OPTIONAL_EXPLODED_ARG';
const argType = argTypeMap.get(explodedArg);
if (!options.nodocs)
printArgDoc(argType, paramDocs.get(argType), out);
overloadedArgs.push(explodedArg);
} else {
const argType = argTypeMap.get(arg);
if (!options.nodocs)
printArgDoc(argType, paramDocs.get(argType), out);
overloadedArgs.push(arg);
}
}
out.push(`${modifiers}${type} ${toAsync(name, member.async)}(${overloadedArgs.join(', ')});`);
if (argIndex < explodedArgs.length - 1)
out.push(''); // output a special blank line
});
// If the exploded union arguments are optional, we also output a special
// signature, to help prevent compilation errors with ambiguous overloads.
// That particular overload only contains the required arguments, or rather
// contains all the arguments *except* the exploded ones.
if (containsOptionalExplodedArgs) {
const filteredArgs = args.filter(x => x !== 'OPTIONAL_EXPLODED_ARG');
if (!options.nodocs)
renderMemberDoc(member, out);
filteredArgs.forEach(arg => {
if (arg === 'EXPLODED_ARG')
throw new Error(`Unsupported required union arg combined an optional union inside ${member.name}`);
const argType = argTypeMap.get(arg);
if (!options.nodocs)
printArgDoc(argType, paramDocs.get(argType), out);
});
out.push(`${type} ${name}(${filteredArgs.join(', ')});`);
}
}
}
/**
*
* @param {Documentation.Type} type
* @param {Documentation.Class|Documentation.Type} parent
* @param {function(Documentation.Type): string} generateNameCallback
* @param {boolean=} optional
* @returns {string}
*/
function translateType(type, parent, generateNameCallback = t => t.name, optional = false, isReturnType = false) {
// a few special cases we can fix automatically
if (type.expression === '[null]|[Error]')
return 'void';
if (type.name === 'Promise' && type.templates?.[0].name === 'any')
return 'Task';
if (type.union) {
if (type.union[0].name === 'null' && type.union.length === 2)
return translateType(type.union[1], parent, generateNameCallback, true, isReturnType);
if (type.expression === '[string]|[Buffer]')
return `byte[]`; // TODO: make sure we implement extension methods for this!
if (type.expression === '[string]|[float]' || type.expression === '[string]|[float]|[boolean]') {
console.warn(`${type.name} should be a 'string', but was a ${type.expression}`);
return `string`;
}
if (type.expression === '[float]|"raf"')
return `Polling`; // hardcoded because there's no other way to denote this
// Regular primitive enums are named in the markdown.
if (type.name) {
enumTypes.set(type.name, type.union.map(t => t.name));
return optional ? type.name + '?' : type.name;
}
return null;
}
if (type.name === 'Array') {
if (type.templates.length !== 1)
throw new Error(`Array (${type.name} from ${parent.name}) has more than 1 dimension. Panic.`);
const innerType = translateType(type.templates[0], parent, generateNameCallback, false, isReturnType);
return isReturnType ? `IReadOnlyList<${innerType}>` : `IEnumerable<${innerType}>`;
}
if (type.name === 'Object') {
// take care of some common cases
// TODO: this can be genericized
if (type.templates && type.templates.length === 2) {
// get the inner types of both templates, and if they're strings, it's a keyvaluepair string, string,
const keyType = translateType(type.templates[0], parent, generateNameCallback, false, isReturnType);
const valueType = translateType(type.templates[1], parent, generateNameCallback, false, isReturnType);
if (['Request', 'Response', 'APIResponse'].includes(parent.name))
return `Dictionary<${keyType}, ${valueType}>`;
return `IEnumerable<KeyValuePair<${keyType}, ${valueType}>>`;
}
if ((type.name === 'Object')
&& !type.properties
&& !type.union)
return 'object';
// this is an additional type that we need to generate
const objectName = generateNameCallback(type);
if (objectName === 'Object')
throw new Error('Object unexpected');
else if (type.name === 'Object')
registerModelType(objectName, type);
return `${objectName}${optional ? '?' : ''}`;
}
if (type.name === 'Map') {
if (type.templates && type.templates.length === 2) {
// we map to a dictionary
const keyType = translateType(type.templates[0], parent, generateNameCallback, false, isReturnType);
const valueType = translateType(type.templates[1], parent, generateNameCallback, false, isReturnType);
return `Dictionary<${keyType}, ${valueType}>`;
} else {
throw 'Map has invalid number of templates.';
}
}
if (type.name === 'function') {
if (type.expression === '[function]' || !type.args)
return 'Action'; // super simple mapping
let argsList = '';
if (type.args) {
const translatedCallbackArguments = type.args.map(t => translateType(t, parent, generateNameCallback, false, isReturnType));
if (translatedCallbackArguments.includes(null))
throw new Error('There was an argument we could not parse. Aborting.');
argsList = translatedCallbackArguments.join(', ');
}
if (!type.returnType) {
// this is an Action
return `Action<${argsList}>`;
} else {
const returnType = translateType(type.returnType, parent, generateNameCallback, false, isReturnType);
if (returnType === null)
throw new Error('Unexpected null as return type.');
if (!argsList)
return `Func<${returnType}>`;
return `Func<${argsList}, ${returnType}>`;
}
}
if (type.templates) {
// this should mean we have a generic type and we can translate that
/** @type {string[]} */
const types = type.templates.map(template => translateType(template, parent));
return `${type.name}<${types.join(', ')}>`;
}
if (type.name === 'Serializable')
return isReturnType ? 'T' : 'object';
// there's a chance this is a name we've already seen before, so check
// this is also where we map known types, like boolean -> bool, etc.
const name = classNameMap.get(type.name) || type.name;
return `${name}${optional ? '?' : ''}`;
}
/**
* @param {string} typeName
* @param {Documentation.Type} type
*/
function registerModelType(typeName, type) {
if (['object', 'string', 'int', 'long'].includes(typeName))
return;
if (typeName.endsWith('Option'))
return;
const potentialType = modelTypes.get(typeName);
if (potentialType) {
// console.log(`Type ${typeName} already exists, so skipping...`);
return;
}
modelTypes.set(typeName, type);
}
/**
* @param {string} name
* @param {string[]} value
* @param {string[]} out
*/
function printArgDoc(name, value, out) {
if (value.length === 1) {
out.push(`/// <param name="${name}">${value}</param>`);
} else {
out.push(`/// <param name="${name}">`);
out.push(...value.map(l => `/// ${l}`));
out.push(`/// </param>`);
}
}
/**
* @param {string} name
* @param {boolean} convert
*/
function toAsync(name, convert) {
if (!convert)
return name;
if (name.includes('<'))
return name.replace('<', 'Async<');
return name + 'Async';
}
/**
* @param {string} suggestedName
* @returns {string}
*/
function rewriteSuggestedOptionsName(suggestedName) {
if ([
'APIRequestContextDeleteOptions',
'APIRequestContextFetchOptions',
'APIRequestContextGetOptions',
'APIRequestContextHeadOptions',
'APIRequestContextPatchOptions',
'APIRequestContextPostOptions',
'APIRequestContextPutOptions',
].includes(suggestedName))
return 'APIRequestContextOptions';
return suggestedName;
}

View File

@@ -0,0 +1,170 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @typedef {'Types'|'ReleaseNotesMd'} OutputType */
// @ts-check
const toKebabCase = require('lodash/kebabCase.js')
const Documentation = require('./documentation');
/**
* @param {string} languagePath
* @param {Documentation.Member} member
* @param {string} text
* @param {string=} paramOrOption
* @returns {string}
*/
function createMarkdownLink(languagePath, member, text, paramOrOption) {
if (!member.clazz)
throw new Error('Member without a class!');
const className = toKebabCase(member.clazz.name);
const memberName = toKebabCase(member.name);
let hash = null;
if (member.kind === 'property' || member.kind === 'method')
hash = `${className}-${memberName}`.toLowerCase();
else if (member.kind === 'event')
hash = `${className}-event-${memberName}`.toLowerCase();
if (paramOrOption)
hash += '-option-' + toKebabCase(paramOrOption).toLowerCase();
return `[${text}](https://playwright.dev${languagePath}/docs/api/class-${member.clazz.name.toLowerCase()}#${hash})`;
};
/**
* @param {string} languagePath
* @param {Documentation.Class} clazz
* @returns {string}
*/
function createClassMarkdownLink(languagePath, clazz) {
return `[${clazz.name}](https://playwright.dev${languagePath}/docs/api/class-${clazz.name.toLowerCase()})`;
};
/**
* @param {string} language
* @param {OutputType} outputType
* @returns {Documentation.Renderer}
*/
function docsLinkRendererForLanguage(language, outputType) {
const languagePath = languageToRelativeDocsPath(language);
return ({ clazz, member, param, option }) => {
if (clazz)
return createClassMarkdownLink(languagePath, clazz);
if (!member || !member.clazz)
throw new Error('Internal error');
if (param)
return createMarkdownLink(languagePath, member, `\`${param.alias}\``, param.name);
if (option)
return createMarkdownLink(languagePath, member, `\`${option.alias}\``, option.name);
const className = member.clazz.varName === 'playwrightAssertions' ? '' : member.clazz.varName + '.';
if (member.kind === 'method') {
const args = outputType === 'ReleaseNotesMd' ? '' : renderJSSignature(member.argsArray);
return createMarkdownLink(languagePath, member, `${formatClassName(className, language)}${member.alias}(${args})`);
}
if (member.kind === 'event')
return createMarkdownLink(languagePath, member, `${className}on('${member.alias.toLowerCase()}')`);
if (member.kind === 'property')
return createMarkdownLink(languagePath, member, `${className}${member.alias}`);
throw new Error('Unknown member kind ' + member.kind);
}
}
function languageToRelativeDocsPath(language) {
if (language === 'js')
return '';
if (language === 'csharp')
return '/dotnet';
if (language === 'python')
return '/python';
if (language === 'java')
return '/java';
throw new Error('Unexpected language ' + language);
}
function formatClassName(className, language) {
if (!className.endsWith('Assertions.'))
return className;
className = className.substring(0, className.length - 1)
if (language === 'js')
return `expect(${assertionArgument(className)}).`;
else if (language === 'csharp')
return `Expect(${assertionArgument(className)}).`;
else if (language === 'python')
return `expect(${assertionArgument(className)}).`;
else if (language === 'java')
return `assertThat(${assertionArgument(className)}).`;
throw new Error('Unexpected language ' + language);
}
function assertionArgument(className) {
switch (className.toLowerCase()) {
case 'locatorassertions': return 'locator';
case 'pageassertions': return 'page';
case 'genericassertions': return 'value';
case 'snapshotassertions': return 'value';
case 'apiresponseassertions': return 'response';
}
throw new Error(`Unexpected assertion class: ${className}`);
}
/**
* @param {Documentation.Member[]} args
*/
function renderJSSignature(args) {
const tokens = [];
let hasOptional = false;
for (const arg of args) {
const name = arg.alias;
const optional = !arg.required;
if (tokens.length) {
if (optional && !hasOptional)
tokens.push(`[, ${name}`);
else
tokens.push(`, ${name}`);
} else {
if (optional && !hasOptional)
tokens.push(`[${name}`);
else
tokens.push(`${name}`);
}
hasOptional = hasOptional || optional;
}
if (hasOptional)
tokens.push(']');
return tokens.join('');
}
/**
* @param {string} content
* @param {string} languagePath
* @param {string} relativePath
* @param {boolean} releaseNotesMode
* @returns {string}
*/
function renderPlaywrightDevLinks(content, languagePath, relativePath, releaseNotesMode = false) {
return content.replace(/\[([^\]]+)\]\((\.[^\)]+)\)/g, (match, p1, p2) => {
if (releaseNotesMode && p2.includes('/images/')) {
const url = new URL(p2, `https://github.com/microsoft/playwright/blob/main/docs/src/`);
url.searchParams.set('raw', 'true');
return `[${p1}](${url.toString()})`;
}
return `[${p1}](${new URL(p2.replace('.md', ''), `https://playwright.dev${languagePath}/docs${relativePath}/`).toString()})`;
});
}
module.exports = {
docsLinkRendererForLanguage,
renderPlaywrightDevLinks,
languageToRelativeDocsPath,
}

View File

@@ -0,0 +1,351 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
const debug = require('debug')
const fs = require('fs');
const path = require('path');
const { parseApi } = require('../api_parser');
const md = require('../../markdown');
const { ESLint } = require('eslint')
const child_process = require('child_process');
const os = require('os');
const { codeFrameColumns } = require('@babel/code-frame');
/** @typedef {import('../documentation').Type} Type */
/** @typedef {import('../../markdown').MarkdownNode} MarkdownNode */
const PROJECT_DIR = path.join(__dirname, '..', '..', '..');
function getAllMarkdownFiles(dirPath, filePaths = []) {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
filePaths.push(path.join(dirPath, entry.name));
else if (entry.isDirectory())
getAllMarkdownFiles(path.join(dirPath, entry.name), filePaths);
}
return filePaths;
}
const run = async () => {
const jsOnly = process.argv.includes('--js-only');
const lintingServiceFactory = new LintingServiceFactory(jsOnly);
const documentationRoot = path.join(PROJECT_DIR, 'docs', 'src');
let documentation = parseApi(path.join(documentationRoot, 'api'));
/** @type {CodeSnippet[]} */
const codeSnippets = [];
for (const filePath of getAllMarkdownFiles(documentationRoot)) {
const data = fs.readFileSync(filePath, 'utf-8');
let rootNode = md.parse(data);
// Renders links.
documentation.renderLinksInNodes(rootNode);
documentation.generateSourceCodeComments();
md.visitAll(rootNode, node => {
if (node.type !== 'code')
return;
const codeLang = node.codeLang.split(' ')[0];
const code = node.lines.join('\n');
codeSnippets.push({
filePath,
codeLang,
code,
})
});
}
await lintingServiceFactory.lintAndReport(codeSnippets);
if (jsOnly)
return;
const { hasErrors } = lintingServiceFactory.reportMetrics();
if (hasErrors)
process.exit(1);
}
/** @typedef {{ codeLang: string, code: string, filePath: string }} CodeSnippet */
/** @typedef {{ status: 'ok' | 'updated' | 'error' | 'unsupported', error?: string }} LintResult */
class LintingService {
/**
* @param {string} codeLang
* @returns {boolean}
*/
supports(codeLang) {
throw new Error('supports() is not implemented');
}
async _writeTempSnippetsFile(snippets) {
const tempFile = path.join(os.tmpdir(), `snippet-${Date.now()}.json`);
await fs.promises.writeFile(tempFile, JSON.stringify(snippets, undefined, 2));
return tempFile;
}
/**
* @param {string} command
* @param {string[]} args
* @param {CodeSnippet[]} snippets
* @param {string} cwd
* @returns {Promise<LintResult[]>}
*/
async spawnAsync(command, args, snippets, cwd) {
const tempFile = await this._writeTempSnippetsFile(snippets);
return await new Promise((fulfill, reject) => {
const child = child_process.spawn(command, [...args, tempFile], { cwd });
let stdout = '';
child.on('error', reject);
child.stdout.on('data', data => stdout += data.toString());
child.stderr.pipe(process.stderr);
child.on('exit', code => {
if (code)
reject(new Error(`${command} exited with code ${code}`));
else
fulfill(JSON.parse(stdout));
});
});
}
/**
* @param {CodeSnippet[]} snippets
* @returns {Promise<LintResult[]>}
*/
async lint(snippets) {
throw new Error('lint() is not implemented');
}
}
class JSLintingService extends LintingService {
_knownBadSnippets = [
'mount(',
'render(',
'vue-router',
'experimental-ct',
];
async _init() {
if (this._eslint)
return this._eslint;
const { fixupConfigRules } = await import('@eslint/compat');
const { FlatCompat } = await import('@eslint/eslintrc');
// @ts-ignore
const js = (await import('@eslint/js')).default;
const compat = new FlatCompat({
baseDirectory: __dirname,
// @ts-ignore
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
const baseConfig = fixupConfigRules(compat.extends('plugin:react/recommended', 'plugin:@typescript-eslint/disable-type-checked'));
const { baseRules }= await import('../../../eslint.config.mjs');
this._eslint = new ESLint({
baseConfig,
plugins: /** @type {any}*/({
'@stylistic': (await import('@stylistic/eslint-plugin')).default,
}),
ignore: false,
overrideConfig: {
files: ['**/*.ts', '**/*.tsx'],
settings: {
react: { version: 'detect' },
},
languageOptions: {
// @ts-ignore
parser: await import('@typescript-eslint/parser'),
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: /** @type {any}*/({
...baseRules,
'notice/notice': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'max-len': ['error', { code: 100 }],
'react/react-in-jsx-scope': 'off',
'eol-last': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
}),
}
});
return this._eslint;
}
supports(codeLang) {
return codeLang === 'js' || codeLang === 'ts';
}
/**
* @param {CodeSnippet} snippet
* @returns {Promise<LintResult>}
*/
async _lintSnippet(snippet) {
const eslint = await this._init();
if (this._knownBadSnippets.some(s => snippet.code.includes(s)))
return { status: 'ok' };
const results = await eslint.lintText(snippet.code, { filePath: path.join(__dirname, 'file.tsx') });
if (!results || !results.length || !results[0].messages.length)
return { status: 'ok' };
const result = results[0];
const error = result.source ? results[0].messages[0].message + '\n\n' + codeFrameColumns(result.source, { start: result.messages[0] }, { highlightCode: true }) : results[0].messages[0].message;
return { status: 'error', error };
}
/**
* @param {CodeSnippet[]} snippets
* @returns {Promise<LintResult[]>}
*/
async lint(snippets) {
const result = [];
for (let i = 0; i < snippets.length; ++i)
result.push(await this._lintSnippet(snippets[i]));
return result;
}
}
class PythonLintingService extends LintingService {
supports(codeLang) {
return codeLang === 'python' || codeLang === 'py';
}
async lint(snippets) {
const result = await this.spawnAsync('python', [path.join(__dirname, 'python', 'main.py')], snippets, path.join(__dirname, 'python'))
return result;
}
}
class CSharpLintingService extends LintingService {
supports(codeLang) {
return codeLang === 'csharp';
}
async lint(snippets) {
return await this.spawnAsync('dotnet', ['run', '--project', path.join(__dirname, 'csharp')], snippets, path.join(__dirname, 'csharp'))
}
}
class JavaLintingService extends LintingService {
supports(codeLang) {
return codeLang === 'java';
}
async lint(snippets) {
return await this.spawnAsync('java', ['-jar', path.join(__dirname, 'java', 'target', 'java-syntax-checker-1.0-SNAPSHOT.jar')], snippets, path.join(__dirname, 'java'))
}
}
class LintingServiceFactory {
constructor(jsOnly) {
/** @type {LintingService[]} */
this.services = [
new JSLintingService(),
]
if (!jsOnly) {
this.services.push(
new PythonLintingService(),
new CSharpLintingService(),
new JavaLintingService(),
);
}
this._metrics = {};
this._log = debug('linting-service');
}
/**
* @param {CodeSnippet[]} allSnippets
*/
async lintAndReport(allSnippets) {
/** @type {Record<string, CodeSnippet[]>} */
const groupedByLanguage = allSnippets.reduce((acc, snippet) => {
if (!acc[snippet.codeLang])
acc[snippet.codeLang] = [];
acc[snippet.codeLang].push(snippet);
return acc;
}, {});
for (const language in groupedByLanguage) {
const service = this.services.find(service => service.supports(language));
if (!service) {
this._collectMetrics(language, {
status: 'unsupported',
});
continue;
}
const languageSnippets = groupedByLanguage[language];
const results = await service.lint(languageSnippets);
if (results.length !== languageSnippets.length)
throw new Error('Linting service returned wrong number of results');
for (const [{ code, codeLang, filePath }, result] of /** @type {[[CodeSnippet, LintResult]]} */ (results.map((result, index) => [languageSnippets[index], result]))) {
const { status, error } = result;
this._collectMetrics(codeLang, result);
if (status === 'error') {
console.log(`${codeLang} linting error!`);
console.log(`ERROR: ${error}`);
console.log(`File: ${filePath}`);
console.log(code);
console.log('-'.repeat(80));
if (process.env.GITHUB_ACTION) {
const actions = await import('@actions/core');
actions.warning(`Error: ${error}\nUnable to lint:\n${code}`, {
title: `${codeLang} linting error`,
file: filePath,
});
}
}
}
}
}
/**
* @returns {{ hasErrors: boolean }}
*/
reportMetrics() {
console.log('Metrics:');
const renderMetric = (metric, name) => {
if (!metric[name])
return '';
return `${name}: ${metric[name]}`;
}
let hasErrors = false;
const languagesOrderedByOk = Object.entries(this._metrics).sort(([langA], [langB]) => {
return this._metrics[langB].ok - this._metrics[langA].ok
})
for (const [language, metrics] of languagesOrderedByOk) {
if (metrics.error)
hasErrors = true;
console.log(` ${language}: ${['ok', 'updated', 'error', 'unsupported'].map(name => renderMetric(metrics, name)).filter(Boolean).join(', ')}`)
}
return { hasErrors }
}
/**
* @param {string} language
* @param {LintResult} result
*/
_collectMetrics(language, result) {
if (!this._metrics[language])
this._metrics[language] = { ok: 0, updated: 0, error: 0, unsupported: 0 };
this._metrics[language][result.status]++;
}
}
run().catch(e => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,2 @@
obj/
bin/

View File

@@ -0,0 +1,43 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
var codeSnippetsPath = args[args.Length - 1];
var codeSnippets = JsonSerializer.Deserialize<List<CodeSnippet>>(File.ReadAllText(codeSnippetsPath));
if (codeSnippets == null)
{
Console.WriteLine("Error: codeSnippets is null");
return;
}
var output = new List<object>();
foreach (var codeSnippet in codeSnippets)
{
var tree = CSharpSyntaxTree.ParseText(codeSnippet.code);
var syntaxErrors = tree.GetDiagnostics()
.Where(diag => diag.Severity == DiagnosticSeverity.Error)
.ToList();
if (syntaxErrors.Any())
{
output.Add(new
{
status = "error",
error = string.Join("\n", syntaxErrors.Select(diag => diag.GetMessage()))
});
}
else
{
output.Add(new
{
status = "ok"
});
}
}
Console.WriteLine(JsonSerializer.Serialize(output));
record CodeSnippet(string filePath, string codeLang, string code);

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>csharp</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.10.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
target/
dependency-reduced-pom.xml

View File

@@ -0,0 +1,53 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>java-syntax-checker</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>15</maven.compiler.source>
<maven.compiler.target>15</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-core</artifactId>
<version>3.26.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>JavaSyntaxChecker</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,110 @@
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.github.javaparser.JavaParser;
import com.github.javaparser.Problem;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.StaticJavaParser;
import java.io.FileReader;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class JavaSyntaxChecker {
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("Error: Please provide the path to the JSON file");
return;
}
String codeSnippetsPath = args[args.length - 1];
List<CodeSnippet> codeSnippets = readCodeSnippets(codeSnippetsPath);
if (codeSnippets == null) {
System.out.println("Error: codeSnippets is null");
return;
}
List<Map<String, Object>> output = new ArrayList<>();
ParserConfiguration config = new ParserConfiguration();
config.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_17);
for (CodeSnippet codeSnippet : codeSnippets) {
String cleanedCode = cleanSnippet(codeSnippet.code);
ParseResult<CompilationUnit> parseResult = new JavaParser(config).parse(cleanedCode);
List<Problem> syntaxErrors = parseResult.getProblems();
if (!syntaxErrors.isEmpty()) {
output.add(Map.of(
"status", "error",
"error", String.join("\n", syntaxErrors.stream()
.map(Problem::getMessage)
.collect(Collectors.toList()))
));
} else {
output.add(Map.of("status", "ok"));
}
}
System.out.println(new Gson().toJson(output));
}
private static String removeImports(String code) {
// Remove import statements
return Pattern.compile("^import.*;$", Pattern.MULTILINE)
.matcher(code)
.replaceAll("");
}
private static String cleanSnippet(String code) {
// if it contains "public class" then it's a full class, return immediately
if (code.contains("public class")) {
return code;
}
code = removeImports(code);
String wrappedCode = """
import com.microsoft.playwright.*;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.*;
public class Example {
public static void main(String[] args) {
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().launch();
BrowserContext context = browser.newContext();
Page page = context.newPage();
%s
}
}
}
""".formatted(code);
return wrappedCode;
}
private static List<CodeSnippet> readCodeSnippets(String filePath) {
try (FileReader reader = new FileReader(filePath)) {
Type listType = new TypeToken<ArrayList<CodeSnippet>>(){}.getType();
return new Gson().fromJson(reader, listType);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
}
class CodeSnippet {
String filePath;
String codeLang;
String code;
public CodeSnippet(String filePath, String codeLang, String code) {
this.filePath = filePath;
this.codeLang = codeLang;
this.code = code;
}
}

View File

@@ -0,0 +1,34 @@
import json
import sys
import black
def check_code_snippet(code_snippet: str):
try:
formatted_code = black.format_str(code_snippet, mode=black.FileMode())
except Exception as e:
return {
'status': 'error',
'error': str(e),
}
if formatted_code.strip() == code_snippet.strip():
return {
'status': 'success',
}
return {
'status': 'updated',
'newCode': formatted_code,
}
def main():
code_snippets_path = sys.argv[1]
if not code_snippets_path:
print("No code snippets path provided")
return
code_snippets = json.load(open(code_snippets_path))
formatted_codes = [check_code_snippet(snippet["code"]) for snippet in code_snippets]
print(json.dumps(formatted_codes))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
black==26.3.1

View File

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