ensure-package.ts 19 KB

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