diff --git a/src/main.ts b/src/main.ts index ad9c61c..c611a68 100644 --- a/src/main.ts +++ b/src/main.ts @@ -76,7 +76,8 @@ module.exports = function (argv: string[]): void { .option('--yarn', 'Use yarn instead of npm') .option('--ignoreFile [path]', 'Indicate alternative .vscodeignore') .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 .command('publish []') @@ -90,7 +91,8 @@ module.exports = function (argv: string[]): void { .option('--yarn', 'Use yarn instead of npm while packing extension files') .option('--noVerify') .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 .command('unpublish []') diff --git a/src/manifest.ts b/src/manifest.ts index 542781a..da1ac19 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -21,6 +21,8 @@ export interface Contributions { [contributionType: string]: any; } +export type ExtensionKind = 'ui' | 'workspace' | 'web'; + export interface Manifest { // mandatory (npm) name: string; @@ -42,7 +44,7 @@ export interface Manifest { _testing?: string; enableProposedApi?: boolean; qna?: 'marketplace' | string | false; - extensionKind?: string[]; + extensionKind?: ExtensionKind | ExtensionKind[]; // optional (npm) author?: string | Person; @@ -55,6 +57,7 @@ export interface Manifest { license?: string; contributors?: string | Person[]; main?: string; + browser?: string; repository?: string | { type?: string; url?: string; }; scripts?: { [name: string]: string; }; dependencies?: { [name: string]: string; }; diff --git a/src/package.ts b/src/package.ts index ac2aa79..654319d 100644 --- a/src/package.ts +++ b/src/package.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as cp from 'child_process'; import * as _ from 'lodash'; import * as yazl from 'yazl'; -import { Manifest } from './manifest'; +import { ExtensionKind, Manifest } from './manifest'; import { ITranslations, patchNLS } from './nls'; import * as util from './util'; import * as _glob from 'glob'; @@ -16,6 +16,7 @@ import { lookup } from 'mime'; import * as urljoin from 'url-join'; import { validatePublisher, validateExtensionName, validateVersion, validateEngineCompatibility, validateVSCodeTypesCompatibility } from './validation'; import { getDependencies } from './npm'; +import { IExtensionsReport } from './publicgalleryapi'; const readFile = denodeify(fs.readFile); const unlink = denodeify(fs.unlink as any); @@ -66,6 +67,7 @@ export interface IPackageOptions { dependencyEntryPoints?: string[]; ignoreFile?: string; expandGitHubIssueLinks?: boolean; + web?: boolean; } export interface IProcessor { @@ -541,7 +543,7 @@ export class ChangelogProcessor extends MarkdownProcessor { class LicenseProcessor extends BaseProcessor { private didFindLicense = false; - private filter: (name: string) => boolean; + filter: (name: string) => boolean; constructor(manifest: 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 { + 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 { 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 util.sequence(processors.map(p => () => p.onEnd())).then(() => { 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 }); return Promise.all([toVsixManifest(vsix), toContentTypes(files)]).then(result => { @@ -892,6 +982,7 @@ export function createDefaultProcessors(manifest: Manifest, options: IPackageOpt new LicenseProcessor(manifest), new IconProcessor(manifest), new NLSProcessor(manifest), + new WebExtensionProcessor(manifest, options), new ValidationProcessor(manifest) ]; } @@ -968,6 +1059,14 @@ export async function pack(options: IPackageOptions = {}): Promise extensionId.toLowerCase() === `${publisher}.${name}`.toLowerCase())[0]; } + + async getExtensionsReport(): Promise { + const res = await this.client.get(this.extensionsReportUrl); + const raw = >JSON.parse(await res.readBody()); + return { + malicious: raw.malicious || [], + web: raw.web || { publishers: [], extensions: [] } + } + } } diff --git a/src/publish.ts b/src/publish.ts index 1d3f0d8..a2449d4 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -98,6 +98,7 @@ export interface IPublishOptions { useYarn?: boolean; noVerify?: boolean; ignoreFile?: string; + web?: boolean; } async function versionBump(cwd: string = process.cwd(), version?: string, commitMessage?: string): Promise { @@ -162,10 +163,11 @@ export function publish(options: IPublishOptions = {}): Promise { const baseImagesUrl = options.baseImagesUrl; const useYarn = options.useYarn; const ignoreFile = options.ignoreFile; + const web = options.web; promise = versionBump(options.cwd, options.version, options.commitMessage) .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 }) => { diff --git a/src/test/package.test.ts b/src/test/package.test.ts index 4a20cd3..de6ce34 100644 --- a/src/test/package.test.ts +++ b/src/test/package.test.ts @@ -1,7 +1,7 @@ import { readManifest, collect, toContentTypes, ReadmeProcessor, read, processFiles, createDefaultProcessors, - toVsixManifest, IFile, validateManifest + toVsixManifest, IFile, validateManifest, isSupportedWebExtension, WebExtensionProcessor, IAsset } from '../package'; import { Manifest } from '../manifest'; import * as path from 'path'; @@ -10,6 +10,7 @@ import * as assert from 'assert'; import { parseString } from 'xml2js'; import * as denodeify from 'denodeify'; import * as _ from 'lodash'; +import { IExtensionsReport } from '../publicgalleryapi'; // don't warn in tests console.warn = () => null; @@ -1803,3 +1804,81 @@ describe('MarkdownProcessor', () => { 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, []); + }); + +}); \ No newline at end of file