ensure-package.ts 19 KB

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