chore: generate third-party license notices for bundled deps

The node bundle inlines its devDependencies by design, so the published
artifact redistributes third-party code without carrying its licenses.
The build now traces every module that lands in an emitted chunk back to
its package and writes THIRD-PARTY-NOTICES.md (license texts, copyright
lines, and any Apache NOTICE files, grouped by identical text), shipped
next to LICENSE via the files list and kept fresh by a staleness guard
in `pnpm check`. Licenses outside a permitted allowlist fail the build.

Also restores the OFL license name records (13/14) that subsetting
stripped from the Inter webfonts and vendors Inter's license text, so
the license travels with the font files into users' site builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5408/head
Divyansh Singh 2 weeks ago
parent d00a5e0f87
commit dadb094dbc

File diff suppressed because it is too large Load Diff

@ -42,6 +42,7 @@
"vitepress": "bin/vitepress.js"
},
"files": [
"THIRD-PARTY-NOTICES.md",
"bin",
"dist",
"types",
@ -81,7 +82,7 @@
"docs:lunaria:open": "pnpm -F=docs lunaria:open",
"format": "prettier --experimental-cli --write .",
"format:fail": "prettier --experimental-cli --check .",
"check": "pnpm format:fail && pnpm build && pnpm test",
"check": "pnpm format:fail && pnpm build && git ls-files --error-unmatch THIRD-PARTY-NOTICES.md && git diff --exit-code -- THIRD-PARTY-NOTICES.md && pnpm test",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"release": "node scripts/release.ts"
},

@ -100,7 +100,12 @@ async function main() {
// Commit changes to the Git and create a tag.
prompts.log.step('Committing changes...')
await run('git', ['add', 'CHANGELOG.md', 'package.json'])
await run('git', [
'add',
'CHANGELOG.md',
'package.json',
'THIRD-PARTY-NOTICES.md'
])
await run('git', ['commit', '-m', `release: v${targetVersion}`])
await run('git', ['tag', `v${targetVersion}`])

@ -181,6 +181,11 @@ def build_subsets(release: Path, subsets: dict[str, str]) -> None:
options = subset.Options()
options.flavor = "woff2"
options.layout_features = [*options.layout_features, "pnum", "tnum"]
# keep the OFL license notice (13) and url (14) name records that
# upstream embeds: the subsets are Modified Versions, and OFL §2
# requires the license to travel with every copy - including the
# ones Vite copies into users' publicly served site builds
options.name_IDs = [*options.name_IDs, 13, 14]
font = subset.load_font(release / file, options)
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=parse_ranges(value))

@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

