1export interface ThirdPartyLicenseEntry {
2 readonly bundles: ReadonlyArray<string>;
3 readonly kind: "custom" | "package";
4 readonly license: string;
5 readonly name: string;
6 readonly noticeText: string;
7 readonly sourceUrl: string | null;
8 readonly version: string | null;
9}
10
11export interface ThirdPartyLicenseManifest {
12 readonly schemaVersion: 1;
13 readonly entries: ReadonlyArray<ThirdPartyLicenseEntry>;
14}
15
16function isRecord(value: unknown): value is Record<string, unknown> {
17 return typeof value === "object" && value !== null && !Array.isArray(value);
18}
19
20function isStringArray(value: unknown): value is ReadonlyArray<string> {
21 return Array.isArray(value) && value.every((entry) => typeof entry === "string");
22}
23
24function isHttpUrl(value: unknown): value is string {
25 if (typeof value !== "string") return false;
26 try {
27 const protocol = new URL(value).protocol;
28 return protocol === "http:" || protocol === "https:";
29 } catch {
30 return false;
31 }
32}
33
34function decodeEntry(value: unknown, index: number): ThirdPartyLicenseEntry {
35 if (!isRecord(value)) {
36 throw new Error(`License entry ${String(index + 1)} is not an object.`);
37 }
38 if (
39 !isStringArray(value.bundles) ||
40 value.bundles.length === 0 ||
41 (value.kind !== "custom" && value.kind !== "package") ||
42 typeof value.license !== "string" ||
43 typeof value.name !== "string" ||
44 typeof value.noticeText !== "string" ||
45 (value.sourceUrl !== null && !isHttpUrl(value.sourceUrl)) ||
46 (value.version !== null && typeof value.version !== "string")
47 ) {
48 throw new Error(`License entry ${String(index + 1)} has an invalid shape.`);
49 }
50 return {
51 bundles: value.bundles,
52 kind: value.kind,
53 license: value.license,
54 name: value.name,
55 noticeText: value.noticeText,
56 sourceUrl: value.sourceUrl,
57 version: value.version,
58 };
59}
60
61export function decodeThirdPartyLicenseManifest(value: unknown): ThirdPartyLicenseManifest {
62 if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries)) {
63 throw new Error("The open-source license manifest has an unsupported format.");
64 }
65 const entries = value.entries.map(decodeEntry);
66 const entryKeys = new Set<string>();
67 for (const entry of entries) {
68 const key = thirdPartyLicenseEntryKey(entry);
69 if (entryKeys.has(key)) {
70 throw new Error(`The open-source license manifest contains a duplicate entry: ${key}`);
71 }
72 entryKeys.add(key);
73 }
74 return {
75 schemaVersion: 1,
76 entries,
77 };
78}
79
80export function filterThirdPartyLicenseEntries(
81 entries: ReadonlyArray<ThirdPartyLicenseEntry>,
82 query: string,
83): ReadonlyArray<ThirdPartyLicenseEntry> {
84 const terms = query
85 .trim()
86 .toLowerCase()
87 .split(/\s+/)
88 .filter((term) => term.length > 0);
89 if (terms.length === 0) return entries;
90 return entries.filter((entry) => {
91 const searchable = [entry.name, entry.version, entry.license, ...entry.bundles]
92 .filter((value): value is string => value !== null)
93 .join(" ")
94 .toLowerCase();
95 return terms.every((term) => searchable.includes(term));
96 });
97}
98
99const BUNDLE_LABELS: Readonly<Record<string, string>> = {
100 android: "Android",
101 assets: "Assets",
102 desktop: "Desktop",
103 "device-tools": "Device tools",
104 ios: "iOS",
105 mobile: "Mobile",
106 server: "Server",
107 web: "Web",
108};
109
110export function formatLicenseBundles(bundles: ReadonlyArray<string>): string {
111 return bundles
112 .map((bundle) =>
113 Object.prototype.hasOwnProperty.call(BUNDLE_LABELS, bundle) ? BUNDLE_LABELS[bundle] : bundle,
114 )
115 .join(", ");
116}
117
118export function thirdPartyLicenseEntryKey(entry: ThirdPartyLicenseEntry): string {
119 return encodeURIComponent(JSON.stringify([entry.kind, entry.name, entry.version]));
120}
121
122export function findThirdPartyLicenseEntry(
123 entries: ReadonlyArray<ThirdPartyLicenseEntry>,
124 key: string,
125): ThirdPartyLicenseEntry | undefined {
126 return entries.find((entry) => thirdPartyLicenseEntryKey(entry) === key);
127}