support web extensions

This commit is contained in:
Sandeep Somavarapu 2020-08-24 11:39:44 +02:00
parent 9633988aa0
commit 3b10000ad4
6 changed files with 211 additions and 8 deletions

View file

@ -76,7 +76,8 @@ module.exports = function (argv: string[]): void {
.option('--yarn', 'Use yarn instead of npm') .option('--yarn', 'Use yarn instead of npm')
.option('--ignoreFile [path]', 'Indicate alternative .vscodeignore') .option('--ignoreFile [path]', 'Indicate alternative .vscodeignore')
.option('--noGitHubIssueLinking', 'Prevent automatic expansion of GitHub-style issue syntax into links') .option('--noGitHubIssueLinking', 'Prevent automatic expansion of GitHub-style issue syntax into links')
.action(({ out, githubBranch, baseContentUrl, baseImagesUrl, yarn, ignoreFile, noGitHubIssueLinking }) => main(packageCommand({ packagePath: out, githubBranch, baseContentUrl, baseImagesUrl, useYarn: yarn, ignoreFile, expandGitHubIssueLinks: noGitHubIssueLinking }))); .option('--web', 'Enable packing as web extension. Note: This is a preview feature and is supported only for selected extensions.')
.action(({ out, githubBranch, baseContentUrl, baseImagesUrl, yarn, ignoreFile, noGitHubIssueLinking, web }) => main(packageCommand({ packagePath: out, githubBranch, baseContentUrl, baseImagesUrl, useYarn: yarn, ignoreFile, expandGitHubIssueLinks: noGitHubIssueLinking, web })));
program program
.command('publish [<version>]') .command('publish [<version>]')
@ -90,7 +91,8 @@ module.exports = function (argv: string[]): void {
.option('--yarn', 'Use yarn instead of npm while packing extension files') .option('--yarn', 'Use yarn instead of npm while packing extension files')
.option('--noVerify') .option('--noVerify')
.option('--ignoreFile [path]', 'Indicate alternative .vscodeignore') .option('--ignoreFile [path]', 'Indicate alternative .vscodeignore')
.action((version, { pat, message, packagePath, githubBranch, baseContentUrl, baseImagesUrl, yarn, noVerify, ignoreFile }) => main(publish({ pat, commitMessage: message, version, packagePath, githubBranch, baseContentUrl, baseImagesUrl, useYarn: yarn, noVerify, ignoreFile }))); .option('--web', 'Enable packing as web extension. Note: This is a preview feature and is supported only for selected extensions.')
.action((version, { pat, message, packagePath, githubBranch, baseContentUrl, baseImagesUrl, yarn, noVerify, ignoreFile, web }) => main(publish({ pat, commitMessage: message, version, packagePath, githubBranch, baseContentUrl, baseImagesUrl, useYarn: yarn, noVerify, ignoreFile, web })));
program program
.command('unpublish [<extensionid>]') .command('unpublish [<extensionid>]')

View file

@ -21,6 +21,8 @@ export interface Contributions {
[contributionType: string]: any; [contributionType: string]: any;
} }
export type ExtensionKind = 'ui' | 'workspace' | 'web';
export interface Manifest { export interface Manifest {
// mandatory (npm) // mandatory (npm)
name: string; name: string;
@ -42,7 +44,7 @@ export interface Manifest {
_testing?: string; _testing?: string;
enableProposedApi?: boolean; enableProposedApi?: boolean;
qna?: 'marketplace' | string | false; qna?: 'marketplace' | string | false;
extensionKind?: string[]; extensionKind?: ExtensionKind | ExtensionKind[];
// optional (npm) // optional (npm)
author?: string | Person; author?: string | Person;
@ -55,6 +57,7 @@ export interface Manifest {
license?: string; license?: string;
contributors?: string | Person[]; contributors?: string | Person[];
main?: string; main?: string;
browser?: string;
repository?: string | { type?: string; url?: string; }; repository?: string | { type?: string; url?: string; };
scripts?: { [name: string]: string; }; scripts?: { [name: string]: string; };
dependencies?: { [name: string]: string; }; dependencies?: { [name: string]: string; };

View file

@ -3,7 +3,7 @@ import * as path from 'path';
import * as cp from 'child_process'; import * as cp from 'child_process';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as yazl from 'yazl'; import * as yazl from 'yazl';
import { Manifest } from './manifest'; import { ExtensionKind, Manifest } from './manifest';
import { ITranslations, patchNLS } from './nls'; import { ITranslations, patchNLS } from './nls';
import * as util from './util'; import * as util from './util';
import * as _glob from 'glob'; import * as _glob from 'glob';
@ -16,6 +16,7 @@ import { lookup } from 'mime';
import * as urljoin from 'url-join'; import * as urljoin from 'url-join';
import { validatePublisher, validateExtensionName, validateVersion, validateEngineCompatibility, validateVSCodeTypesCompatibility } from './validation'; import { validatePublisher, validateExtensionName, validateVersion, validateEngineCompatibility, validateVSCodeTypesCompatibility } from './validation';
import { getDependencies } from './npm'; import { getDependencies } from './npm';
import { IExtensionsReport } from './publicgalleryapi';
const readFile = denodeify<string, string, string>(fs.readFile); const readFile = denodeify<string, string, string>(fs.readFile);
const unlink = denodeify<string, void>(fs.unlink as any); const unlink = denodeify<string, void>(fs.unlink as any);
@ -66,6 +67,7 @@ export interface IPackageOptions {
dependencyEntryPoints?: string[]; dependencyEntryPoints?: string[];
ignoreFile?: string; ignoreFile?: string;
expandGitHubIssueLinks?: boolean; expandGitHubIssueLinks?: boolean;
web?: boolean;
} }
export interface IProcessor { export interface IProcessor {
@ -541,7 +543,7 @@ export class ChangelogProcessor extends MarkdownProcessor {
class LicenseProcessor extends BaseProcessor { class LicenseProcessor extends BaseProcessor {
private didFindLicense = false; private didFindLicense = false;
private filter: (name: string) => boolean; filter: (name: string) => boolean;
constructor(manifest: Manifest) { constructor(manifest: Manifest) {
super(manifest); super(manifest);
@ -609,6 +611,89 @@ class IconProcessor extends BaseProcessor {
} }
} }
export function isSupportedWebExtension(manifest: Manifest, extensionsReport: IExtensionsReport): boolean {
const id = `${manifest.publisher}.${manifest.name}`;
return extensionsReport.web.publishers.some(publisher => manifest.publisher === publisher)
|| extensionsReport.web.extensions.some(extension => extension === id);
}
export function isWebKind(manifest: Manifest): boolean {
const extensionKind = getExtensionKind(manifest);
return extensionKind.some(kind => kind === 'web');
}
const workspaceExtensionPoints: string[] = ['terminal', 'debuggers', 'jsonValidation'];
function getExtensionKind(manifest: Manifest): ExtensionKind[] {
// check the manifest
if (manifest.extensionKind) {
return Array.isArray(manifest.extensionKind)
? manifest.extensionKind
: manifest.extensionKind === 'ui' ? ['ui', 'workspace'] : [manifest.extensionKind]
}
// Not an UI extension if it has main
if (manifest.main) {
if (manifest.browser) {
return ['workspace', 'web'];
}
return ['workspace'];
}
if (manifest.browser) {
return ['web'];
}
const isNonEmptyArray = obj => Array.isArray(obj) && obj.length > 0;
// Not an UI nor web extension if it has dependencies or an extension pack
if (isNonEmptyArray(manifest.extensionDependencies) || isNonEmptyArray(manifest.extensionPack)) {
return ['workspace'];
}
if (manifest.contributes) {
// Not an UI nor web extension if it has workspace contributions
for (const contribution of Object.keys(manifest.contributes)) {
if (workspaceExtensionPoints.indexOf(contribution) !== -1) {
return ['workspace'];
}
}
}
return ['ui', 'workspace', 'web'];
}
export class WebExtensionProcessor extends BaseProcessor {
private readonly isWebKind: boolean = false;
private readonly licenseProcessor: LicenseProcessor;
constructor(manifest: Manifest, options: IPackageOptions) {
super(manifest);
this.isWebKind = options.web && isWebKind(manifest);
this.licenseProcessor = new LicenseProcessor(manifest);
}
onFile(file: IFile): Promise<IFile> {
if (this.isWebKind) {
const path = util.normalize(file.path);
if (/\.svg$/i.test(path)) {
throw new Error(`SVGs can't be used in web extensions: ${path}`);
}
if (
!/^extension\/readme.md$/i.test(path) // exclude read me
&& !/^extension\/changelog.md$/i.test(path) // exclude changelog
&& !/^extension\/package.json$/i.test(path) // exclude package.json
&& !this.licenseProcessor.filter(path) // exclude licenses
&& !/^extension\/*node_modules\/*/i.test(path) // exclude node_modules
) {
this.assets.push({ type: `Microsoft.VisualStudio.Code.WebResources/${path}`, path });
}
}
return Promise.resolve(file);
}
}
export class NLSProcessor extends BaseProcessor { export class NLSProcessor extends BaseProcessor {
private translations: { [path: string]: string } = Object.create(null); private translations: { [path: string]: string } = Object.create(null);
@ -870,6 +955,11 @@ export function processFiles(processors: IProcessor[], files: IFile[]): Promise<
return Promise.all(processedFiles).then(files => { return Promise.all(processedFiles).then(files => {
return util.sequence(processors.map(p => () => p.onEnd())).then(() => { return util.sequence(processors.map(p => () => p.onEnd())).then(() => {
const assets = _.flatten(processors.map(p => p.assets)); const assets = _.flatten(processors.map(p => p.assets));
if (assets.length >= 50) {
throw new Error('Cannot have more than 50 assets');
}
const vsix = processors.reduce((r, p) => ({ ...r, ...p.vsix }), { assets }); const vsix = processors.reduce((r, p) => ({ ...r, ...p.vsix }), { assets });
return Promise.all([toVsixManifest(vsix), toContentTypes(files)]).then(result => { return Promise.all([toVsixManifest(vsix), toContentTypes(files)]).then(result => {
@ -892,6 +982,7 @@ export function createDefaultProcessors(manifest: Manifest, options: IPackageOpt
new LicenseProcessor(manifest), new LicenseProcessor(manifest),
new IconProcessor(manifest), new IconProcessor(manifest),
new NLSProcessor(manifest), new NLSProcessor(manifest),
new WebExtensionProcessor(manifest, options),
new ValidationProcessor(manifest) new ValidationProcessor(manifest)
]; ];
} }
@ -968,6 +1059,14 @@ export async function pack(options: IPackageOptions = {}): Promise<IPackageResul
const cwd = options.cwd || process.cwd(); const cwd = options.cwd || process.cwd();
const manifest = await readManifest(cwd); const manifest = await readManifest(cwd);
if (options.web && isWebKind(manifest)) {
const extensionsReport = await util.getPublicGalleryAPI().getExtensionsReport();
if (!isSupportedWebExtension(manifest, extensionsReport)) {
throw new Error(`Cannot pack as web extension as it is not supported`);
}
}
await prepublish(cwd, manifest, options.useYarn); await prepublish(cwd, manifest, options.useYarn);
const files = await collect(manifest, options); const files = await collect(manifest, options);

View file

@ -11,9 +11,18 @@ export interface ExtensionQuery {
readonly assetTypes?: string[]; readonly assetTypes?: string[];
} }
export interface IExtensionsReport {
malicious: string[];
web: {
publishers: string[],
extensions: string[],
};
}
export class PublicGalleryAPI { export class PublicGalleryAPI {
private client = new HttpClient('vsce'); private readonly extensionsReportUrl = 'https://az764295.vo.msecnd.net/extensions/marketplace.json';
private readonly client = new HttpClient('vsce');
constructor(private baseUrl: string, private apiVersion = '3.0-preview.1') { } constructor(private baseUrl: string, private apiVersion = '3.0-preview.1') { }
@ -45,4 +54,13 @@ export class PublicGalleryAPI {
const extensions = await this.extensionQuery(query); const extensions = await this.extensionQuery(query);
return extensions.filter(({ publisher: { publisherName: publisher }, extensionName: name }) => extensionId.toLowerCase() === `${publisher}.${name}`.toLowerCase())[0]; return extensions.filter(({ publisher: { publisherName: publisher }, extensionName: name }) => extensionId.toLowerCase() === `${publisher}.${name}`.toLowerCase())[0];
} }
async getExtensionsReport(): Promise<IExtensionsReport> {
const res = await this.client.get(this.extensionsReportUrl);
const raw = <Partial<IExtensionsReport>>JSON.parse(await res.readBody());
return {
malicious: raw.malicious || [],
web: raw.web || { publishers: [], extensions: [] }
}
}
} }

View file

@ -98,6 +98,7 @@ export interface IPublishOptions {
useYarn?: boolean; useYarn?: boolean;
noVerify?: boolean; noVerify?: boolean;
ignoreFile?: string; ignoreFile?: string;
web?: boolean;
} }
async function versionBump(cwd: string = process.cwd(), version?: string, commitMessage?: string): Promise<void> { async function versionBump(cwd: string = process.cwd(), version?: string, commitMessage?: string): Promise<void> {
@ -162,10 +163,11 @@ export function publish(options: IPublishOptions = {}): Promise<any> {
const baseImagesUrl = options.baseImagesUrl; const baseImagesUrl = options.baseImagesUrl;
const useYarn = options.useYarn; const useYarn = options.useYarn;
const ignoreFile = options.ignoreFile; const ignoreFile = options.ignoreFile;
const web = options.web;
promise = versionBump(options.cwd, options.version, options.commitMessage) promise = versionBump(options.cwd, options.version, options.commitMessage)
.then(() => tmpName()) .then(() => tmpName())
.then(packagePath => pack({ packagePath, cwd, githubBranch, baseContentUrl, baseImagesUrl, useYarn, ignoreFile })); .then(packagePath => pack({ packagePath, cwd, githubBranch, baseContentUrl, baseImagesUrl, useYarn, ignoreFile, web }));
} }
return promise.then(({ manifest, packagePath }) => { return promise.then(({ manifest, packagePath }) => {

View file

@ -1,7 +1,7 @@
import { import {
readManifest, collect, toContentTypes, ReadmeProcessor, readManifest, collect, toContentTypes, ReadmeProcessor,
read, processFiles, createDefaultProcessors, read, processFiles, createDefaultProcessors,
toVsixManifest, IFile, validateManifest toVsixManifest, IFile, validateManifest, isSupportedWebExtension, WebExtensionProcessor, IAsset
} from '../package'; } from '../package';
import { Manifest } from '../manifest'; import { Manifest } from '../manifest';
import * as path from 'path'; import * as path from 'path';
@ -10,6 +10,7 @@ import * as assert from 'assert';
import { parseString } from 'xml2js'; import { parseString } from 'xml2js';
import * as denodeify from 'denodeify'; import * as denodeify from 'denodeify';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { IExtensionsReport } from '../publicgalleryapi';
// don't warn in tests // don't warn in tests
console.warn = () => null; console.warn = () => null;
@ -1803,3 +1804,81 @@ describe('MarkdownProcessor', () => {
await throws(() => processor.onFile(readme)); await throws(() => processor.onFile(readme));
}) })
}); });
describe('isSupportedWebExtension', () => {
it('should return true if extension report has extension', () => {
const manifest = createManifest({ name: 'test', publisher: 'mocha' });
const extensionReport: IExtensionsReport = { malicious: [], web: { extensions: ['mocha.test'], publishers: [] } };
assert.ok(isSupportedWebExtension(manifest, extensionReport));
});
it('should return true if extension report has publisher', () => {
const manifest = createManifest({ name: 'test', publisher: 'mocha' });
const extensionReport: IExtensionsReport = { malicious: [], web: { extensions: [], publishers: ['mocha'] } };
assert.ok(isSupportedWebExtension(manifest, extensionReport));
});
it('should return false if extension report does not has extension', () => {
const manifest = createManifest({ name: 'test', publisher: 'mocha' });
const extensionReport: IExtensionsReport = { malicious: [], web: { extensions: [], publishers: [] } };
assert.ok(!isSupportedWebExtension(manifest, extensionReport));
});
});
describe('WebExtensionProcessor', () => {
it('should include browser file', () => {
const manifest = createManifest({ extensionKind: ['web'] });
const processor = new WebExtensionProcessor(manifest, { web: true });
const file = { path: 'extension/browser.js', contents: '' };
processor.onFile(file);
const expected: IAsset[] = [{ type: `Microsoft.VisualStudio.Code.WebResources/${file.path}`, path: file.path }];
assert.deepEqual(processor.assets, expected);
});
it('should exclude manifest', () => {
const manifest = createManifest({ extensionKind: ['web'] });
const processor = new WebExtensionProcessor(manifest, { web: true });
const manifestFile = { path: 'extension/package.json', contents: JSON.stringify(manifest) };
processor.onFile(manifestFile);
assert.deepEqual(processor.assets, []);
});
it('should exclude changelog', () => {
const manifest = createManifest({ extensionKind: ['web'] });
const processor = new WebExtensionProcessor(manifest, { web: true });
const changelogFile = { path: 'extension/changelog.md', contents: '' };
processor.onFile(changelogFile);
assert.deepEqual(processor.assets, []);
});
it('should exclude readme', () => {
const manifest = createManifest({ extensionKind: ['web'] });
const processor = new WebExtensionProcessor(manifest, { web: true });
const readMeFile = { path: 'extension/readme.md', contents: '' };
processor.onFile(readMeFile);
assert.deepEqual(processor.assets, []);
});
it('should exclude files from node_modules', () => {
const manifest = createManifest({ extensionKind: ['web'] });
const processor = new WebExtensionProcessor(manifest, { web: true });
processor.onFile({ path: 'extension/node_modules/sample.t.ds', contents: '' });
processor.onFile({ path: 'extension/node_modules/a/sample.js', contents: '' });
processor.onFile({ path: 'extension/node_modules/a/b/c/sample.js', contents: '' });
assert.deepEqual(processor.assets, []);
});
});