// Third-party license notices for plugin ZIPs (issue #345). A plugin that // redistributes third-party material must ship the license texts alongside it // (Apache-2.0 §4(a), MIT's notice clause, OFL §2). The bundled-package list is // derived from the esbuild metafile — the set of files that actually ended up // in plugin.js — so the notices can never drift from the bundle the way a // hand-maintained list would. Non-bundled material (vendored webapps, copied // font assets) cannot appear in a metafile; callers pass those as `extras`. import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve, sep } from 'node:path'; const LICENSE_FILE_PATTERN = /^(licen[cs]e|copying|notice)(\.|$)/i; /** Walk up from `file` to the nearest package.json that names a package. */ function packageRootOf(file) { let dir = dirname(resolve(file)); while (dir !== dirname(dir)) { const pj = join(dir, 'package.json'); if (existsSync(pj)) { try { const parsed = JSON.parse(readFileSync(pj, 'utf8')); if (parsed.name) return { dir, pkg: parsed }; } catch { // unreadable package.json (e.g. a fixture) — keep walking up } } dir = dirname(dir); } return null; } function shippedLicenseText(dir) { const names = readdirSync(dir).filter((name) => LICENSE_FILE_PATTERN.test(name)); return names .sort() .map((name) => readFileSync(join(dir, name), 'utf8').trim()) .join('\n\n'); } /** * All third-party npm packages whose files the metafile lists as bundle * inputs, deduplicated by name@version. First-party `@dorfteich/*` packages * are covered by the repository LICENSE and skipped. */ export function bundledPackages(metafile) { const seen = new Map(); for (const input of Object.keys(metafile.inputs)) { if (!input.split(sep).includes('node_modules') && !input.includes('/node_modules/')) continue; const found = packageRootOf(input); if (!found || found.pkg.name.startsWith('@dorfteich/')) continue; const key = `${found.pkg.name}@${found.pkg.version}`; if (!seen.has(key)) { seen.set(key, { name: found.pkg.name, version: found.pkg.version, license: typeof found.pkg.license === 'string' ? found.pkg.license : 'see license text', text: shippedLicenseText(found.dir), }); } } return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)); } /** * Renders `licenses/THIRD-PARTY-NOTICES.txt` for a plugin ZIP: one section per * bundled package (license expression + the license file it ships), then one * per caller-supplied extra ({ title, license, note?, text? }). */ export function thirdPartyNotices(metafile, extras = []) { const rule = '='.repeat(72); const sections = [ 'THIRD-PARTY NOTICES\n\nThis plugin package redistributes the third-party components listed\nbelow, each under its own license.\n', ]; for (const pkg of bundledPackages(metafile)) { const body = pkg.text || `License: ${pkg.license} (no license file shipped in the npm package)`; sections.push(`${rule}\n${pkg.name} ${pkg.version} — ${pkg.license}\n${rule}\n\n${body}\n`); } for (const extra of extras) { const parts = [extra.note, extra.text].filter(Boolean).join('\n\n'); sections.push(`${rule}\n${extra.title} — ${extra.license}\n${rule}\n\n${parts}\n`); } return sections.join('\n'); }