@ -170,6 +170,446 @@ function skipUnchanged(): Rolldown.Plugin {
}
}
// The node bundle inlines its devDependencies by design, so the published
// artifact redistributes third-party code. Every module that ends up in an
// emitted chunk is traced back to its package here, and the aggregate
// notices — license texts with their copyright lines, plus any NOTICE files
// (Apache-2.0 §4(d)) — are written to THIRD-PARTY-NOTICES.md. npm does not
// auto-include that name, so it ships only via the files list in
// package.json; `pnpm check` fails when the committed copy goes stale. The
// file is regenerated by the build — do not edit it by hand. Entries with
// byte-identical texts are grouped: the copyright notices the licenses
// require live inside the quoted texts, so grouping loses nothing.
const NOTICES_FILE = 'THIRD-PARTY-NOTICES.md'
// licenses whose redistribution obligations this file's mechanism fully
// discharges: carrying the verbatim license text and copyright notice along
// (plus NOTICE propagation for Apache-2.0, and the modifications statement
// in the header). Anything else fails the build so its obligations get
// reviewed deliberately instead of silently shipped — copyleft (GPL),
// source-offer duties (MPL/LGPL), unparseable ids, and also e.g. Zlib and
// Python-2.0, whose mark-your-changes clauses an aggregate notices file
// cannot satisfy on its own.
const PERMITTED_LICENSES = new Set([
'0BSD',
'Apache-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'BlueOak-1.0.0',
'CC0-1.0',
'ISC',
'MIT',
'OFL-1.1',
'Unlicense'
])
// canonical license bodies for packages that declare an id but ship no text
// file. MIT/ISC require the copyright and permission notice to accompany
// copies, so a bare identifier would not be compliant; the reconstruction
// pairs the fixed license wording with the package's own author line and is
// surfaced as a warning on every full build.
const LICENSE_TEMPLATES: Record<string, (holder: string) => string> = {
MIT: (holder) => `MIT License
Copyright (c) ${holder}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.`,
ISC: (holder) => `ISC License
Copyright (c) ${holder}
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.`
}
// copyright holders for packages that ship neither a license file nor an
// author field, verified against their repositories
const AUTHOR_FALLBACKS: Record<string, string> = {
'@polka/compression': 'Luke Edwards <luke@lukeed.com> (https://lukeed.com)'
}
interface BundledPackage {
name: string
version?: string
license: string
authors?: string
repository?: string
licenseText: string
noticeText?: string
reconstructed?: boolean
}
// third-party content vendored into src rather than resolved from
// node_modules: the Inter font files shipped under theme-default/fonts
const VENDORED_PACKAGES: BundledPackage[] = [
{
name: 'Inter',
license: 'OFL-1.1',
authors: 'The Inter Project Authors',
repository: 'https://github.com/rsms/inter',
licenseText: readFileSync(
path.join(ROOT, 'src/client/theme-default/fonts/LICENSE.txt'),
'utf8'
)
.replaceAll('\r\n', '\n')
.trim()
}
]
// localeCompare consults the host locale; the output must be byte-stable
// across machines, so sort by code units everywhere
const compareStrings = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const blockquote = (text: string) =>
text
.split('\n')
.map((l) => (l ? `> ${l}` : '>'))
.join('\n')
function formatPerson(person: unknown): string | undefined {
if (typeof person === 'string') return person
const { name, email, url } = (person ?? {}) as Record<string, string>
if (!name) return undefined
return name + (email ? ` <${email}>` : '') + (url ? ` (${url})` : '')
}
// render repository fields as plain https URLs, whatever form the manifest
// uses (git+/git@/ssh, github: and owner/repo shorthands)
function normalizeRepoUrl(url: string): string {
const repo = url
.replace(/^git\+/, '')
.replace(/\.git$/, '')
.replace(/^git@([^:]+):/, 'https://$1/')
.replace(/^(?:git|ssh):\/\/(?:[^@/]+@)?/, 'https://')
.replace(/^github:/, 'https://github.com/')
.replace(/^gitlab:/, 'https://gitlab.com/')
.replace(/^bitbucket:/, 'https://bitbucket.org/')
return /^[\w.-]+\/[\w.-]+$/.test(repo) ? `https://github.com/${repo}` : repo
}
type PackageResolution = { info: BundledPackage } | { error: string } | null
const packageCache = new Map<string, PackageResolution>()
// a module's package root is everything up to the first path segment (two
// for scoped packages) after the last node_modules/ in its id — this holds
// for pnpm's .pnpm layout too, where the real package dir always sits under
// a nested node_modules/
function resolveBundledPackage(moduleId: string): PackageResolution {
if (moduleId.startsWith('\0')) return null
const id = moduleId.replaceAll('\\', '/').replace(/\?.*$/, '')
const base = id.lastIndexOf('/node_modules/')
if (base === -1) return null
const segments = id.slice(base + '/node_modules/'.length).split('/')
const name =
segments[0][0] === '@' ? segments.slice(0, 2).join('/') : segments[0]
if (name[0] === '.') return null
const root = id.slice(0, base) + '/node_modules/' + name
const cached = packageCache.get(root)
if (cached !== undefined) return cached
const resolution = readPackageNotices(root, name)
packageCache.set(root, resolution)
return resolution
}
function readPackageNotices(root: string, dirName: string): PackageResolution {
let pkg: Record<string, any>
try {
pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
} catch {
return { error: `${dirName}: no readable package.json at ${root}` }
}
const name: string = pkg.name ?? dirName
const license: string | undefined =
typeof pkg.license === 'string'
? pkg.license
: (pkg.license?.type ??
(Array.isArray(pkg.licenses)
? pkg.licenses
.map((l: any) => (typeof l === 'string' ? l : l?.type))
.filter(Boolean)
.join(' OR ')
: undefined))
if (!license) {
return { error: `${name}: declares no license in its package.json` }
}
// an SPDX OR expression satisfies the policy if any alternative does; AND
// and WITH combine obligations, so those always need review as a whole
const alternatives = /\s(?:AND|WITH)\s/.test(license)
? []
: license.replace(/[()]/g, '').split(/\s+OR\s+/)
if (!alternatives.some((l) => PERMITTED_LICENSES.has(l.trim()))) {
return {
error:
`${name} is licensed under "${license}", which is not in ` +
'PERMITTED_LICENSES — review its obligations before extending the set'
}
}
const people = [
pkg.author,
...(Array.isArray(pkg.contributors) ? pkg.contributors : []),
...(Array.isArray(pkg.maintainers) ? pkg.maintainers : [])
]
.map(formatPerson)
.filter((p): p is string => !!p)
const authors = [...new Set(people)].join(', ') || AUTHOR_FALLBACKS[name]
const repositoryField =
typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url
const repository = repositoryField
? normalizeRepoUrl(repositoryField)
: undefined
const tryReadText = (file: string): string | undefined => {
try {
return readFileSync(path.join(root, file), 'utf8')
.replace(/^\uFEFF/, '')
.replaceAll('\r\n', '\n')
.trim()
} catch {
return undefined
}
}
let entries: string[]
try {
entries = readdirSync(root)
} catch {
entries = []
}
// NOTICE files carry their own propagation obligation and ride along even
// when a license file exists too
const noticeText =
entries
.filter((f) => /^notices?(?:$|[-._])/i.test(f))
.sort(compareStrings)
.map(tryReadText)
.filter(Boolean)
.join('\n\n') || undefined
const rank = (f: string) => (/^licen[cs]e/i.test(f) ? 0 : 1)
const licenseFile = entries
.filter((f) =>
/^(?:(?:un)?licen[cs]e|copying|(?:mit|bsd|apache|isc)[-_.]licen[cs]e)(?:$|[-._])/i.test(
f
)
)
.sort((a, b) => rank(a) - rank(b) || compareStrings(a, b))[0]
let licenseText = licenseFile ? tryReadText(licenseFile) : undefined
let reconstructed = false
if (!licenseText) {
const template = LICENSE_TEMPLATES[license]
if (!template || !authors) {
return {
error:
`${name} (${license}) ships no license text and it cannot be ` +
'reconstructed — vendor its license text in tsdown.config.ts'
}
}
licenseText = template(authors)
reconstructed = true
}
return {
info: {
name,
version: pkg.version,
license,
authors,
repository,
licenseText,
noticeText,
reconstructed
}
}
}
function renderNotices(packages: BundledPackage[]): string {
// group packages whose texts match byte-for-byte (same license wording
// and same copyright holder), so shared boilerplate appears once
interface NoticeGroup {
names: string[]
licenses: string[]
authors: string[]
repositories: string[]
licenseText: string
noticeText?: string
}
const groups = new Map<string, NoticeGroup>()
for (const p of packages) {
// texts without a copyright notice line (BlueOak, CC0, …) cannot name
// their holders themselves, so different holders must not merge — the
// By: line is the only attribution such an entry has
const hasHolder = /copyright\s+(?:\(c\)|©|[0-9])/i.test(p.licenseText)
const key =
p.licenseText +
'\0' +
(p.noticeText ?? '') +
(hasHolder ? '' : '\0' + (p.authors ?? p.name))
let group = groups.get(key)
if (!group) {
group = {
names: [],
licenses: [],
authors: [],
repositories: [],
licenseText: p.licenseText,
noticeText: p.noticeText
}
groups.set(key, group)
}
if (!group.names.includes(p.name)) group.names.push(p.name)
if (!group.licenses.includes(p.license)) group.licenses.push(p.license)
if (p.authors && !group.authors.includes(p.authors)) {
group.authors.push(p.authors)
}
if (p.repository && !group.repositories.includes(p.repository)) {
group.repositories.push(p.repository)
}
}
const sorted = [...groups.values()]
for (const g of sorted) {
g.names.sort(compareStrings)
g.licenses.sort(compareStrings)
g.authors.sort(compareStrings)
g.repositories.sort(compareStrings)
}
// same leading name can occur twice when two bundled versions of a package
// carry different texts; tie-break on the text to keep the order stable
sorted.sort(
(a, b) =>
compareStrings(a.names[0], b.names[0]) ||
compareStrings(a.licenseText, b.licenseText) ||
compareStrings(a.noticeText ?? '', b.noticeText ?? '')
)
const ids = [...new Set(packages.map((p) => p.license))].sort(compareStrings)
let out =
'# Third-party notices\n\n' +
'VitePress is published under the MIT license (see LICENSE). The ' +
'published vitepress package additionally bundles code and font files ' +
'from the projects listed below, used under the following licenses: ' +
`${ids.join(', ')}. The bundled code has been mechanically ` +
'transformed from its original source form (concatenated, tree-shaken, ' +
'and minified), and the font files are subsets of their original ' +
'releases.\n'
for (const g of sorted) {
out += `\n## ${g.names.join(', ')}\n\n`
out += `License: ${g.licenses.join(', ')}\n`
if (g.authors.length) out += `By: ${g.authors.join(', ')}\n`
if (g.repositories.length) {
out += `Repository: ${g.repositories.join(', ')}\n`
}
out += '\n' + blockquote(g.licenseText) + '\n'
if (g.noticeText) out += '\nNotice:\n\n' + blockquote(g.noticeText) + '\n'
}
return out
}
const collectedPackages = new Map<string, Map<string, BundledPackage>>()
// assigned where the configs are assembled at the bottom of this file; the
// notices file is only written once every config has reported, so building
// a lone config can never shrink it to a subset
let expectedOutputs = 0
// …and a build where some output never reports (a lone config, or drift in
// how the configs are assembled) must fail loudly instead of silently
// leaving a stale file behind. Watch mode never collects, so it never trips
// this.
process.on('beforeExit', () => {
if (collectedPackages.size > 0 && collectedPackages.size < expectedOutputs) {
console.error(
`${NOTICES_FILE} was not regenerated: only ` +
`${collectedPackages.size} of ${expectedOutputs} build outputs reported`
)
process.exitCode = 1
}
})
function thirdPartyNotices(): Rolldown.Plugin {
return {
name: 'vitepress:third-party-notices',
// default-order generateBundle runs before skipUnchanged's post handler
// prunes byte-identical files, so the module lists are still complete
generateBundle(options, bundle) {
// build-only: dev rebuilds shouldn't churn a tracked file — full
// builds and the check script keep it fresh
if (this.meta.watchMode) return
const errors = new Set<string>()
const found = new Map<string, BundledPackage>()
for (const file of Object.values(bundle)) {
if (file.type !== 'chunk') continue
for (const [id, mod] of Object.entries(file.modules)) {
// fully tree-shaken modules contribute nothing to the output
// (declaration chunks report real lengths too)
if (mod.renderedLength === 0) continue
const resolution = resolveBundledPackage(id)
if (!resolution) continue
if ('error' in resolution) {
errors.add(resolution.error)
continue
}
const { info } = resolution
found.set(`${info.name}@${info.version}`, info)
}
}
if (errors.size) {
this.error([...errors].sort(compareStrings).join('\n'))
}
collectedPackages.set(options.dir ?? '.', found)
if (collectedPackages.size < expectedOutputs) return
const union = new Map<string, BundledPackage>()
for (const packages of collectedPackages.values()) {
for (const [key, info] of packages) union.set(key, info)
}
const reconstructed = [...union.values()]
.filter((p) => p.reconstructed)
.map((p) => p.name)
.sort(compareStrings)
if (reconstructed.length) {
console.warn(
`license text for ${reconstructed.join(', ')} is reconstructed ` +
'from their manifests — they ship none'
)
}
const content = renderNotices([...VENDORED_PACKAGES, ...union.values()])
const dest = path.join(ROOT, NOTICES_FILE)
// same mtime-stability contract as skipUnchanged above
try {
if (readFileSync(dest, 'utf8') === content) return
} catch {}
writeFileSync(dest, content)
console.warn(`\n${NOTICES_FILE} updated — commit the regenerated file`)
}
}
}
function withStableOutputs(config: UserConfig): UserConfig {
return {
...config,
@ -177,6 +617,7 @@ function withStableOutputs(config: UserConfig): UserConfig {
plugins: [
...(config.plugins as Rolldown.Plugin[]),
fixDeclarationSpecifiers(),
thirdPartyNotices(),
skipUnchanged()
]
}
@ -238,4 +679,7 @@ const node: UserConfig = {
plugins: [syncShared('node'), rootTypesSpecifiers()]
}
export default defineConfig([client, node].map(withStableOutputs))
const configs = [client, node].map(withStableOutputs)
expectedOutputs = configs.length
export default defineConfig(configs)

Loading…
Cancel
Save