ensure-package.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. /*-----------------------------------------------------------------------------
  2. | Copyright (c) Jupyter Development Team.
  3. | Distributed under the terms of the Modified BSD License.
  4. |----------------------------------------------------------------------------*/
  5. import * as fs from 'fs-extra';
  6. import * as glob from 'glob';
  7. import * as path from 'path';
  8. import * as prettier from 'prettier';
  9. import * as ts from 'typescript';
  10. import { getDependency } from './get-dependency';
  11. import * as utils from './utils';
  12. const HEADER_TEMPLATE = `
  13. /*-----------------------------------------------------------------------------
  14. | Copyright (c) Jupyter Development Team.
  15. | Distributed under the terms of the Modified BSD License.
  16. |----------------------------------------------------------------------------*/
  17. /* This file was auto-generated by {{funcName}}() in @jupyterlab/buildutils */
  18. `;
  19. const ICON_IMPORTS_TEMPLATE = `
  20. import { Icon } from './interfaces';
  21. // icon svg import statements
  22. {{iconImportStatements}}
  23. // defaultIcons definition
  24. export namespace IconImports {
  25. export const defaultIcons: ReadonlyArray<Icon.IModel> = [
  26. {{iconModelDeclarations}}
  27. ];
  28. }
  29. `;
  30. const ICON_CSS_CLASSES_TEMPLATE = `
  31. /**
  32. * (DEPRECATED) Support for consuming icons as CSS background images
  33. */
  34. /* Icons urls */
  35. :root {
  36. {{iconCSSUrls}}
  37. }
  38. /* Icon CSS class declarations */
  39. {{iconCSSDeclarations}}
  40. `;
  41. /**
  42. * Ensure the integrity of a package.
  43. *
  44. * @param options - The options used to ensure the package.
  45. *
  46. * @returns A list of changes that were made to ensure the package.
  47. */
  48. export async function ensurePackage(
  49. options: IEnsurePackageOptions
  50. ): Promise<string[]> {
  51. let { data, pkgPath } = options;
  52. let deps: { [key: string]: string } = data.dependencies || {};
  53. let devDeps: { [key: string]: string } = data.devDependencies || {};
  54. let seenDeps = options.depCache || {};
  55. let missing = options.missing || [];
  56. let unused = options.unused || [];
  57. let messages: string[] = [];
  58. let locals = options.locals || {};
  59. let cssImports = options.cssImports || [];
  60. let differentVersions = options.differentVersions || [];
  61. // Verify dependencies are consistent.
  62. let promises = Object.keys(deps).map(async name => {
  63. if (differentVersions.indexOf(name) !== -1) {
  64. // Skip processing packages that can have different versions
  65. return;
  66. }
  67. if (!(name in seenDeps)) {
  68. seenDeps[name] = await getDependency(name);
  69. }
  70. if (deps[name] !== seenDeps[name]) {
  71. messages.push(`Updated dependency: ${name}@${seenDeps[name]}`);
  72. }
  73. deps[name] = seenDeps[name];
  74. });
  75. await Promise.all(promises);
  76. // Verify devDependencies are consistent.
  77. promises = Object.keys(devDeps).map(async name => {
  78. if (differentVersions.indexOf(name) !== -1) {
  79. // Skip processing packages that can have different versions
  80. return;
  81. }
  82. if (!(name in seenDeps)) {
  83. seenDeps[name] = await getDependency(name);
  84. }
  85. if (devDeps[name] !== seenDeps[name]) {
  86. messages.push(`Updated devDependency: ${name}@${seenDeps[name]}`);
  87. }
  88. devDeps[name] = seenDeps[name];
  89. });
  90. await Promise.all(promises);
  91. // For TypeScript files, verify imports match dependencies.
  92. let filenames: string[] = [];
  93. filenames = glob.sync(path.join(pkgPath, 'src/*.ts*'));
  94. filenames = filenames.concat(glob.sync(path.join(pkgPath, 'src/**/*.ts*')));
  95. if (!fs.existsSync(path.join(pkgPath, 'tsconfig.json'))) {
  96. if (utils.writePackageData(path.join(pkgPath, 'package.json'), data)) {
  97. messages.push('Updated package.json');
  98. }
  99. return messages;
  100. }
  101. let imports: string[] = [];
  102. // Extract all of the imports from the TypeScript files.
  103. filenames.forEach(fileName => {
  104. let sourceFile = ts.createSourceFile(
  105. fileName,
  106. fs.readFileSync(fileName).toString(),
  107. (ts.ScriptTarget as any).ES6,
  108. /*setParentNodes */ true
  109. );
  110. imports = imports.concat(getImports(sourceFile));
  111. });
  112. // Make sure we are not importing CSS in a core package.
  113. if (data.name.indexOf('example') === -1) {
  114. imports.forEach(importStr => {
  115. if (importStr.indexOf('.css') !== -1) {
  116. messages.push('CSS imports are not allowed source files');
  117. }
  118. });
  119. }
  120. let names: string[] = Array.from(new Set(imports)).sort();
  121. names = names.map(function(name) {
  122. let parts = name.split('/');
  123. if (name.indexOf('@') === 0) {
  124. return parts[0] + '/' + parts[1];
  125. }
  126. return parts[0];
  127. });
  128. // Look for imports with no dependencies.
  129. promises = names.map(async name => {
  130. if (missing.indexOf(name) !== -1) {
  131. return;
  132. }
  133. if (name === '.' || name === '..') {
  134. return;
  135. }
  136. if (!deps[name]) {
  137. if (!(name in seenDeps)) {
  138. seenDeps[name] = await getDependency(name);
  139. }
  140. deps[name] = seenDeps[name];
  141. messages.push(`Added dependency: ${name}@${seenDeps[name]}`);
  142. }
  143. });
  144. await Promise.all(promises);
  145. // Template the CSS index file.
  146. if (cssImports && fs.existsSync(path.join(pkgPath, 'style/base.css'))) {
  147. const funcName = 'ensurePackage';
  148. let cssIndexContents = utils.fromTemplate(
  149. HEADER_TEMPLATE,
  150. { funcName },
  151. { end: '' }
  152. );
  153. cssImports.forEach(cssImport => {
  154. cssIndexContents += `\n@import url('~${cssImport}');`;
  155. });
  156. cssIndexContents += "\n\n@import url('./base.css');\n";
  157. // write out cssIndexContents, if needed
  158. const cssIndexPath = path.join(pkgPath, 'style/index.css');
  159. messages.push(...ensureFile(cssIndexPath, cssIndexContents, false));
  160. }
  161. // Look for unused packages
  162. Object.keys(deps).forEach(name => {
  163. if (options.noUnused === false) {
  164. return;
  165. }
  166. if (unused.indexOf(name) !== -1) {
  167. return;
  168. }
  169. const isTest = data.name.indexOf('test') !== -1;
  170. if (isTest) {
  171. const testLibs = ['jest', 'ts-jest', '@jupyterlab/testutils'];
  172. if (testLibs.indexOf(name) !== -1) {
  173. return;
  174. }
  175. }
  176. if (names.indexOf(name) === -1) {
  177. let version = data.dependencies[name];
  178. messages.push(
  179. `Unused dependency: ${name}@${version}: remove or add to list of known unused dependencies for this package`
  180. );
  181. }
  182. });
  183. // Handle typedoc config output.
  184. const tdOptionsPath = path.join(pkgPath, 'tdoptions.json');
  185. if (fs.existsSync(tdOptionsPath)) {
  186. const tdConfigData = utils.readJSONFile(tdOptionsPath);
  187. const pkgDirName = pkgPath.split('/').pop();
  188. tdConfigData['out'] = `../../docs/api/${pkgDirName}`;
  189. utils.writeJSONFile(tdOptionsPath, tdConfigData);
  190. }
  191. // Handle references.
  192. let references: { [key: string]: string } = Object.create(null);
  193. Object.keys(deps).forEach(name => {
  194. if (!(name in locals)) {
  195. return;
  196. }
  197. const target = locals[name];
  198. if (!fs.existsSync(path.join(target, 'tsconfig.json'))) {
  199. return;
  200. }
  201. let ref = path.relative(pkgPath, locals[name]);
  202. references[name] = ref.split(path.sep).join('/');
  203. });
  204. if (
  205. data.name.indexOf('example-') === -1 &&
  206. Object.keys(references).length > 0
  207. ) {
  208. const tsConfigPath = path.join(pkgPath, 'tsconfig.json');
  209. const tsConfigData = utils.readJSONFile(tsConfigPath);
  210. tsConfigData.references = [];
  211. Object.keys(references).forEach(name => {
  212. tsConfigData.references.push({ path: references[name] });
  213. });
  214. utils.writeJSONFile(tsConfigPath, tsConfigData);
  215. }
  216. // Get a list of all the published files.
  217. // This will not catch .js or .d.ts files if they have not been built,
  218. // but we primarily use this to check for files that are published as-is,
  219. // like styles, assets, and schemas.
  220. const published = new Set<string>(
  221. data.files
  222. ? data.files.reduce((acc: string[], curr: string) => {
  223. return acc.concat(glob.sync(path.join(pkgPath, curr)));
  224. }, [])
  225. : []
  226. );
  227. // Ensure that the `schema` directories match what is in the `package.json`
  228. const schemaDir = data.jupyterlab && data.jupyterlab.schemaDir;
  229. const schemas = glob.sync(
  230. path.join(pkgPath, schemaDir || 'schema', '*.json')
  231. );
  232. if (schemaDir && !schemas.length) {
  233. messages.push(`No schemas found in ${path.join(pkgPath, schemaDir)}.`);
  234. } else if (!schemaDir && schemas.length) {
  235. messages.push(`Schemas found, but no schema indicated in ${pkgPath}`);
  236. }
  237. for (let schema of schemas) {
  238. if (!published.has(schema)) {
  239. messages.push(`Schema ${schema} not published in ${pkgPath}`);
  240. }
  241. }
  242. // Ensure that the `style` directories match what is in the `package.json`
  243. const styles = glob.sync(path.join(pkgPath, 'style', '**/*.*'));
  244. for (let style of styles) {
  245. if (!published.has(style)) {
  246. messages.push(`Style file ${style} not published in ${pkgPath}`);
  247. }
  248. }
  249. // If we have styles, ensure that 'style' field is declared
  250. if (styles.length > 0) {
  251. if (data.style === undefined) {
  252. data.style = 'style/index.css';
  253. }
  254. }
  255. // Ensure that sideEffects are declared, and that any styles are covered
  256. if (styles.length > 0) {
  257. if (data.sideEffects === undefined) {
  258. messages.push(
  259. `Side effects not declared in ${pkgPath}, and styles are present.`
  260. );
  261. } else if (data.sideEffects === false) {
  262. messages.push(`Style files not included in sideEffects in ${pkgPath}`);
  263. }
  264. }
  265. // Ensure dependencies and dev dependencies.
  266. data.dependencies = deps;
  267. data.devDependencies = devDeps;
  268. if (Object.keys(data.dependencies).length === 0) {
  269. delete data.dependencies;
  270. }
  271. if (Object.keys(data.devDependencies).length === 0) {
  272. delete data.devDependencies;
  273. }
  274. // Make sure there are no gitHead keys, which are only temporary keys used
  275. // when a package is actually being published.
  276. delete data.gitHead;
  277. // Ensure there is a minimal prepublishOnly script
  278. if (!data.private && !data.scripts.prepublishOnly) {
  279. messages.push(`prepublishOnly script missing in ${pkgPath}`);
  280. data.scripts.prepublishOnly = 'npm run build';
  281. }
  282. if (utils.writePackageData(path.join(pkgPath, 'package.json'), data)) {
  283. messages.push('Updated package.json');
  284. }
  285. return messages;
  286. }
  287. /**
  288. * An extra ensure function just for the @jupyterlab/ui-components package.
  289. * Ensures that the icon svg import statements are synced with the contents
  290. * of ui-components/style/icons.
  291. *
  292. * @param pkgPath - The path to the @jupyterlab/ui-components package.
  293. *
  294. * @returns A list of changes that were made to ensure the package.
  295. */
  296. export async function ensureUiComponents(pkgPath: string): Promise<string[]> {
  297. const funcName = 'ensureUiComponents';
  298. let messages: string[] = [];
  299. const svgs = glob.sync(path.join(pkgPath, 'style/icons', '**/*.svg'));
  300. /* support for glob import of icon svgs */
  301. const iconSrcDir = path.join(pkgPath, 'src/icon');
  302. // build the per-icon import code
  303. let _iconImportStatements: string[] = [];
  304. let _iconModelDeclarations: string[] = [];
  305. svgs.forEach(svg => {
  306. const name = utils.stem(svg);
  307. const nameCamel = utils.camelCase(name) + 'Svg';
  308. _iconImportStatements.push(
  309. `import ${nameCamel} from '${path
  310. .relative(iconSrcDir, svg)
  311. .split(path.sep)
  312. .join('/')}';`
  313. );
  314. _iconModelDeclarations.push(`{ name: '${name}', svg: ${nameCamel} }`);
  315. });
  316. const iconImportStatements = _iconImportStatements.join('\n');
  317. const iconModelDeclarations = _iconModelDeclarations.join(',\n');
  318. // generate the actual contents of the iconImports file
  319. const iconImportsPath = path.join(iconSrcDir, 'iconImports.ts');
  320. const iconImportsContents = utils.fromTemplate(
  321. HEADER_TEMPLATE + ICON_IMPORTS_TEMPLATE,
  322. { funcName, iconImportStatements, iconModelDeclarations }
  323. );
  324. messages.push(...ensureFile(iconImportsPath, iconImportsContents));
  325. /* support for deprecated icon CSS classes */
  326. const iconCSSDir = path.join(pkgPath, 'style');
  327. // build the per-icon import code
  328. let _iconCSSUrls: string[] = [];
  329. let _iconCSSDeclarations: string[] = [];
  330. svgs.forEach(svg => {
  331. const name = utils.stem(svg);
  332. const urlName = 'jp-icon-' + name;
  333. const className = 'jp-' + utils.camelCase(name, true) + 'Icon';
  334. _iconCSSUrls.push(
  335. `--${urlName}: url('${path
  336. .relative(iconCSSDir, svg)
  337. .split(path.sep)
  338. .join('/')}');`
  339. );
  340. _iconCSSDeclarations.push(
  341. `.${className} {background-image: var(--${urlName})}`
  342. );
  343. });
  344. const iconCSSUrls = _iconCSSUrls.join('\n');
  345. const iconCSSDeclarations = _iconCSSDeclarations.join('\n');
  346. // generate the actual contents of the iconCSSClasses file
  347. const iconCSSClassesPath = path.join(iconCSSDir, 'deprecated.css');
  348. const iconCSSClassesContent = utils.fromTemplate(
  349. HEADER_TEMPLATE + ICON_CSS_CLASSES_TEMPLATE,
  350. { funcName, iconCSSUrls, iconCSSDeclarations }
  351. );
  352. messages.push(...ensureFile(iconCSSClassesPath, iconCSSClassesContent));
  353. return messages;
  354. }
  355. /**
  356. * The options used to ensure a package.
  357. */
  358. export interface IEnsurePackageOptions {
  359. /**
  360. * The path to the package.
  361. */
  362. pkgPath: string;
  363. /**
  364. * The package data.
  365. */
  366. data: any;
  367. /**
  368. * The cache of dependency versions by package.
  369. */
  370. depCache?: { [key: string]: string };
  371. /**
  372. * A list of dependencies that can be unused.
  373. */
  374. unused?: string[];
  375. /**
  376. * A list of dependencies that can be missing.
  377. */
  378. missing?: string[];
  379. /**
  380. * A map of local package names and their relative path.
  381. */
  382. locals?: { [key: string]: string };
  383. /**
  384. * Whether to enforce that dependencies get used. Default is true.
  385. */
  386. noUnused?: boolean;
  387. /**
  388. * The css import list for the package.
  389. */
  390. cssImports?: string[];
  391. /**
  392. * Packages which are allowed to have multiple versions pulled in
  393. */
  394. differentVersions?: string[];
  395. }
  396. /**
  397. * Ensure that contents of a file match a supplied string. If they do match,
  398. * do nothing and return an empty array. If they don't match, overwrite the
  399. * file and return an array with an update message.
  400. *
  401. * @param path: The path to the file being checked. The file must exist,
  402. * or else this function does nothing.
  403. *
  404. * @param contents: The desired file contents.
  405. *
  406. * @param prettify: default = true. If true, format the contents with
  407. * `prettier` before comparing/writing. Set to false only if you already
  408. * know your code won't be modified later by the `prettier` git commit hook.
  409. *
  410. * @returns a string array with 0 or 1 messages.
  411. */
  412. function ensureFile(
  413. path: string,
  414. contents: string,
  415. prettify: boolean = true
  416. ): string[] {
  417. let messages: string[] = [];
  418. if (!fs.existsSync(path)) {
  419. // bail
  420. messages.push(
  421. `Tried to ensure the contents of ./${path}, but the file does not exist`
  422. );
  423. return messages;
  424. }
  425. // run the newly generated contents through prettier before comparing
  426. if (prettify) {
  427. contents = prettier.format(contents, { filepath: path, singleQuote: true });
  428. }
  429. const prev = fs.readFileSync(path, {
  430. encoding: 'utf8'
  431. });
  432. // Normalize line endings to match current content
  433. if (prev.indexOf('\r') !== -1) {
  434. contents = contents.replace(/\n/g, '\r\n');
  435. }
  436. if (prev !== contents) {
  437. fs.writeFileSync(path, contents);
  438. messages.push(`Updated ./${path}`);
  439. }
  440. return messages;
  441. }
  442. /**
  443. * Extract the module imports from a TypeScript source file.
  444. *
  445. * @param sourceFile - The path to the source file.
  446. *
  447. * @returns An array of package names.
  448. */
  449. function getImports(sourceFile: ts.SourceFile): string[] {
  450. let imports: string[] = [];
  451. handleNode(sourceFile);
  452. function handleNode(node: any): void {
  453. switch (node.kind) {
  454. case ts.SyntaxKind.ImportDeclaration:
  455. imports.push(node.moduleSpecifier.text);
  456. break;
  457. case ts.SyntaxKind.ImportEqualsDeclaration:
  458. imports.push(node.moduleReference.expression.text);
  459. break;
  460. default:
  461. // no-op
  462. }
  463. ts.forEachChild(node, handleNode);
  464. }
  465. return imports;
  466. }